diff --git a/.travis.yml b/.travis.yml index 5561447b5..d1f0310c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -85,7 +85,7 @@ matrix: - "3.6" script: - mypy openslides/ tests/ - - pytest --cov --cov-fail-under=72 + - pytest --cov --cov-fail-under=75 - name: "Server: Tests Python 3.7" language: python @@ -96,7 +96,7 @@ matrix: - isort --check-only --diff --recursive openslides tests - black --check --diff --target-version py36 openslides tests - mypy openslides/ tests/ - - pytest --cov --cov-fail-under=72 + - pytest --cov --cov-fail-under=75 - name: "Server: Tests Python 3.8" language: python @@ -107,7 +107,7 @@ matrix: - isort --check-only --diff --recursive openslides tests - black --check --diff --target-version py36 openslides tests - mypy openslides/ tests/ - - pytest --cov --cov-fail-under=72 + - pytest --cov --cov-fail-under=75 - name: "Client: Linting" language: node_js diff --git a/client/package.json b/client/package.json index 8624fa96d..996c54bda 100644 --- a/client/package.json +++ b/client/package.json @@ -31,79 +31,81 @@ "cleanup-win": "npm run prettify-write & npm run lint-write" }, "dependencies": { - "@angular/animations": "~8.2.4", + "@angular/animations": "^8.2.14", "@angular/cdk": "~8.1.4", "@angular/cdk-experimental": "~8.1.4", - "@angular/common": "~8.2.4", - "@angular/compiler": "~8.2.4", - "@angular/core": "~8.2.4", - "@angular/forms": "~8.2.4", + "@angular/common": "^8.2.14", + "@angular/compiler": "^8.2.14", + "@angular/core": "^8.2.14", + "@angular/forms": "^8.2.14", "@angular/material": "~8.1.4", "@angular/material-moment-adapter": "~8.1.4", - "@angular/platform-browser": "~8.2.4", - "@angular/platform-browser-dynamic": "~8.2.4", - "@angular/pwa": "^0.803.1", - "@angular/router": "~8.2.4", - "@angular/service-worker": "~8.2.4", - "@ngx-pwa/local-storage": "~8.2.1", + "@angular/platform-browser": "^8.2.14", + "@angular/platform-browser-dynamic": "^8.2.14", + "@angular/pwa": "^0.803.23", + "@angular/router": "^8.2.14", + "@angular/service-worker": "^8.2.14", + "@ngx-pwa/local-storage": "^8.2.4", "@ngx-translate/core": "~11.0.1", "@ngx-translate/http-loader": "^4.0.0", "@pebula/ngrid": "1.0.0-rc.16", "@pebula/ngrid-material": "1.0.0-rc.16", "@pebula/utils": "1.0.2", - "@tinymce/tinymce-angular": "^3.2.0", - "acorn": "^7.0.0", - "core-js": "^3.2.1", - "css-element-queries": "^1.2.1", + "@tinymce/tinymce-angular": "^3.3.1", + "acorn": "^7.1.0", + "chart.js": "^2.9.2", + "core-js": "^3.6.4", + "css-element-queries": "^1.2.3", "exceljs": "1.15.0", "file-saver": "^2.0.2", "hammerjs": "^2.0.8", "lz4js": "^0.2.0", "material-icon-font": "git+https://github.com/petergng/materialIconFont.git", "moment": "^2.24.0", + "ng2-charts": "^2.3.0", "ng2-pdf-viewer": "^5.3.4", - "ngx-file-drop": "~8.0.7", + "ngx-file-drop": "^8.0.8", "ngx-mat-select-search": "^1.8.0", "ngx-material-timepicker": "^4.0.2", "ngx-papaparse": "^4.0.2", - "pdfmake": "^0.1.58", - "po2json": "^1.0.0-alpha", - "rxjs": "^6.5.2", - "tinymce": "^5.0.14", + "pdfmake": "^0.1.63", + "po2json": "^1.0.0-beta-2", + "rxjs": "^6.5.4", + "tinymce": "^5.1.5", "tslib": "^1.10.0", - "uuid": "^3.3.2", + "uuid": "^3.3.3", "zone.js": "~0.9.1" }, "devDependencies": { - "@angular-devkit/build-angular": "~0.803.2", - "@angular/cli": "~8.3.2", - "@angular/compiler-cli": "~8.2.4", - "@angular/language-service": "~8.2.4", + "@angular-devkit/build-angular": "^0.803.23", + "@angular/cli": "^8.3.23", + "@angular/compiler-cli": "^8.2.14", + "@angular/language-service": "^8.2.14", "@biesbjerg/ngx-translate-extract": "^3.0.5", - "@compodoc/compodoc": "^1.1.8", - "@types/jasmine": "^3.3.9", - "@types/jasminewd2": "^2.0.6", - "@types/node": "~12.7.2", - "@types/yargs": "^13.0.0", - "codelyzer": "^5.0.1", - "husky": "^3.0.4", + "@compodoc/compodoc": "^1.1.11", + "@types/jasmine": "^3.5.0", + "@types/jasminewd2": "^2.0.8", + "@types/node": "^12.7.12", + "@types/yargs": "^13.0.5", + "codelyzer": "^5.2.1", + "husky": "^3.1.0", "jasmine-core": "~3.4.0", "jasmine-spec-reporter": "~4.2.1", - "karma": "^4.1.0", + "karma": "^4.4.1", "karma-chrome-launcher": "~3.1.0", - "karma-coverage-istanbul-reporter": "^2.0.5", + "karma-coverage-istanbul-reporter": "^2.1.1", "karma-jasmine": "~2.0.1", - "karma-jasmine-html-reporter": "^1.4.0", + "karma-jasmine-html-reporter": "^1.5.1", "npm-license-crawler": "^0.2.1", "npm-run-all": "^4.1.5", "prettier": "^1.19.1", "protractor": "^5.4.2", "resize-observer-polyfill": "^1.5.1", - "source-map-explorer": "^2.0.1", + "source-map-explorer": "^2.2.2", "ts-node": "~8.3.0", "tslint": "~5.19.0", "tsutils": "3.17.1", "typescript": "~3.5.3", - "webpack-bundle-analyzer": "^3.3.2" + "webpack-bundle-analyzer": "^3.6.0" } } diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss index 687fbe074..395d04e7f 100644 --- a/client/src/app/app.component.scss +++ b/client/src/app/app.component.scss @@ -1,3 +1,4 @@ .content { flex: 1; + height: 100vh; } diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index ba028738a..a01719ec2 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -17,6 +17,7 @@ import { PrioritizeService } from './core/core-services/prioritize.service'; import { RoutingStateService } from './core/ui-services/routing-state.service'; import { ServertimeService } from './core/core-services/servertime.service'; import { ThemeService } from './core/ui-services/theme.service'; +import { VotingBannerService } from './core/ui-services/voting-banner.service'; declare global { /** @@ -25,6 +26,12 @@ declare global { */ interface Array { flatMap(o: any): any[]; + intersect(a: T[]): T[]; + mapToObject(f: (item: T) => { [key: string]: any }): { [key: string]: any }; + } + + interface Set { + equals(other: Set): boolean; } /** @@ -79,7 +86,8 @@ export class AppComponent { dataStoreUpgradeService: DataStoreUpgradeService, // to start it. prioritizeService: PrioritizeService, pingService: PingService, - routingState: RoutingStateService + routingState: RoutingStateService, + votingBannerService: VotingBannerService // needed for initialisation ) { // manually add the supported languages translate.addLangs(['en', 'de', 'cs', 'ru']); @@ -91,8 +99,8 @@ export class AppComponent { translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en'); // change default JS functions - this.overloadArrayToString(); - this.overloadFlatMap(); + this.overloadArrayFunctions(); + this.overloadSetFunctions(); this.overloadModulo(); // Wait until the App reaches a stable state. @@ -106,15 +114,7 @@ export class AppComponent { .subscribe(() => servertimeService.startScheduler()); } - /** - * Function to alter the normal Array.toString - function - * - * Will add a whitespace after a comma and shorten the output to - * three strings. - * - * TODO: Should be renamed - */ - private overloadArrayToString(): void { + private overloadArrayFunctions(): void { Object.defineProperty(Array.prototype, 'toString', { value: function(): string { let string = ''; @@ -135,13 +135,7 @@ export class AppComponent { }, enumerable: false }); - } - /** - * Adds an implementation of flatMap. - * TODO: Remove once flatMap made its way into official JS/TS (ES 2019?) - */ - private overloadFlatMap(): void { Object.defineProperty(Array.prototype, 'flatMap', { value: function(o: any): any[] { const concatFunction = (x: any, y: any[]) => x.concat(y); @@ -150,6 +144,54 @@ export class AppComponent { }, enumerable: false }); + + Object.defineProperty(Array.prototype, 'intersect', { + value: function(other: T[]): T[] { + let a = this; + let b = other; + // indexOf to loop over shorter + if (b.length > a.length) { + [a, b] = [b, a]; + } + return a.filter(e => b.indexOf(e) > -1); + }, + enumerable: false + }); + + Object.defineProperty(Array.prototype, 'mapToObject', { + value: function(f: (item: T) => { [key: string]: any }): { [key: string]: any } { + return this.reduce((aggr, item) => { + const res = f(item); + for (const key in res) { + if (res.hasOwnProperty(key)) { + aggr[key] = res[key]; + } + } + return aggr; + }, {}); + }, + enumerable: false + }); + } + + /** + * Adds some functions to Set. + */ + private overloadSetFunctions(): void { + Object.defineProperty(Set.prototype, 'equals', { + value: function(other: Set): boolean { + const difference = new Set(this); + for (const elem of other) { + if (difference.has(elem)) { + difference.delete(elem); + } else { + return false; + } + } + return !difference.size; + }, + enumerable: false + }); } /** diff --git a/client/src/app/core/core-services/app-load.service.ts b/client/src/app/core/core-services/app-load.service.ts index a05b6b074..97728f430 100644 --- a/client/src/app/core/core-services/app-load.service.ts +++ b/client/src/app/core/core-services/app-load.service.ts @@ -68,15 +68,10 @@ export class AppLoadService { let repository: BaseRepository = null; repository = this.injector.get(entry.repository); repositories.push(repository); - this.modelMapper.registerCollectionElement( - entry.collectionString, - entry.model, - entry.viewModel, - repository - ); + this.modelMapper.registerCollectionElement(entry.model, entry.viewModel, repository); if (this.isSearchableModelEntry(entry)) { this.searchService.registerModel( - entry.collectionString, + entry.model.COLLECTIONSTRING, repository, entry.searchOrder, entry.openInNewTab @@ -108,7 +103,7 @@ export class AppLoadService { // to check if the result of the contructor (the model instance) is really a searchable. if (!isSearchable(new entry.viewModel())) { throw Error( - `Wrong configuration for ${entry.collectionString}: you gave a searchOrder, but the model is not searchable.` + `Wrong configuration for ${entry.model.COLLECTIONSTRING}: you gave a searchOrder, but the model is not searchable.` ); } return true; diff --git a/client/src/app/core/core-services/collection-string-mapper.service.ts b/client/src/app/core/core-services/collection-string-mapper.service.ts index b1dab001e..3ba7cd8bb 100644 --- a/client/src/app/core/core-services/collection-string-mapper.service.ts +++ b/client/src/app/core/core-services/collection-string-mapper.service.ts @@ -47,12 +47,11 @@ export class CollectionStringMapperService { * @param model */ public registerCollectionElement, M extends BaseModel>( - collectionString: string, model: ModelConstructor, viewModel: ViewModelConstructor, repository: BaseRepository ): void { - this.collectionStringMapping[collectionString] = [model, viewModel, repository]; + this.collectionStringMapping[model.COLLECTIONSTRING] = [model, viewModel, repository]; } /** diff --git a/client/src/app/core/core-services/offline.service.ts b/client/src/app/core/core-services/offline.service.ts index b8d6a238f..e869a7fb9 100644 --- a/client/src/app/core/core-services/offline.service.ts +++ b/client/src/app/core/core-services/offline.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Observable } from 'rxjs'; +import { _ } from 'app/core/translate/translation-marker'; +import { BannerDefinition, BannerService } from '../ui-services/banner.service'; + /** * This service handles everything connected with being offline. * @@ -16,6 +20,16 @@ export class OfflineService { * BehaviorSubject to receive further status values. */ private offline = new BehaviorSubject(false); + private bannerDefinition: BannerDefinition = { + text: _('Offline mode'), + icon: 'cloud_off' + }; + + public constructor(private banner: BannerService, translate: TranslateService) { + translate.onLangChange.subscribe(() => { + this.bannerDefinition.text = translate.instant(this.bannerDefinition.text); + }); + } /** * Determines of you are either in Offline mode or not connected via websocket @@ -33,7 +47,7 @@ export class OfflineService { if (!this.offline.getValue()) { console.log('offline because whoami failed.'); } - this.offline.next(true); + this.goOffline(); } /** @@ -43,7 +57,15 @@ export class OfflineService { if (!this.offline.getValue()) { console.log('offline because connection lost.'); } + this.goOffline(); + } + + /** + * Helper function to set offline status + */ + private goOffline(): void { this.offline.next(true); + this.banner.addBanner(this.bannerDefinition); } /** @@ -51,5 +73,6 @@ export class OfflineService { */ public goOnline(): void { this.offline.next(false); + this.banner.removeBanner(this.bannerDefinition); } } diff --git a/client/src/app/core/core-services/openslides-status.service.ts b/client/src/app/core/core-services/openslides-status.service.ts index 0829c761c..90594a0ea 100644 --- a/client/src/app/core/core-services/openslides-status.service.ts +++ b/client/src/app/core/core-services/openslides-status.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { History } from 'app/shared/models/core/history'; +import { BannerDefinition, BannerService } from '../ui-services/banner.service'; /** * Holds information about OpenSlides. This is not included into other services to @@ -14,6 +15,9 @@ export class OpenSlidesStatusService { * in History mode, saves the history point. */ private history: History = null; + private bannerDefinition: BannerDefinition = { + type: 'history' + }; /** * Returns, if OpenSlides is in the history mode. @@ -27,7 +31,7 @@ export class OpenSlidesStatusService { /** * Ctor, does nothing. */ - public constructor() {} + public constructor(private banner: BannerService) {} /** * Calls the getLocaleString function of the history object, if present. @@ -44,6 +48,7 @@ export class OpenSlidesStatusService { */ public enterHistoryMode(history: History): void { this.history = history; + this.banner.addBanner(this.bannerDefinition); } /** @@ -51,5 +56,6 @@ export class OpenSlidesStatusService { */ public leaveHistoryMode(): void { this.history = null; + this.banner.removeBanner(this.bannerDefinition); } } diff --git a/client/src/app/core/core-services/relation-manager.service.ts b/client/src/app/core/core-services/relation-manager.service.ts index d3de55fa7..aee102088 100644 --- a/client/src/app/core/core-services/relation-manager.service.ts +++ b/client/src/app/core/core-services/relation-manager.service.ts @@ -98,6 +98,17 @@ export class RelationManagerService { viewModel: BaseViewModel, relation: RelationDefinition ): any { + // No cache for reverse relations. + // The issue: we cannot invalidate the cache, if a new object is created (The + // following example is for a O2M foreign relation): + // There is no possibility to detect the create case: The target does not update, + // all related models does not update. The autoupdate does not provide the created- + // information. So we may check, if the relaten has changed in length every time. But + // this is the same as just resolving the relation every time it is requested. So no cache here. + if (isReverseRelationDefinition(relation)) { + return this.handleRelation(model, viewModel, relation) as BaseViewModel | BaseViewModel[]; + } + let result: any; const cacheProperty = '__' + property; @@ -187,12 +198,24 @@ export class RelationManagerService { const _model: M = target.getModel(); const relation = typeof property === 'string' ? relationsByKey[property] : null; + // try to find a getter for property if (property in target) { - const descriptor = Object.getOwnPropertyDescriptor(viewModelCtor.prototype, property); + // iterate over prototype chain + let prototypeFunc = viewModelCtor, + descriptor = null; + do { + descriptor = Object.getOwnPropertyDescriptor(prototypeFunc.prototype, property); + if (!descriptor || !descriptor.get) { + prototypeFunc = Object.getPrototypeOf(prototypeFunc); + } + } while (!(descriptor && descriptor.get) && prototypeFunc && prototypeFunc.prototype); + if (descriptor && descriptor.get) { + // if getter was found in prototype chain, bind it with this proxy for right `this` access result = descriptor.get.bind(viewModel)(); } else { result = target[property]; + // console.log(property, target); } } else if (property in _model) { result = _model[property]; diff --git a/client/src/app/core/definitions/app-config.ts b/client/src/app/core/definitions/app-config.ts index 412671363..cdfafe010 100644 --- a/client/src/app/core/definitions/app-config.ts +++ b/client/src/app/core/definitions/app-config.ts @@ -7,7 +7,6 @@ import { MainMenuEntry } from '../core-services/main-menu.service'; import { Searchable } from '../../site/base/searchable'; interface BaseModelEntry { - collectionString: string; repository: Type>; model: ModelConstructor; } diff --git a/client/src/app/core/definitions/has-view-model-list-observable.ts b/client/src/app/core/definitions/has-view-model-list-observable.ts new file mode 100644 index 000000000..446bacadb --- /dev/null +++ b/client/src/app/core/definitions/has-view-model-list-observable.ts @@ -0,0 +1,5 @@ +import { Observable } from 'rxjs'; + +export interface HasViewModelListObservable { + getViewModelListObservable(): Observable; +} diff --git a/client/src/app/core/repositories/assignments/assignment-option-repository.service.spec.ts b/client/src/app/core/repositories/assignments/assignment-option-repository.service.spec.ts new file mode 100644 index 000000000..727666b3b --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-option-repository.service.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentOptionRepositoryService } from './assignment-option-repository.service'; + +describe('AssignmentOptionRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); + + it('should be created', () => { + const service: AssignmentOptionRepositoryService = TestBed.get(AssignmentOptionRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/repositories/assignments/assignment-option-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-option-repository.service.ts new file mode 100644 index 000000000..f757e82b7 --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-option-repository.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DataSendService } from 'app/core/core-services/data-send.service'; +import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { RelationDefinition } from 'app/core/definitions/relations'; +import { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; +import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; +import { ViewAssignmentVote } from 'app/site/assignments/models/view-assignment-vote'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BaseRepository } from '../base-repository'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +const AssignmentOptionRelations: RelationDefinition[] = [ + { + type: 'O2M', + foreignIdKey: 'option_id', + ownKey: 'votes', + foreignViewModel: ViewAssignmentVote + }, + { + type: 'M2O', + ownIdKey: 'poll_id', + ownKey: 'poll', + foreignViewModel: ViewAssignmentPoll + }, + { + type: 'M2O', + ownIdKey: 'user_id', + ownKey: 'user', + foreignViewModel: ViewUser + } +]; + +/** + * Repository Service for Options. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class AssignmentOptionRepositoryService extends BaseRepository { + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + relationManager: RelationManagerService + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + AssignmentOption, + AssignmentOptionRelations + ); + } + + public getTitle = (titleInformation: object) => { + return 'Option'; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Options' : 'Option'); + }; +} diff --git a/client/src/app/core/repositories/assignments/assignment-poll-repository.service.spec.ts b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.spec.ts new file mode 100644 index 000000000..7173774f4 --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentPollRepositoryService } from './assignment-poll-repository.service'; + +describe('AssignmentPollRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); + + it('should be created', () => { + const service: AssignmentPollRepositoryService = TestBed.get(AssignmentPollRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts new file mode 100644 index 000000000..d05b9d2ee --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts @@ -0,0 +1,136 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DataSendService } from 'app/core/core-services/data-send.service'; +import { HttpService } from 'app/core/core-services/http.service'; +import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { RelationDefinition } from 'app/core/definitions/relations'; +import { VotingService } from 'app/core/ui-services/voting.service'; +import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; +import { ViewAssignment } from 'app/site/assignments/models/view-assignment'; +import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; +import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; +import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +const AssignmentPollRelations: RelationDefinition[] = [ + { + type: 'M2M', + ownIdKey: 'groups_id', + ownKey: 'groups', + foreignViewModel: ViewGroup + }, + { + type: 'O2M', + ownIdKey: 'options_id', + ownKey: 'options', + foreignViewModel: ViewAssignmentOption + }, + { + type: 'M2O', + ownIdKey: 'assignment_id', + ownKey: 'assignment', + foreignViewModel: ViewAssignment + }, + { + type: 'M2M', + ownIdKey: 'voted_id', + ownKey: 'voted', + foreignViewModel: ViewUser + } +]; + +export interface AssignmentAnalogVoteData { + options: { + [key: number]: { + Y: number; + N?: number; + A?: number; + }; + }; + votesvalid?: number; + votesinvalid?: number; + votescast?: number; + global_no?: number; + global_abstain?: number; +} + +export interface VotingData { + votes: Object; + global?: GlobalVote; +} + +export type GlobalVote = 'A' | 'N'; + +/** + * Repository Service for Assignments. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class AssignmentPollRepositoryService extends BasePollRepositoryService< + ViewAssignmentPoll, + AssignmentPoll, + AssignmentPollTitleInformation +> { + /** + * Constructor for the Assignment Repository. + * + * @param DS DataStore access + * @param dataSend Sending data + * @param mapperService Map models to object + * @param viewModelStoreService Access view models + * @param translate Translate string + * @param httpService make HTTP Requests + */ + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + relationManager: RelationManagerService, + votingService: VotingService, + http: HttpService + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + AssignmentPoll, + AssignmentPollRelations, + {}, + votingService, + http + ); + } + + public getTitle = (titleInformation: AssignmentPollTitleInformation) => { + return titleInformation.title; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Polls' : 'Poll'); + }; + + public vote(data: VotingData, poll_id: number): Promise { + let requestData; + if (data.global) { + requestData = `"${data.global}"`; + } else { + requestData = data.votes; + } + + return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, requestData); + } +} diff --git a/client/src/app/core/repositories/assignments/assignment-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-repository.service.ts index dea62667b..764a181d7 100644 --- a/client/src/app/core/repositories/assignments/assignment-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-repository.service.ts @@ -8,12 +8,9 @@ import { RelationManagerService } from 'app/core/core-services/relation-manager. import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { RelationDefinition } from 'app/core/definitions/relations'; import { Assignment } from 'app/shared/models/assignments/assignment'; -import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; -import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option'; import { AssignmentRelatedUser } from 'app/shared/models/assignments/assignment-related-user'; import { AssignmentTitleInformation, ViewAssignment } from 'app/site/assignments/models/view-assignment'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; -import { ViewAssignmentPollOption } from 'app/site/assignments/models/view-assignment-poll-option'; import { ViewAssignmentRelatedUser } from 'app/site/assignments/models/view-assignment-related-user'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { ViewTag } from 'app/site/tags/models/view-tag'; @@ -35,6 +32,12 @@ const AssignmentRelations: RelationDefinition[] = [ ownIdKey: 'attachments_id', ownKey: 'attachments', foreignViewModel: ViewMediafile + }, + { + type: 'O2M', + ownKey: 'polls', + foreignIdKey: 'assignment_id', + foreignViewModel: ViewAssignmentPoll } ]; @@ -57,28 +60,6 @@ const AssignmentNestedModelDescriptors: NestedModelDescriptors = { getTitle: (viewAssignmentRelatedUser: ViewAssignmentRelatedUser) => viewAssignmentRelatedUser.user ? viewAssignmentRelatedUser.user.getFullName() : '' } - }, - { - ownKey: 'polls', - foreignViewModel: ViewAssignmentPoll, - foreignModel: AssignmentPoll, - relationDefinitionsByKey: {} - } - ], - 'assignments/assignment-poll': [ - { - ownKey: 'options', - foreignViewModel: ViewAssignmentPollOption, - foreignModel: AssignmentPollOption, - order: 'weight', - relationDefinitionsByKey: { - user: { - type: 'M2O', - ownIdKey: 'candidate_id', - ownKey: 'user', - foreignViewModel: ViewUser - } - } } ] }; @@ -97,11 +78,8 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake AssignmentTitleInformation > { private readonly restPath = '/rest/assignments/assignment/'; - private readonly restPollPath = '/rest/assignments/poll/'; private readonly candidatureOtherPath = '/candidature_other/'; private readonly candidatureSelfPath = '/candidature_self/'; - private readonly createPollPath = '/create_poll/'; - private readonly markElectedPath = '/mark_elected/'; /** * Constructor for the Assignment Repository. @@ -179,87 +157,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath); } - /** - * Creates a new Poll to a given assignment - * - * @param assignment The assignment to add the poll to - */ - public async addPoll(assignment: ViewAssignment): Promise { - await this.httpService.post(this.restPath + assignment.id + this.createPollPath); - // TODO: change current tab to new poll - } - - /** - * Deletes a poll - * - * @param id id of the poll to delete - */ - public async deletePoll(poll: ViewAssignmentPoll): Promise { - await this.httpService.delete(`${this.restPollPath}${poll.id}/`); - } - - /** - * update data (metadata etc) for a poll - * - * @param poll the (partial) data to update - * @param originalPoll the poll to update - * - * TODO: check if votes is untouched - */ - public async updatePoll(poll: Partial, originalPoll: ViewAssignmentPoll): Promise { - const data: AssignmentPoll = Object.assign(originalPoll.poll, poll); - await this.httpService.patch(`${this.restPollPath}${originalPoll.id}/`, data); - } - - /** - * TODO: temporary (?) update votes method. Needed because server needs - * different input than it's output in case of votes ? - * - * @param poll the updated Poll - * @param originalPoll the original poll - */ - public async updateVotes(poll: Partial, originalPoll: ViewAssignmentPoll): Promise { - const votes = poll.options.map(option => { - const voteObject = {}; - for (const vote of option.votes) { - voteObject[vote.value] = vote.weight; - } - return voteObject; - }); - - const data = { - assignment_id: originalPoll.assignment_id, - votes: votes, - votesabstain: poll.votesabstain || null, - votescast: poll.votescast || null, - votesinvalid: poll.votesinvalid || null, - votesno: poll.votesno || null, - votesvalid: poll.votesvalid || null - }; - - await this.httpService.put(`${this.restPollPath}${originalPoll.id}/`, data); - } - - /** - * change the 'elected' state of an election candidate - * - * @param assignmentRelatedUser - * @param assignment - * @param elected true if the candidate is to be elected, false if unelected - */ - public async markElected( - assignmentRelatedUser: ViewAssignmentRelatedUser, - assignment: ViewAssignment, - elected: boolean - ): Promise { - const data = { user: assignmentRelatedUser.user_id }; - if (elected) { - await this.httpService.post(this.restPath + assignment.id + this.markElectedPath, data); - } else { - await this.httpService.delete(this.restPath + assignment.id + this.markElectedPath, data); - } - } - /** * Sends a request to sort an assignment's candidates * diff --git a/client/src/app/core/repositories/assignments/assignment-vote-repository.service.spec.ts b/client/src/app/core/repositories/assignments/assignment-vote-repository.service.spec.ts new file mode 100644 index 000000000..665e212ff --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-vote-repository.service.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentVoteRepositoryService } from './assignment-vote-repository.service'; + +describe('AssignmentVoteRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); + + it('should be created', () => { + const service: AssignmentVoteRepositoryService = TestBed.get(AssignmentVoteRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts new file mode 100644 index 000000000..a77c14f4c --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DataSendService } from 'app/core/core-services/data-send.service'; +import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { RelationDefinition } from 'app/core/definitions/relations'; +import { AssignmentVote } from 'app/shared/models/assignments/assignment-vote'; +import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; +import { ViewAssignmentVote } from 'app/site/assignments/models/view-assignment-vote'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BaseRepository } from '../base-repository'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +const AssignmentVoteRelations: RelationDefinition[] = [ + { + type: 'M2O', + ownIdKey: 'user_id', + ownKey: 'user', + foreignViewModel: ViewUser + }, + { + type: 'M2O', + ownIdKey: 'option_id', + ownKey: 'option', + foreignViewModel: ViewAssignmentOption + } +]; + +/** + * Repository Service for Assignments. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class AssignmentVoteRepositoryService extends BaseRepository { + /** + * @param DS DataStore access + * @param dataSend Sending data + * @param mapperService Map models to object + * @param viewModelStoreService Access view models + * @param translate Translate string + * @param httpService make HTTP Requests + */ + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + relationManager: RelationManagerService + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + AssignmentVote, + AssignmentVoteRelations + ); + } + + public getTitle = (titleInformation: object) => { + return 'Vote'; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Votes' : 'Vote'); + }; + + public getVotesForUser(pollId: number, userId: number): ViewAssignmentVote[] { + return this.getViewModelList().filter(vote => vote.option.poll_id === pollId && vote.user_id === userId); + } +} diff --git a/client/src/app/core/repositories/base-repository.ts b/client/src/app/core/repositories/base-repository.ts index 3d59f2272..942909904 100644 --- a/client/src/app/core/repositories/base-repository.ts +++ b/client/src/app/core/repositories/base-repository.ts @@ -8,6 +8,7 @@ import { BaseViewModel, TitleInformation, ViewModelConstructor } from '../../sit import { CollectionStringMapperService } from '../core-services/collection-string-mapper.service'; import { DataSendService } from '../core-services/data-send.service'; import { DataStoreService } from '../core-services/data-store.service'; +import { HasViewModelListObservable } from '../definitions/has-view-model-list-observable'; import { Identifiable } from '../../shared/models/base/identifiable'; import { OnAfterAppsLoaded } from '../definitions/on-after-apps-loaded'; import { RelationManagerService } from '../core-services/relation-manager.service'; @@ -30,7 +31,7 @@ export interface NestedModelDescriptors { } export abstract class BaseRepository - implements OnAfterAppsLoaded, Collection { + implements OnAfterAppsLoaded, Collection, HasViewModelListObservable { /** * Stores all the viewModel in an object */ @@ -42,8 +43,8 @@ export abstract class BaseRepository } = {}; /** - * Observable subject for the whole list. These entries are unsorted an not piped through - * autodTime. Just use this internally. + * Observable subject for the whole list. These entries are unsorted and not piped through + * auditTime. Just use this internally. * * It's used to debounce messages on the sortedViewModelListSubject */ @@ -188,7 +189,7 @@ export abstract class BaseRepository number = (a: V, b: V) => a.id - b.id; diff --git a/client/src/app/core/repositories/motions/motion-option-repository.service.spec.ts b/client/src/app/core/repositories/motions/motion-option-repository.service.spec.ts new file mode 100644 index 000000000..84bea42fc --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-option-repository.service.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionOptionRepositoryService } from './motion-option-repository.service'; + +describe('MotionOptionRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); + + it('should be created', () => { + const service: MotionOptionRepositoryService = TestBed.get(MotionOptionRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/repositories/motions/motion-option-repository.service.ts b/client/src/app/core/repositories/motions/motion-option-repository.service.ts new file mode 100644 index 000000000..f55014f43 --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-option-repository.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DataSendService } from 'app/core/core-services/data-send.service'; +import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { RelationDefinition } from 'app/core/definitions/relations'; +import { MotionOption } from 'app/shared/models/motions/motion-option'; +import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote'; +import { BaseRepository } from '../base-repository'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +const MotionOptionRelations: RelationDefinition[] = [ + { + type: 'O2M', + foreignIdKey: 'option_id', + ownKey: 'votes', + foreignViewModel: ViewMotionVote + }, + { + type: 'M2O', + ownIdKey: 'poll_id', + ownKey: 'poll', + foreignViewModel: ViewMotionPoll + } +]; + +/** + * Repository Service for Options. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionOptionRepositoryService extends BaseRepository { + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + relationManager: RelationManagerService + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + MotionOption, + MotionOptionRelations + ); + } + + public getTitle = (titleInformation: object) => { + return 'Option'; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Options' : 'Option'); + }; +} diff --git a/client/src/app/core/repositories/motions/motion-poll-repository.service.spec.ts b/client/src/app/core/repositories/motions/motion-poll-repository.service.spec.ts new file mode 100644 index 000000000..95c88a660 --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-poll-repository.service.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionPollRepositoryService } from './motion-poll-repository.service'; + +describe('MotionPollRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); + + it('should be created', () => { + const service: MotionPollRepositoryService = TestBed.get(MotionPollRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/repositories/motions/motion-poll-repository.service.ts b/client/src/app/core/repositories/motions/motion-poll-repository.service.ts new file mode 100644 index 000000000..87de50df6 --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-poll-repository.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DataSendService } from 'app/core/core-services/data-send.service'; +import { HttpService } from 'app/core/core-services/http.service'; +import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { RelationDefinition } from 'app/core/definitions/relations'; +import { VotingService } from 'app/core/ui-services/voting.service'; +import { MotionPoll } from 'app/shared/models/motions/motion-poll'; +import { VoteValue } from 'app/shared/models/poll/base-vote'; +import { ViewMotion } from 'app/site/motions/models/view-motion'; +import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; +import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +const MotionPollRelations: RelationDefinition[] = [ + { + type: 'M2M', + ownIdKey: 'groups_id', + ownKey: 'groups', + foreignViewModel: ViewGroup + }, + { + type: 'O2M', + ownIdKey: 'options_id', + ownKey: 'options', + foreignViewModel: ViewMotionOption + }, + { + type: 'M2O', + ownIdKey: 'motion_id', + ownKey: 'motion', + foreignViewModel: ViewMotion + }, + { + type: 'M2M', + ownIdKey: 'voted_id', + ownKey: 'voted', + foreignViewModel: ViewUser + } +]; + +/** + * Repository Service for Assignments. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionPollRepositoryService extends BasePollRepositoryService< + ViewMotionPoll, + MotionPoll, + MotionPollTitleInformation +> { + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + relationManager: RelationManagerService, + votingService: VotingService, + http: HttpService + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + MotionPoll, + MotionPollRelations, + {}, + votingService, + http + ); + } + + public getTitle = (titleInformation: MotionPollTitleInformation) => { + return titleInformation.title; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Polls' : 'Poll'); + }; + + public vote(vote: VoteValue, poll_id: number): Promise { + return this.http.post(`/rest/motions/motion-poll/${poll_id}/vote/`, JSON.stringify(vote)); + } +} diff --git a/client/src/app/core/repositories/motions/motion-repository.service.ts b/client/src/app/core/repositories/motions/motion-repository.service.ts index 2cad3fbf4..b9f4af858 100644 --- a/client/src/app/core/repositories/motions/motion-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-repository.service.ts @@ -14,7 +14,6 @@ import { ConfigService } from 'app/core/ui-services/config.service'; import { DiffLinesInParagraph, DiffService } from 'app/core/ui-services/diff.service'; import { TreeIdNode } from 'app/core/ui-services/tree.service'; import { Motion } from 'app/shared/models/motions/motion'; -import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { Submitter } from 'app/shared/models/motions/submitter'; import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change'; import { PersonalNoteContent } from 'app/shared/models/users/personal-note'; @@ -24,6 +23,7 @@ import { MotionTitleInformation, ViewMotion } from 'app/site/motions/models/view import { ViewMotionAmendedParagraph } from 'app/site/motions/models/view-motion-amended-paragraph'; import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block'; import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewState } from 'app/site/motions/models/view-state'; import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph'; import { ViewSubmitter } from 'app/site/motions/models/view-submitter'; @@ -126,12 +126,17 @@ const MotionRelations: RelationDefinition[] = [ ownKey: 'amendments', foreignViewModel: ViewMotion }, - // TMP: { type: 'M2O', ownIdKey: 'parent_id', ownKey: 'parent', foreignViewModel: ViewMotion + }, + { + type: 'O2M', + foreignIdKey: 'motion_id', + ownKey: 'polls', + foreignViewModel: ViewMotionPoll } // Personal notes are dynamically added in the repo. ]; @@ -844,46 +849,6 @@ export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersCo .filter((para: ViewMotionAmendedParagraph) => para !== null); } - /** - * Sends a request to the server, creating a new poll for the motion - */ - public async createPoll(motion: ViewMotion): Promise { - const url = '/rest/motions/motion/' + motion.id + '/create_poll/'; - await this.httpService.post(url); - } - - /** - * Sends an update request for a poll. - * - * @param poll - */ - public async updatePoll(poll: MotionPoll): Promise { - const url = '/rest/motions/motion-poll/' + poll.id + '/'; - const data = { - motion_id: poll.motion_id, - id: poll.id, - votescast: poll.votescast, - votesvalid: poll.votesvalid, - votesinvalid: poll.votesinvalid, - votes: { - Yes: poll.yes, - No: poll.no, - Abstain: poll.abstain - } - }; - await this.httpService.put(url, data); - } - - /** - * Sends a http request to delete the given poll - * - * @param poll - */ - public async deletePoll(poll: MotionPoll): Promise { - const url = '/rest/motions/motion-poll/' + poll.id + '/'; - await this.httpService.delete(url); - } - /** * Signals the acceptance of the current recommendation to the server * diff --git a/client/src/app/core/repositories/motions/motion-vote-repository.service.spec.ts b/client/src/app/core/repositories/motions/motion-vote-repository.service.spec.ts new file mode 100644 index 000000000..24617ab6a --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-vote-repository.service.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionVoteRepositoryService } from './motion-vote-repository.service'; + +describe('MotionVoteRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); + + it('should be created', () => { + const service: MotionVoteRepositoryService = TestBed.get(MotionVoteRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/repositories/motions/motion-vote-repository.service.ts b/client/src/app/core/repositories/motions/motion-vote-repository.service.ts new file mode 100644 index 000000000..f60037806 --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-vote-repository.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DataSendService } from 'app/core/core-services/data-send.service'; +import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { RelationDefinition } from 'app/core/definitions/relations'; +import { MotionVote } from 'app/shared/models/motions/motion-vote'; +import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; +import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BaseRepository } from '../base-repository'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +const MotionVoteRelations: RelationDefinition[] = [ + { + type: 'M2O', + ownIdKey: 'user_id', + ownKey: 'user', + foreignViewModel: ViewUser + }, + { + type: 'M2O', + ownIdKey: 'option_id', + ownKey: 'option', + foreignViewModel: ViewMotionOption + } +]; + +/** + * Repository Service for Assignments. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionVoteRepositoryService extends BaseRepository { + /** + * @param DS DataStore access + * @param dataSend Sending data + * @param mapperService Map models to object + * @param viewModelStoreService Access view models + * @param translate Translate string + * @param httpService make HTTP Requests + */ + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + relationManager: RelationManagerService + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + MotionVote, + MotionVoteRelations + ); + } + + public getTitle = (titleInformation: object) => { + return 'Vote'; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Votes' : 'Vote'); + }; +} diff --git a/client/src/app/core/repositories/users/group-repository.service.ts b/client/src/app/core/repositories/users/group-repository.service.ts index c33c8296e..b7cc1cfde 100644 --- a/client/src/app/core/repositories/users/group-repository.service.ts +++ b/client/src/app/core/repositories/users/group-repository.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { HttpService } from 'app/core/core-services/http.service'; import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; @@ -72,6 +74,13 @@ export class GroupRepositoryService extends BaseRepository ids.includes(group.id)) + .map(group => this.translate.instant(group.getTitle())) + .join(', '); + } + /** * Toggles the given permisson. * @@ -187,4 +196,12 @@ export class GroupRepositoryService extends BaseRepository { + // since groups are sorted by id, default is always the first entry + return this.getViewModelListObservable().pipe(map(groups => groups.slice(1))); + } } diff --git a/client/src/app/core/repositories/users/user-repository.service.ts b/client/src/app/core/repositories/users/user-repository.service.ts index 35f74ed29..c317d27ea 100644 --- a/client/src/app/core/repositories/users/user-repository.service.ts +++ b/client/src/app/core/repositories/users/user-repository.service.ts @@ -125,6 +125,18 @@ export class UserRepositoryService extends BaseRepository { return this.translate.instant(plural ? 'Participants' : 'Participant'); }; @@ -145,12 +157,13 @@ export class UserRepositoryService extends BaseRepository this.getFullName(viewModel); viewModel.getShortName = () => this.getShortName(viewModel); + viewModel.getLevelAndNumber = () => this.getLevelAndNumber(viewModel); return viewModel; } diff --git a/client/src/app/core/translate/marked-translations.ts b/client/src/app/core/translate/marked-translations.ts index 43ff8c29a..1f9dc3afb 100644 --- a/client/src/app/core/translate/marked-translations.ts +++ b/client/src/app/core/translate/marked-translations.ts @@ -99,7 +99,8 @@ _('Only main agenda items'); _('Topics'); _('Open requests to speak'); -// Motions config strings +// ** Motions ** +// config strings // subgroup general _('General'); _('Workflow of new motions'); @@ -155,7 +156,7 @@ _('Choose 0 to disable the supporting system.'); _('Remove all supporters of a motion if a submitter edits his motion in early state'); // subgroup Voting and ballot papers _('Voting and ballot papers'); -_('The 100 % base of a voting result consists of'); +_('Default 100 % base of a voting result'); _('Yes/No/Abstain'); _('Yes/No'); _('All valid ballots'); @@ -172,16 +173,13 @@ _('Number of all delegates'); _('Number of all participants'); _('Use the following custom number'); _('Custom number of ballot papers'); +_('Voting'); // subgroup PDF export _('PDF export'); _('Title for PDF documents of motions'); _('Preamble text for PDF documents of motions'); _('Show submitters and recommendation/state in table of contents'); _('Show checkbox to record decision'); -// misc motion strings -_('Amendment'); -_('Statute amendment for'); -_('Statute paragraphs'); // motion workflow 1 _('Simple Workflow'); @@ -224,46 +222,7 @@ _('Needs review'); _('rejected (not authorized)'); _('Reject (not authorized)'); _('Rejection (not authorized)'); -// misc for motions -_('Called'); -_('Called with'); -_('Recommendation'); -_('Motion block'); -_('The text field may not be blank.'); -_('The reason field may not be blank.'); - -// Assignment config strings -_('Election method'); -_('Automatic assign of method'); -_('Always one option per candidate'); -_('Always Yes-No-Abstain per candidate'); -_('Always Yes/No per candidate'); -_('Elections'); -_('Ballot and ballot papers'); -_('The 100-%-base of an election result consists of'); -_( - 'For Yes/No/Abstain per candidate and Yes/No per candidate the 100-%-base depends on the election method: If there is only one option per candidate, the sum of all votes of all candidates is 100 %. Otherwise for each candidate the sum of all votes is 100 %.' -); -_('Yes/No/Abstain per candidate'); -_('Yes/No per candidate'); -_('All valid ballots'); -_('All casted ballots'); -_('Disabled (no percents)'); -_('Number of ballot papers (selection)'); -_('Number of all delegates'); -_('Number of all participants'); -_('Use the following custom number'); -_('Custom number of ballot papers'); -_('Required majority'); -_('Default method to check whether a candidate has reached the required majority.'); -_('Simple majority'); -_('Two-thirds majority'); -_('Three-quarters majority'); -_('Disabled'); -_('Put all candidates on the list of speakers'); -_('Title for PDF document (all elections)'); -_('Preamble text for PDF document (all elections)'); -// motion workflow +// motion workflow manager _('Recommendation label'); _('Allow support'); _('Allow create poll'); @@ -275,11 +234,60 @@ _('Show amendment in parent motion'); _('Restrictions'); _('Label color'); _('Next states'); +// misc for motions +_('Amendment'); +_('Statute amendment for'); +_('Statute paragraphs'); +_('Called'); +_('Called with'); +_('Recommendation'); +_('Motion block'); +_('The text field may not be blank.'); +_('The reason field may not be blank.'); -// other translations +// ** Assignments ** +// Assignment config strings +_('Elections'); +// subgroup ballot +_('Default election method'); +_('Default 100 % base of an election result'); +_('All valid ballots'); +_('All casted ballots'); +_('Disabled (no percents)'); +_('Default groups with voting rights'); +_('Sort election results by amount of votes'); +_('Put all candidates on the list of speakers'); +// subgroup ballot papers +_('Ballot papers'); +_('Number of ballot papers'); +_('Number of all delegates'); +_('Number of all participants'); +_('Use the following custom number'); +_('Custom number of ballot papers'); +_('Required majority'); +_('Default method to check whether a candidate has reached the required majority.'); +_('Simple majority'); +_('Two-thirds majority'); +_('Three-quarters majority'); +_('Disabled'); +_('Title for PDF document (all elections)'); +_('Preamble text for PDF document (all elections)'); +// misc for assignments _('Searching for candidates'); -_('Voting'); _('Finished'); +_('In the election process'); + +// Voting strings +_('Voting type'); +_('analog'); +_('nominal'); +_('non-nominal'); +_('Start voting'); +_('Stop voting'); +_('Publish'); +_('Entitled to vote'); +_('Voting method'); +_('Amount of votes'); // ** Users ** // permission strings (see models.py of each Django app) diff --git a/client/src/app/core/ui-services/banner.service.spec.ts b/client/src/app/core/ui-services/banner.service.spec.ts new file mode 100644 index 000000000..7f7d378a3 --- /dev/null +++ b/client/src/app/core/ui-services/banner.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { BannerService } from './banner.service'; + +describe('BannerService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: BannerService = TestBed.get(BannerService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/ui-services/banner.service.ts b/client/src/app/core/ui-services/banner.service.ts new file mode 100644 index 000000000..7bc1a6619 --- /dev/null +++ b/client/src/app/core/ui-services/banner.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; + +export interface BannerDefinition { + type?: string; + class?: string; + icon?: string; + text?: string; + subText?: string; + link?: string; + largerOnMobileView?: boolean; +} + +/** + * A service handling the active banners at the top of the site. Banners are defined via a BannerDefinition + * and are removed by reference so the service adding a banner has to store the reference to remove it later + */ +@Injectable({ + providedIn: 'root' +}) +export class BannerService { + public activeBanners: BehaviorSubject = new BehaviorSubject([]); + + /** + * Adds a banner to the list of active banners. Skip the banner if it's already in the list + * @param toAdd the banner to add + */ + public addBanner(toAdd: BannerDefinition): void { + if (!this.activeBanners.value.find(banner => banner === toAdd)) { + const newBanners = this.activeBanners.value.concat([toAdd]); + this.activeBanners.next(newBanners); + } + } + + /** + * Replaces a banner with another. Convenience method to prevent flickering + * @param toAdd the banner to add + * @param toRemove the banner to remove + */ + public replaceBanner(toRemove: BannerDefinition, toAdd: BannerDefinition): void { + if (toRemove) { + const newArray = Array.from(this.activeBanners.value); + const idx = newArray.findIndex(banner => banner === toRemove); + if (idx === -1) { + throw new Error("The given banner couldn't be found."); + } else { + newArray[idx] = toAdd; + this.activeBanners.next(newArray); // no need for this.update since the length doesn't change + } + } else { + this.addBanner(toAdd); + } + } + + /** + * removes the given banner + * @param toRemove the banner to remove + */ + public removeBanner(toRemove: BannerDefinition): void { + if (toRemove) { + const newBanners = this.activeBanners.value.filter(banner => banner !== toRemove); + this.activeBanners.next(newBanners); + } + } +} diff --git a/client/src/app/core/ui-services/base-filter-list.service.ts b/client/src/app/core/ui-services/base-filter-list.service.ts index 455ea51b1..1de1a21ce 100644 --- a/client/src/app/core/ui-services/base-filter-list.service.ts +++ b/client/src/app/core/ui-services/base-filter-list.service.ts @@ -534,6 +534,8 @@ export abstract class BaseFilterListService { if (item[filter.property].id === option.condition) { return true; } + } else if (typeof item[filter.property] === 'function') { + return item[filter.property]() === option.condition; } else if (item[filter.property] === option.condition) { return true; } else if (item[filter.property].toString() === option.condition) { diff --git a/client/src/app/core/ui-services/base-poll-dialog.service.ts b/client/src/app/core/ui-services/base-poll-dialog.service.ts new file mode 100644 index 000000000..0e45c36c1 --- /dev/null +++ b/client/src/app/core/ui-services/base-poll-dialog.service.ts @@ -0,0 +1,59 @@ +import { ComponentType } from '@angular/cdk/portal'; +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material'; + +import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; +import { Collection } from 'app/shared/models/base/collection'; +import { PollState, PollType } from 'app/shared/models/poll/base-poll'; +import { mediumDialogSettings } from 'app/shared/utils/dialog-settings'; +import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component'; +import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; + +/** + * Abstract class for showing a poll dialog. Has to be subclassed to provide the right `PollService` + */ +@Injectable({ + providedIn: 'root' +}) +export abstract class BasePollDialogService { + protected dialogComponent: ComponentType>; + + public constructor(private dialog: MatDialog, private mapper: CollectionStringMapperService) {} + + /** + * Opens the dialog to enter votes and edit the meta-info for a poll. + * + * @param data Passing the (existing or new) data for the poll + */ + public async openDialog(viewPoll: Partial & Collection): Promise { + const dialogRef = this.dialog.open(this.dialogComponent, { + data: viewPoll, + ...mediumDialogSettings + }); + const result = await dialogRef.afterClosed().toPromise(); + if (result) { + const repo = this.mapper.getRepository(viewPoll.collectionString); + if (!viewPoll.poll) { + await repo.create(result); + } else { + let update = result; + if (viewPoll.state !== PollState.Created) { + update = { + title: result.title, + onehundred_percent_base: result.onehundred_percent_base, + majority_method: result.majority_method, + description: result.description + }; + if (viewPoll.type === PollType.Analog) { + update = { + ...update, + votes: result.votes, + publish_immediately: result.publish_immediately + }; + } + } + await repo.patch(update, viewPoll); + } + } + } +} diff --git a/client/src/app/core/ui-services/poll.service.spec.ts b/client/src/app/core/ui-services/poll.service.spec.ts deleted file mode 100644 index c04c5aff8..000000000 --- a/client/src/app/core/ui-services/poll.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { inject, TestBed } from '@angular/core/testing'; - -import { E2EImportsModule } from 'e2e-imports.module'; - -import { PollService } from './poll.service'; - -describe('PollService', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [E2EImportsModule], - providers: [PollService] - }); - }); - - it('should be created', inject([PollService], (service: PollService) => { - expect(service).toBeTruthy(); - })); -}); diff --git a/client/src/app/core/ui-services/poll.service.ts b/client/src/app/core/ui-services/poll.service.ts deleted file mode 100644 index dc0566f42..000000000 --- a/client/src/app/core/ui-services/poll.service.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { _ } from 'app/core/translate/translation-marker'; - -/** - * The possible keys of a poll object that represent numbers. - * TODO Should be 'key of MotionPoll|AssinmentPoll if type of key is number' - */ -export type CalculablePollKey = - | 'votesvalid' - | 'votesinvalid' - | 'votescast' - | 'yes' - | 'no' - | 'abstain' - | 'votesno' - | 'votesabstain'; - -/** - * TODO: may be obsolete if the server switches to lower case only - * (lower case variants are already in CalculablePollKey) - */ -export type PollVoteValue = 'Yes' | 'No' | 'Abstain' | 'Votes'; - -/** - * Interface representing possible majority calculation methods. The implementing - * calc function should return an integer number that must be reached for the - * option to successfully fulfill the quorum, or null if disabled - */ -export interface MajorityMethod { - value: string; - display_name: string; - calc: (base: number) => number | null; -} - -/** - * Function to round up the passed value of a poll. - * - * @param value The calculated value of 100%-base. - * @param addOne Flag, if the result should be increased by 1. - * - * @returns The necessary value to get the majority. - */ -export const calcMajority = (value: number, addOne: boolean = false) => { - return Math.ceil(value) + (addOne ? 1 : 0); -}; - -/** - * List of available majority methods, used in motion and assignment polls - */ -export const PollMajorityMethod: MajorityMethod[] = [ - { - value: 'simple_majority', - display_name: 'Simple majority', - calc: base => calcMajority(base * 0.5, true) - }, - { - value: 'two-thirds_majority', - display_name: 'Two-thirds majority', - calc: base => calcMajority((base / 3) * 2) - }, - { - value: 'three-quarters_majority', - display_name: 'Three-quarters majority', - calc: base => calcMajority((base / 4) * 3) - }, - { - value: 'disabled', - display_name: 'Disabled', - calc: a => null - } -]; - -/** - * Shared service class for polls. Used by child classes {@link MotionPollService} - * and {@link AssignmentPollService} - */ -@Injectable({ - providedIn: 'root' -}) -export abstract class PollService { - /** - * The chosen and currently used base for percentage calculations. Is - * supposed to be set by a config service - */ - public percentBase: string; - - /** - * The default majority method (to be set set per config). - */ - public defaultMajorityMethod: string; - - /** - * The majority method currently in use - */ - public majorityMethod: MajorityMethod; - - /** - * An array of value - label pairs for special value signifiers. - * TODO: Should be given by the server, and editable. For now they are hard - * coded - */ - private _specialPollVotes: [number, string][] = [ - [-1, 'majority'], - [-2, 'undocumented'] - ]; - - /** - * getter for the special vote values - * - * @returns an array of special (non-positive) numbers used in polls and - * their descriptive strings - */ - public get specialPollVotes(): [number, string][] { - return this._specialPollVotes; - } - - /** - * empty constructor - * - */ - public constructor() {} - - /** - * Gets an icon for a Poll Key - * - * @param key yes, no, abstain or something like that - * @returns a string for material-icons to represent the icon for - * this key(e.g. yes: positive sign, no: negative sign) - */ - public getIcon(key: CalculablePollKey): string { - switch (key) { - case 'yes': - return 'thumb_up'; - case 'no': - case 'votesno': - return 'thumb_down'; - case 'abstain': - case 'votesabstain': - return 'not_interested'; - // TODO case 'votescast': - // sum - case 'votesvalid': - return 'check'; - case 'votesinvalid': - return 'cancel'; - default: - return ''; - } - } - - /** - * Gets a label for a poll Key - * - * @param key yes, no, abstain or something like that - * @returns A short descriptive name for the poll keys - */ - public getLabel(key: CalculablePollKey | PollVoteValue): string { - switch (key.toLowerCase()) { - case 'yes': - return 'Yes'; - case 'no': - case 'votesno': - return 'No'; - case 'abstain': - case 'votesabstain': - return 'Abstain'; - case 'votescast': - return _('Total votes cast'); - case 'votesvalid': - return _('Valid votes'); - case 'votesinvalid': - return _('Invalid votes'); - default: - return ''; - } - } - - /** - * retrieve special labels for a poll value - * {@link specialPollVotes}. Positive values will return as string - * representation of themselves - * - * @param value check value for special numbers - * @returns the label for a non-positive value, according to - */ - public getSpecialLabel(value: number): string { - if (value >= 0) { - return value.toString(); - // TODO: toLocaleString(lang); but translateService is not usable here, thus lang is not well defined - } - const vote = this.specialPollVotes.find(special => special[0] === value); - return vote ? vote[1] : 'Undocumented special (negative) value'; - } - - /** - * Get the progress bar class for a decision key - * - * @param key a calculable poll key (like yes or no) - * @returns a css class designing a progress bar in a color, or an empty string - */ - public getProgressBarColor(key: CalculablePollKey | PollVoteValue): string { - switch (key.toLowerCase()) { - case 'yes': - return 'progress-green'; - case 'no': - return 'progress-red'; - case 'abstain': - return 'progress-yellow'; - case 'votes': - return 'progress-green'; - default: - return ''; - } - } -} diff --git a/client/src/app/core/ui-services/voting-banner.service.spec.ts b/client/src/app/core/ui-services/voting-banner.service.spec.ts new file mode 100644 index 000000000..7040f7abb --- /dev/null +++ b/client/src/app/core/ui-services/voting-banner.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { VotingBannerService } from './voting-banner.service'; + +describe('VotingBannerService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: VotingBannerService = TestBed.get(VotingBannerService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/ui-services/voting-banner.service.ts b/client/src/app/core/ui-services/voting-banner.service.ts new file mode 100644 index 000000000..ce5c59e47 --- /dev/null +++ b/client/src/app/core/ui-services/voting-banner.service.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { _ } from 'app/core/translate/translation-marker'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; +import { PollListObservableService } from 'app/site/polls/services/poll-list-observable.service'; +import { BannerDefinition, BannerService } from './banner.service'; +import { OpenSlidesStatusService } from '../core-services/openslides-status.service'; +import { VotingService } from './voting.service'; + +@Injectable({ + providedIn: 'root' +}) +export class VotingBannerService { + private currentBanner: BannerDefinition; + + private subText = _('Click here to vote!'); + + public constructor( + pollListObservableService: PollListObservableService, + private banner: BannerService, + private translate: TranslateService, + private OSStatus: OpenSlidesStatusService, + private votingService: VotingService + ) { + pollListObservableService.getViewModelListObservable().subscribe(polls => this.checkForVotablePolls(polls)); + } + + /** + * checks all polls for votable ones and displays a banner for them + * @param polls the updated poll list + */ + private checkForVotablePolls(polls: ViewBasePoll[]): void { + // display no banner if in history mode or there are no polls to vote + const pollsToVote = polls.filter(poll => this.votingService.canVote(poll) && !poll.user_has_voted); + if ((this.OSStatus.isInHistoryMode && this.currentBanner) || !pollsToVote.length) { + this.sliceBanner(); + return; + } + + const banner = + pollsToVote.length === 1 + ? this.createBanner(this.getTextForPoll(pollsToVote[0]), pollsToVote[0].parentLink) + : this.createBanner(`${pollsToVote.length} ${this.translate.instant('open votes')}`, '/polls/'); + this.sliceBanner(banner); + } + + /** + * Creates a new `BannerDefinition` and returns it. + * + * @param text The text for the banner. + * @param link The link for the banner. + * + * @returns The created banner. + */ + private createBanner(text: string, link: string): BannerDefinition { + return { + text: text, + subText: this.subText, + link: link, + icon: 'how_to_vote', + largerOnMobileView: true + }; + } + + /** + * Returns for a given poll a title for the banner. + * + * @param poll The given poll. + * + * @returns The title. + */ + private getTextForPoll(poll: ViewBasePoll): string { + if (poll instanceof ViewMotionPoll) { + return `${this.translate.instant('Motion')} ${poll.motion.getIdentifierOrTitle()}: ${this.translate.instant( + 'Voting opened' + )}`; + } else if (poll instanceof ViewAssignmentPoll) { + return `${poll.assignment.getTitle()}: ${this.translate.instant('Ballot opened')}`; + } + } + + /** + * Removes the current banner or replaces it, if a new one is given. + * + * @param nextBanner Optional the next banner to show. + */ + private sliceBanner(nextBanner?: BannerDefinition): void { + if (nextBanner) { + this.banner.replaceBanner(this.currentBanner, nextBanner); + } else { + this.banner.removeBanner(this.currentBanner); + } + this.currentBanner = nextBanner || null; + } +} diff --git a/client/src/app/core/ui-services/voting.service.spec.ts b/client/src/app/core/ui-services/voting.service.spec.ts new file mode 100644 index 000000000..6dab02c52 --- /dev/null +++ b/client/src/app/core/ui-services/voting.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { VotingService } from './voting.service'; + +describe('VotingService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: VotingService = TestBed.get(VotingService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/ui-services/voting.service.ts b/client/src/app/core/ui-services/voting.service.ts new file mode 100644 index 000000000..9930af398 --- /dev/null +++ b/client/src/app/core/ui-services/voting.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@angular/core'; + +import { PollState, PollType } from 'app/shared/models/poll/base-poll'; +import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; +import { OperatorService } from '../core-services/operator.service'; + +export enum VotingError { + POLL_WRONG_STATE = 1, // 1 so we can check with negation + POLL_WRONG_TYPE, + USER_HAS_NO_PERMISSION, + USER_IS_ANONYMOUS, + USER_NOT_PRESENT +} + +/** + * TODO: It appears that the only message that makes sense for the user to see it the last one. + */ +export const VotingErrorVerbose = { + 1: "You can't vote on this poll right now because it's not in the 'Started' state.", + 2: "You can't vote on this poll because its type is set to analog voting.", + 3: "You don't have permission to vote on this poll.", + 4: 'You have to be logged in to be able to vote.', + 5: 'You have to be present to vote on a poll.', + 6: "You have already voted on this poll. You can't change your vote in a pseudoanonymous poll." +}; + +@Injectable({ + providedIn: 'root' +}) +export class VotingService { + public constructor(private operator: OperatorService) {} + + /** + * checks whether the operator can vote on the given poll + */ + public canVote(poll: ViewBasePoll): boolean { + const error = this.getVotePermissionError(poll); + return !error; + } + + /** + * checks whether the operator can vote on the given poll + * @returns null if no errors exist (= user can vote) or else a VotingError + */ + public getVotePermissionError(poll: ViewBasePoll): VotingError | void { + const user = this.operator.viewUser; + if (this.operator.isAnonymous) { + return VotingError.USER_IS_ANONYMOUS; + } + if (!poll.groups_id.intersect(user.groups_id).length) { + return VotingError.USER_HAS_NO_PERMISSION; + } + if (poll.type === PollType.Analog) { + return VotingError.POLL_WRONG_TYPE; + } + if (poll.state !== PollState.Started) { + return VotingError.POLL_WRONG_STATE; + } + if (!user.is_present) { + return VotingError.USER_NOT_PRESENT; + } + } + + public getVotePermissionErrorVerbose(poll: ViewBasePoll): string | void { + const error = this.getVotePermissionError(poll); + if (error) { + return VotingErrorVerbose[error]; + } + } +} diff --git a/client/src/app/shared/components/agenda-content-object-form/agenda-content-object-form.component.html b/client/src/app/shared/components/agenda-content-object-form/agenda-content-object-form.component.html index d0a41560d..e6f7ae75d 100644 --- a/client/src/app/shared/components/agenda-content-object-form/agenda-content-object-form.component.html +++ b/client/src/app/shared/components/agenda-content-object-form/agenda-content-object-form.component.html @@ -21,15 +21,16 @@ -
- +
+ + +
diff --git a/client/src/app/shared/components/attachment-control/attachment-control.component.html b/client/src/app/shared/components/attachment-control/attachment-control.component.html index 65b22b0eb..609beaabf 100644 --- a/client/src/app/shared/components/attachment-control/attachment-control.component.html +++ b/client/src/app/shared/components/attachment-control/attachment-control.component.html @@ -1,12 +1,13 @@ -
- +
+ + + diff --git a/client/src/app/shared/components/attachment-control/attachment-control.component.ts b/client/src/app/shared/components/attachment-control/attachment-control.component.ts index 66a6e4be2..501807d2d 100644 --- a/client/src/app/shared/components/attachment-control/attachment-control.component.ts +++ b/client/src/app/shared/components/attachment-control/attachment-control.component.ts @@ -1,44 +1,62 @@ -import { Component, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core'; -import { ControlValueAccessor, FormControl } from '@angular/forms'; -import { MatDialog } from '@angular/material'; +import { FocusMonitor } from '@angular/cdk/a11y'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + OnInit, + Optional, + Output, + Self, + TemplateRef +} from '@angular/core'; +import { FormBuilder, NgControl } from '@angular/forms'; +import { MatDialog, MatFormFieldControl } from '@angular/material'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service'; +import { BaseFormControlComponent } from 'app/shared/models/base/base-form-control'; import { mediumDialogSettings } from 'app/shared/utils/dialog-settings'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; @Component({ selector: 'os-attachment-control', templateUrl: './attachment-control.component.html', - styleUrls: ['./attachment-control.component.scss'] + styleUrls: ['./attachment-control.component.scss'], + providers: [{ provide: MatFormFieldControl, useExisting: AttachmentControlComponent }], + changeDetection: ChangeDetectionStrategy.OnPush }) -export class AttachmentControlComponent implements OnInit, ControlValueAccessor { +export class AttachmentControlComponent extends BaseFormControlComponent implements OnInit { /** * Output for an error handler */ @Output() public errorHandler: EventEmitter = new EventEmitter(); - /** - * The form-control name to access the value for the form-control - */ - @Input() - public controlName: FormControl; - /** * The file list that is necessary for the `SearchValueSelector` */ public mediaFileList: Observable; - /** - * Default constructor - * - * @param dialogService Reference to the `MatDialog` - * @param mediaService Reference for the `MediaFileRepositoryService` - */ - public constructor(private dialogService: MatDialog, private mediaService: MediafileRepositoryService) {} + public get empty(): boolean { + return !this.contentForm.value.length; + } + public get controlType(): string { + return 'attachment-control'; + } + + public constructor( + formBuilder: FormBuilder, + focusMonitor: FocusMonitor, + element: ElementRef, + @Optional() @Self() public ngControl: NgControl, + private dialogService: MatDialog, + private mediaService: MediafileRepositoryService + ) { + super(formBuilder, focusMonitor, element, ngControl); + } /** * On init method @@ -64,11 +82,9 @@ export class AttachmentControlComponent implements OnInit, ControlValueAccessor * @param fileIDs a list with the ids of the uploaded files */ public uploadSuccess(fileIDs: number[]): void { - if (this.controlName) { - const newValues = [...this.controlName.value, ...fileIDs]; - this.controlName.setValue(newValues); - this.dialogService.closeAll(); - } + const newValues = [...this.contentForm.value, ...fileIDs]; + this.updateForm(newValues); + this.dialogService.closeAll(); } /** @@ -81,28 +97,15 @@ export class AttachmentControlComponent implements OnInit, ControlValueAccessor } /** - * Function to write a new value to the form. - * Satisfy the interface. - * - * @param value The new value for this form. + * Declared as abstract in MatFormFieldControl and not required for this component */ - public writeValue(value: any): void { - if (value && this.controlName) { - this.controlName.setValue(value); - } + public onContainerClick(event: MouseEvent): void {} + + protected initializeForm(): void { + this.contentForm = this.fb.control([]); } - /** - * Function executed when the control's value changed. - * - * @param fn the function that is executed. - */ - public registerOnChange(fn: any): void {} - - /** - * To satisfy the interface - * - * @param fn the registered callback function for onBlur-events. - */ - public registerOnTouched(fn: any): void {} + protected updateForm(value: ViewMediafile[] | null): void { + this.contentForm.setValue(value || []); + } } diff --git a/client/src/app/shared/components/banner/banner.component.html b/client/src/app/shared/components/banner/banner.component.html new file mode 100644 index 000000000..b8d6dbe2c --- /dev/null +++ b/client/src/app/shared/components/banner/banner.component.html @@ -0,0 +1,25 @@ + diff --git a/client/src/app/shared/components/banner/banner.component.scss b/client/src/app/shared/components/banner/banner.component.scss new file mode 100644 index 000000000..539b3ce2c --- /dev/null +++ b/client/src/app/shared/components/banner/banner.component.scss @@ -0,0 +1,57 @@ +@import '../../../../assets/styles/media-queries.scss'; + +.banner { + &.larger-on-mobile { + @include set-breakpoint-lower(sm) { + min-height: 40px; + } + } + + position: relative; // was fixed before to prevent the overflow + min-height: 20px; + line-height: 20px; + width: 100%; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + border-bottom: 1px solid white; + + a { + align-items: center; + justify-content: center; + text-decoration: none; + color: white; + &.banner-link { + width: 100%; + height: 100%; + } + } + + mat-icon { + $font-size: 16px; + width: $font-size; + height: $font-size; + font-size: $font-size; + + & + span { + margin-left: 10px; + } + } +} + +.history-mode-indicator { + background: repeating-linear-gradient(45deg, #ffee00, #ffee00 10px, #070600 10px, #000000 20px); + + span, + a { + padding: 2px; + color: #000000; + background: #ffee00; + } + + a { + cursor: pointer; + font-weight: bold; + } +} diff --git a/client/src/app/shared/components/banner/banner.component.scss-theme.scss b/client/src/app/shared/components/banner/banner.component.scss-theme.scss new file mode 100644 index 000000000..20866ad83 --- /dev/null +++ b/client/src/app/shared/components/banner/banner.component.scss-theme.scss @@ -0,0 +1,11 @@ +@import '~@angular/material/theming'; + +/** Custom component theme. Only lives in a specific scope */ +@mixin os-banner-style($theme) { + $accent: map-get($theme, accent); + + /** style for the offline-banner */ + .banner { + background: mat-color($accent, 500); + } +} diff --git a/client/src/app/shared/components/banner/banner.component.spec.ts b/client/src/app/shared/components/banner/banner.component.spec.ts new file mode 100644 index 000000000..337859455 --- /dev/null +++ b/client/src/app/shared/components/banner/banner.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { BannerComponent } from './banner.component'; + +describe('BannerComponent', () => { + let component: BannerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BannerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/banner/banner.component.ts b/client/src/app/shared/components/banner/banner.component.ts new file mode 100644 index 000000000..92c0505d3 --- /dev/null +++ b/client/src/app/shared/components/banner/banner.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service'; +import { TimeTravelService } from 'app/core/core-services/time-travel.service'; +import { BannerDefinition, BannerService } from 'app/core/ui-services/banner.service'; +import { langToLocale } from 'app/shared/utils/lang-to-locale'; + +@Component({ + selector: 'os-banner', + templateUrl: './banner.component.html', + styleUrls: ['./banner.component.scss'] +}) +export class BannerComponent implements OnInit { + public banners: BannerDefinition[] = []; + + public constructor( + private OSStatus: OpenSlidesStatusService, + protected translate: TranslateService, + public timeTravel: TimeTravelService, + private banner: BannerService + ) {} + + public ngOnInit(): void { + this.banner.activeBanners.subscribe(banners => { + this.banners = banners; + }); + } + + /** + * Get the timestamp for the current point in history mode. + * Tries to detect the ideal timestamp format using the translation service + * + * @returns the timestamp as string + */ + public getHistoryTimestamp(): string { + return this.OSStatus.getHistoryTimeStamp(langToLocale(this.translate.currentLang)); + } +} diff --git a/client/src/app/shared/components/charts/charts.component.html b/client/src/app/shared/components/charts/charts.component.html new file mode 100644 index 000000000..bf3bd225f --- /dev/null +++ b/client/src/app/shared/components/charts/charts.component.html @@ -0,0 +1,26 @@ +
+ + + + + +
diff --git a/client/src/app/shared/components/charts/charts.component.scss b/client/src/app/shared/components/charts/charts.component.scss new file mode 100644 index 000000000..f740fde6b --- /dev/null +++ b/client/src/app/shared/components/charts/charts.component.scss @@ -0,0 +1,15 @@ +.charts-wrapper { + position: relative; + display: block; + margin: auto; + + &.has-padding { + padding: 16px; + } +} + +@for $i from 1 through 100 { + .os-charts--#{$i} { + width: unquote($string: $i + '%'); + } +} diff --git a/client/src/app/shared/components/charts/charts.component.spec.ts b/client/src/app/shared/components/charts/charts.component.spec.ts new file mode 100644 index 000000000..0d30a4475 --- /dev/null +++ b/client/src/app/shared/components/charts/charts.component.spec.ts @@ -0,0 +1,24 @@ +import { async, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('ChartsComponent', () => { + // let component: ChartsComponent; + // let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + // fixture = TestBed.createComponent(ChartsComponent); + // component = fixture.componentInstance; + // fixture.detectChanges(); + }); + + it('should create', () => { + // expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/charts/charts.component.ts b/client/src/app/shared/components/charts/charts.component.ts new file mode 100644 index 000000000..2dab09785 --- /dev/null +++ b/client/src/app/shared/components/charts/charts.component.ts @@ -0,0 +1,300 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { ChartOptions } from 'chart.js'; +import { Label } from 'ng2-charts'; +import { Observable } from 'rxjs'; + +import { BaseViewComponent } from 'app/site/base/base-view'; + +/** + * The different supported chart-types. + */ +export type ChartType = 'line' | 'bar' | 'pie' | 'doughnut' | 'horizontalBar' | 'stackedBar'; + +/** + * Describes the events the chart is fired, when hovering or clicking on it. + */ +interface ChartEvent { + event: MouseEvent; + active: {}[]; +} + +/** + * One single collection in an array. + */ +export interface ChartDate { + data: number[]; + label: string; + backgroundColor?: string; + hoverBackgroundColor?: string; + barThickness?: number; + maxBarThickness?: number; +} + +/** + * An alias for an array of `ChartDate`. + */ +export type ChartData = ChartDate[]; + +export type ChartLegendSize = 'small' | 'middle'; + +/** + * Wrapper for the chart-library. + * + * It takes the passed data to fit the different types of the library. + */ +@Component({ + selector: 'os-charts', + templateUrl: './charts.component.html', + styleUrls: ['./charts.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ChartsComponent extends BaseViewComponent { + /** + * Sets the data as an observable. + * + * The data is prepared and splitted to dynamic use of bar/line or doughnut/pie chart. + */ + @Input() + public set data(dataObservable: Observable) { + this.subscriptions.push( + dataObservable.subscribe(data => { + if (!data) { + return; + } + data = data.flatMap((date: ChartDate) => ({ ...date, data: date.data.filter(value => value >= 0) })); + this.chartData = data; + this.circleData = data.flatMap((date: ChartDate) => date.data); + this.circleLabels = data.map(date => date.label); + const circleColors = [ + { + backgroundColor: data.map(date => date.backgroundColor).filter(color => !!color), + hoverBackgroundColor: data.map(date => date.hoverBackgroundColor).filter(color => !!color) + } + ]; + this.circleColors = !!circleColors[0].backgroundColor.length ? circleColors : null; + this.checkAndUpdateChartType(); + this.cd.detectChanges(); + }) + ); + } + + /** + * The type of the chart. Defaults to `'bar'`. + */ + @Input() + public set type(type: ChartType) { + this._type = type; + this.checkAndUpdateChartType(); + this.cd.detectChanges(); + } + + public get type(): ChartType { + return this._type; + } + + @Input() + public set chartLegendSize(size: ChartLegendSize) { + this._chartLegendSize = size; + this.setupChartLegendSize(); + } + + /** + * Whether to show the legend. + */ + @Input() + public showLegend = true; + + /** + * The labels for the separated sections. + * Each label represent one section, e.g. one year. + */ + @Input() + public labels: Label[] = []; + + /** + * Sets the position of the legend. + * Defaults to `'top'`. + */ + @Input() + public set legendPosition(position: Chart.PositionType) { + this.chartOptions.legend.position = position; + } + + /** + * Determine, if the chart has some padding at the borders. + */ + @Input() + public hasPadding = true; + + /** + * Optional passing a number as percentage value for `max-width`. + * Range from 1 to 100. + * Defaults to `100`. + */ + @Input() + public set size(size: number) { + if (size > 100) { + size = 100; + } + if (size < 1) { + size = 1; + } + this._size = size; + } + + public get size(): number { + return this._size; + } + + /** + * Fires an event, when the user clicks on the chart. + */ + @Output() + public select = new EventEmitter(); + + /** + * Fires an event, when the user hovers over the chart. + */ + @Output() + public hover = new EventEmitter(); + + /** + * Returns a string to append to the `chart-wrapper's` classes. + */ + public get classes(): string { + return 'os-charts os-charts--' + this.size; + } + + /** + * The general data for the chart. + * This is only needed for `type == 'bar' || 'line'` + */ + public chartData: ChartData = []; + + /** + * The data for circle-like charts, like 'doughnut' or 'pie'. + */ + public circleData: number[] = []; + + /** + * The labels for circle-like charts, like 'doughnut' or 'pie'. + */ + public circleLabels: Label[] = []; + + /** + * The colors for circle-like charts, like 'doughnut' or 'pie'. + */ + public circleColors: { backgroundColor?: string[]; hoverBackgroundColor?: string[] }[] = []; + + /** + * The options used for the charts. + */ + public chartOptions: ChartOptions = { + responsive: true, + legend: { + position: 'top', + labels: {} + }, + scales: { + xAxes: [ + { + gridLines: { + drawOnChartArea: false + }, + ticks: { beginAtZero: true, stepSize: 1 }, + stacked: true + } + ], + yAxes: [ + { + gridLines: { + drawBorder: false, + drawOnChartArea: false, + drawTicks: false + }, + ticks: { mirror: true, labelOffset: -20 }, + stacked: true + } + ] + } + }; + + /** + * Chart option for pie and doughnut + */ + @Input() + public pieChartOptions: ChartOptions = { + responsive: true, + legend: { + position: 'left' + }, + aspectRatio: 1 + }; + + /** + * Holds the type of the chart - defaults to `bar`. + */ + private _type: ChartType = 'bar'; + + private _chartLegendSize: ChartLegendSize = 'middle'; + + /** + * Holds the value for `max-width`. + */ + private _size = 100; + + /** + * Constructor. + * + * @param title + * @param translate + * @param matSnackbar + * @param cd + */ + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + private cd: ChangeDetectorRef + ) { + super(title, translate, matSnackbar); + } + + private setupBar(): void { + if (!this.chartData.every(date => date.barThickness && date.maxBarThickness)) { + this.chartData = this.chartData.map(chartDate => ({ + ...chartDate, + barThickness: 20, + maxBarThickness: 48 + })); + } + } + + private setupChartLegendSize(): void { + switch (this._chartLegendSize) { + case 'small': + this.chartOptions.legend.labels = Object.assign(this.chartOptions.legend.labels, { + fontSize: 10, + boxWidth: 20 + }); + break; + case 'middle': + this.chartOptions.legend.labels = { + fontSize: 14, + boxWidth: 40 + }; + } + this.cd.detectChanges(); + } + + private checkAndUpdateChartType(): void { + if (this._type === 'stackedBar') { + this.setupBar(); + this._type = 'horizontalBar'; + } + } +} diff --git a/client/src/app/shared/components/check-input/check-input.component.html b/client/src/app/shared/components/check-input/check-input.component.html new file mode 100644 index 000000000..2cfb1f52e --- /dev/null +++ b/client/src/app/shared/components/check-input/check-input.component.html @@ -0,0 +1,22 @@ +
+ + + + + {{ checkboxLabel }} + +
+
diff --git a/client/src/app/shared/components/check-input/check-input.component.scss b/client/src/app/shared/components/check-input/check-input.component.scss new file mode 100644 index 000000000..a776b3fc9 --- /dev/null +++ b/client/src/app/shared/components/check-input/check-input.component.scss @@ -0,0 +1,10 @@ +.check-input--container { + display: flex; + align-items: center; + justify-content: space-between; + + & > * { + flex: 1; + padding: 0 5px; + } +} diff --git a/client/src/app/shared/components/check-input/check-input.component.spec.ts b/client/src/app/shared/components/check-input/check-input.component.spec.ts new file mode 100644 index 000000000..2da61fb27 --- /dev/null +++ b/client/src/app/shared/components/check-input/check-input.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { CheckInputComponent } from './check-input.component'; + +describe('CheckInputComponent', () => { + let component: CheckInputComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CheckInputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/check-input/check-input.component.ts b/client/src/app/shared/components/check-input/check-input.component.ts new file mode 100644 index 000000000..7e7d6ca44 --- /dev/null +++ b/client/src/app/shared/components/check-input/check-input.component.ts @@ -0,0 +1,147 @@ +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { BaseViewComponent } from 'app/site/base/base-view'; + +@Component({ + selector: 'os-check-input', + templateUrl: './check-input.component.html', + styleUrls: ['./check-input.component.scss'], + providers: [{ provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => CheckInputComponent) }] +}) +export class CheckInputComponent extends BaseViewComponent implements OnInit, ControlValueAccessor { + /** + * Type of the used input. + */ + @Input() + public inputType = 'text'; + + /** + * The placeholder for the form-field. + */ + @Input() + public placeholder: string; + + /** + * The value received, if the checkbox is checked. + */ + @Input() + public checkboxValue: number | string; + + /** + * Label for the checkbox. + */ + @Input() + public checkboxLabel: string; + + /** + * Model for the state of the checkbox. + */ + public isChecked = false; + + /** + * The form-control-reference. + */ + public contentForm: FormControl; + + /** + * Default constructor. + */ + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + private fb: FormBuilder + ) { + super(title, translate, matSnackbar); + this.initForm(); + } + + /** + * OnInit. + * Subscribes to value-changes of the form-control. + */ + public ngOnInit(): void { + this.subscriptions.push(this.contentForm.valueChanges.subscribe(value => this.sendValue(value))); + } + + /** + * Function to handle checkbox-state-changed-event. + */ + public checkboxStateChanged(checked: boolean): void { + this.isChecked = checked; + if (checked) { + this.contentForm.disable({ emitEvent: false }); + } else { + this.contentForm.enable({ emitEvent: false }); + } + this.sendValue(); + } + + /** + * The value from the FormControl + * + * @param obj the value from the parent form. Type "any" is required by the interface + */ + public writeValue(obj: string | number): void { + if (obj || typeof obj === 'number') { + if (obj === this.checkboxValue) { + this.checkboxStateChanged(true); + } else { + this.contentForm.patchValue(obj); + } + } + } + + /** + * Hands changes back to the parent form + * + * @param fn the function to propagate the changes + */ + public registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + /** + * To satisfy the interface. + * + * @param fn + */ + public registerOnTouched(fn: any): void {} + + /** + * To satisfy the interface + * + * @param isDisabled + */ + public setDisabledState?(isDisabled: boolean): void {} + + /** + * Helper function to determine which information to give to the parent form + */ + private propagateChange = (_: any) => {}; + + /** + * Initially build the form-control. + */ + private initForm(): void { + this.contentForm = this.fb.control(''); + } + + /** + * Sends the given value by the propagateChange-funtion. + * + * @param value Optional parameter to pass a value to send. + */ + private sendValue(value?: string | number): void { + if (this.isChecked) { + this.propagateChange(this.checkboxValue); + } else { + this.propagateChange(value); + } + } +} diff --git a/client/src/app/shared/components/extension-field/extension-field.component.html b/client/src/app/shared/components/extension-field/extension-field.component.html index 1c8850c5b..7a60c096d 100644 --- a/client/src/app/shared/components/extension-field/extension-field.component.html +++ b/client/src/app/shared/components/extension-field/extension-field.component.html @@ -38,14 +38,13 @@ (keydown)="keyDownFunction($event)" /> - + + + diff --git a/client/src/app/shared/components/list-view-table/list-view-table.component.html b/client/src/app/shared/components/list-view-table/list-view-table.component.html index c1ed0905b..8c5e295b9 100644 --- a/client/src/app/shared/components/list-view-table/list-view-table.component.html +++ b/client/src/app/shared/components/list-view-table/list-view-table.component.html @@ -14,8 +14,9 @@ ; + public listObservableProvider: HasViewModelListObservable; + + /** + * ...or the required observable + */ + @Input() + public listObservable: Observable; /** * The currently active sorting service for the list view @@ -109,7 +116,7 @@ export class ListViewTableComponent; @@ -187,12 +194,25 @@ export class ListViewTableComponent { - if (this.repo) { - return this.repo.getViewModelListObservable(); - } - } - /** * Define which columns to hide. Uses the input-property * "hide" to hide individual columns @@ -342,7 +353,7 @@ export class ListViewTableComponent 0) { + document.documentElement.style.setProperty('--pbl-height', this.vScrollFixed + 'px'); + } } /** diff --git a/client/src/app/shared/components/media-upload-content/media-upload-content.component.html b/client/src/app/shared/components/media-upload-content/media-upload-content.component.html index 4cc4a808e..3b05c2a24 100644 --- a/client/src/app/shared/components/media-upload-content/media-upload-content.component.html +++ b/client/src/app/shared/components/media-upload-content/media-upload-content.component.html @@ -13,16 +13,17 @@
-
- +
+ + +
@@ -69,14 +70,15 @@ Access groups - - + + + + diff --git a/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts b/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts index 71beedb6b..3638de4e9 100644 --- a/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts +++ b/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts @@ -90,7 +90,8 @@ export class MediaUploadContentComponent implements OnInit { public get selectedDirectoryId(): number | null { if (this.showDirectorySelector) { - return this.directorySelectionForm.controls.parent_id.value; + const parent = this.directorySelectionForm.controls.parent_id; + return !parent.value || typeof parent.value !== 'number' ? null : parent.value; } else { return this.directoryId; } @@ -110,7 +111,7 @@ export class MediaUploadContentComponent implements OnInit { this.directoryBehaviorSubject = this.repo.getDirectoryBehaviorSubject(); this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject(); this.directorySelectionForm = this.formBuilder.group({ - parent_id: [] + parent_id: null }); } diff --git a/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.html b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.html new file mode 100644 index 000000000..992d78bac --- /dev/null +++ b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.html @@ -0,0 +1,39 @@ +
+ + + + + + + + + + + + + + + + + + +
Votes
+ + {{ row.votingOption | pollKeyVerbose | translate }} + + + {{ row.votingOption | pollKeyVerbose | translate }} + + + + {{ row.value[0].amount | pollPercentBase: poll }} + + + {{ row.value[0].amount | parsePollNumber }} +
+ + +
+ +
+
diff --git a/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.scss b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.scss new file mode 100644 index 000000000..01b374641 --- /dev/null +++ b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.scss @@ -0,0 +1,31 @@ +@import '~assets/styles/poll-styles-common.scss'; + +.result-wrapper { + display: grid; + grid-gap: 2em; + margin: 2em; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + + .result-table { + // display: block; + th { + text-align: right; + font-weight: initial; + } + + tr { + height: 48px; + border-bottom: none !important; + + .result-cell-definition { + text-align: right; + } + } + } + + .doughnut-chart { + display: block; + margin-top: auto; + margin-bottom: auto; + } +} diff --git a/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.spec.ts b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.spec.ts new file mode 100644 index 000000000..6a5a010dd --- /dev/null +++ b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionPollDetailContentComponent } from './motion-poll-detail-content.component'; + +describe('MotionPollDetailContentComponent', () => { + let component: MotionPollDetailContentComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionPollDetailContentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.ts b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.ts new file mode 100644 index 000000000..76d2882ab --- /dev/null +++ b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.ts @@ -0,0 +1,37 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; + +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; +import { PollData, PollTableData } from 'app/site/polls/services/poll.service'; +import { ChartData } from '../charts/charts.component'; + +@Component({ + selector: 'os-motion-poll-detail-content', + templateUrl: './motion-poll-detail-content.component.html', + styleUrls: ['./motion-poll-detail-content.component.scss'] +}) +export class MotionPollDetailContentComponent implements OnInit { + @Input() + public poll: ViewMotionPoll | PollData; + + @Input() + public chartData: BehaviorSubject; + + public get hasVotes(): boolean { + return this.poll && !!this.poll.options; + } + + public constructor(private motionPollService: MotionPollService) {} + + public ngOnInit(): void {} + + public getTableData(): PollTableData[] { + return this.motionPollService.generateTableData(this.poll); + } + + public get showChart(): boolean { + return this.motionPollService.showChart(this.poll) && this.chartData && !!this.chartData.value; + } +} diff --git a/client/src/app/shared/components/progress-snack-bar/progress-snack-bar.component.scss b/client/src/app/shared/components/progress-snack-bar/progress-snack-bar.component.scss index 10876178e..0d66fd042 100644 --- a/client/src/app/shared/components/progress-snack-bar/progress-snack-bar.component.scss +++ b/client/src/app/shared/components/progress-snack-bar/progress-snack-bar.component.scss @@ -5,16 +5,16 @@ 'message action' 'bar action'; grid-template-columns: auto min-content; -} -.message { - grid-area: message; -} + .message { + grid-area: message; + } -.bar { - grid-area: bar; -} + .bar { + grid-area: bar; + } -.action { - grid-area: action; + .action { + grid-area: action; + } } diff --git a/client/src/app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss b/client/src/app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss new file mode 100644 index 000000000..918ed29e0 --- /dev/null +++ b/client/src/app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss @@ -0,0 +1,10 @@ +@import '~@angular/material/theming'; + +/** Custom component theme. Only lives in a specific scope */ +@mixin os-progress-snack-bar-style($theme) { + $background: map-get($theme, background); + + .mat-progress-bar-buffer { + background-color: mat-color($background, card) !important; + } +} diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.html b/client/src/app/shared/components/search-value-selector/search-value-selector.component.html index e3f019d3c..88a974c07 100644 --- a/client/src/app/shared/components/search-value-selector/search-value-selector.component.html +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.html @@ -1,19 +1,30 @@ - - - -
- - {{ noneTitle | translate }} - - + + + +
+
+ + + {{ item.getTitle() }} + cancel + + +
+
- - {{ selectedItem.getTitle() | translate }} +
+ + + {{ noneTitle | translate }} -
- + + + + {{ selectedItem.getTitle() | translate }} + + diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.scss b/client/src/app/shared/components/search-value-selector/search-value-selector.component.scss index e69de29bb..a66882f9c 100644 --- a/client/src/app/shared/components/search-value-selector/search-value-selector.component.scss +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.scss @@ -0,0 +1,21 @@ +.os-search-value-selector { + max-height: 312px !important ; +} + +.os-search-value-selector-chip-container { + position: absolute; + padding: 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + top: 52px; + width: 100%; + background: white; + z-index: 100; + min-height: 41px; +} + +.os-search-value-selector-chip-placeholder { + padding: 8px; + width: 100%; + background: white; + min-height: 39px; +} diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.spec.ts b/client/src/app/shared/components/search-value-selector/search-value-selector.component.spec.ts index ac15cebd3..c242cf735 100644 --- a/client/src/app/shared/components/search-value-selector/search-value-selector.component.spec.ts +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.spec.ts @@ -1,6 +1,6 @@ import { Component, ViewChild } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormBuilder, FormControl } from '@angular/forms'; +import { FormBuilder } from '@angular/forms'; import { BehaviorSubject } from 'rxjs'; @@ -43,10 +43,8 @@ describe('SearchValueSelectorComponent', () => { hostComponent.searchValueSelectorComponent.inputListValues = subject; const formBuilder: FormBuilder = TestBed.get(FormBuilder); - const formGroup = formBuilder.group({ - testArray: [] - }); - hostComponent.searchValueSelectorComponent.formControl = formGroup.get('testArray'); + const formControl = formBuilder.control([]); + hostComponent.searchValueSelectorComponent.contentForm = formControl; hostFixture.detectChanges(); expect(hostComponent.searchValueSelectorComponent).toBeTruthy(); diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts b/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts index d01428439..23e19fe5b 100644 --- a/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts @@ -1,31 +1,40 @@ -import { ChangeDetectionStrategy, Component, Input, OnDestroy, ViewChild } from '@angular/core'; -import { FormControl } from '@angular/forms'; -import { MatSelect } from '@angular/material'; +import { FocusMonitor } from '@angular/cdk/a11y'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + Optional, + Self, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { FormBuilder, FormControl, NgControl } from '@angular/forms'; +import { MatFormFieldControl } from '@angular/material'; import { TranslateService } from '@ngx-translate/core'; -import { Observable, Subscription } from 'rxjs'; +import { Observable } from 'rxjs'; import { auditTime } from 'rxjs/operators'; +import { BaseFormControlComponent } from 'app/shared/models/base/base-form-control'; +import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher'; import { Selectable } from '../selectable'; /** - * Reusable Searchable Value Selector + * Searchable Value Selector * - * Use `multiple="true"`, `[InputListValues]=myValues`,`[formControl]="myformcontrol"` and `placeholder={{listname}}` to pass the Values and Listname + * Use `multiple="true"`, `[inputListValues]=myValues`,`formControlName="myformcontrol"` and `placeholder={{listname}}` to pass the Values and Listname * * ## Examples: * * ### Usage of the selector: * - * ngDefaultControl: https://stackoverflow.com/a/39053470 - * * ```html * + * [inputListValues]="myListValues" + * formControlName="myformcontrol"> * * ``` * @@ -35,23 +44,13 @@ import { Selectable } from '../selectable'; selector: 'os-search-value-selector', templateUrl: './search-value-selector.component.html', styleUrls: ['./search-value-selector.component.scss'], + providers: [{ provide: MatFormFieldControl, useExisting: SearchValueSelectorComponent }], + encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush }) -export class SearchValueSelectorComponent implements OnDestroy { - /** - * Saves the current subscription to _inputListSubject. - */ - private _inputListSubscription: Subscription = null; - - /** - * Value of the search input - */ - private searchValue = ''; - - /** - * All items - */ - private selectableItems: Selectable[]; +export class SearchValueSelectorComponent extends BaseFormControlComponent { + @ViewChild('chipPlaceholder', { static: false }) + public chipPlaceholder: ElementRef; /** * Decide if this should be a single or multi-select-field @@ -65,14 +64,14 @@ export class SearchValueSelectorComponent implements OnDestroy { @Input() public includeNone = false; + @Input() + public showChips = true; + @Input() public noneTitle = 'โ€“'; - /** - * Boolean, whether the component should be rendered with full width. - */ @Input() - public fullWidth = false; + public errorStateMatcher: ParentErrorStateMatcher; /** * The inputlist subject. Subscribes to it and updates the selector, if the subject @@ -83,55 +82,51 @@ export class SearchValueSelectorComponent implements OnDestroy { if (!value) { return; } - if (Array.isArray(value)) { this.selectableItems = value; } else { - // unsubscribe to old subscription. - if (this._inputListSubscription) { - this._inputListSubscription.unsubscribe(); - } - this._inputListSubscription = value.pipe(auditTime(10)).subscribe(items => { - this.selectableItems = items; - if (this.formControl) { - !!items && items.length > 0 - ? this.formControl.enable({ emitEvent: false }) - : this.formControl.disable({ emitEvent: false }); - } - }); + this.subscriptions.push( + value.pipe(auditTime(10)).subscribe(items => { + this.selectableItems = items; + if (this.contentForm) { + this.disabled = !items || (!!items && !items.length); + } + }) + ); } } - /** - * Placeholder of the List - */ - @Input() - public listname: string; + public searchValue: FormControl; + + public get empty(): boolean { + return Array.isArray(this.contentForm.value) ? !this.contentForm.value.length : !this.contentForm.value; + } + + public get selectedItems(): Selectable[] { + return this.selectableItems && this.contentForm.value + ? this.selectableItems.filter(item => this.contentForm.value.includes(item.id)) + : []; + } + + public controlType = 'search-value-selector'; + + public get width(): string { + return this.chipPlaceholder ? `${this.chipPlaceholder.nativeElement.clientWidth - 16}px` : '100%'; + } /** - * Name of the Form + * All items */ - @Input() - public formControl: FormControl; + private selectableItems: Selectable[]; - /** - * The MultiSelect Component - */ - @ViewChild('thisSelector', { static: true }) - public thisSelector: MatSelect; - - /** - * Empty constructor - */ - public constructor(protected translate: TranslateService) {} - - /** - * Unsubscribe on destroing. - */ - public ngOnDestroy(): void { - if (this._inputListSubscription) { - this._inputListSubscription.unsubscribe(); - } + public constructor( + protected translate: TranslateService, + formBuilder: FormBuilder, + @Optional() @Self() public ngControl: NgControl, + focusMonitor: FocusMonitor, + element: ElementRef + ) { + super(formBuilder, focusMonitor, element, ngControl); } /** @@ -141,29 +136,50 @@ export class SearchValueSelectorComponent implements OnDestroy { */ public getFilteredItems(): Selectable[] { if (this.selectableItems) { + const searchValue: string = this.searchValue.value.toLowerCase(); return this.selectableItems.filter(item => { const idString = '' + item.id; const foundId = idString .trim() .toLowerCase() - .indexOf(this.searchValue) !== -1; + .indexOf(searchValue) !== -1; if (foundId) { return true; } - const searchableString = this.translate.instant(item.getTitle()).toLowerCase(); - return searchableString.indexOf(this.searchValue) > -1; + + return ( + item + .toString() + .toLowerCase() + .indexOf(searchValue) > -1 + ); }); } } - /** - * Function to set the search value. - * - * @param searchValue the new value the user is searching for. - */ - public onSearch(searchValue: string): void { - this.searchValue = searchValue.toLowerCase(); + public removeItem(itemId: number): void { + const items = this.contentForm.value; + items.splice( + items.findIndex(item => item === itemId), + 1 + ); + this.contentForm.setValue(items); + } + + public onContainerClick(event: MouseEvent): void { + if ((event.target as Element).tagName.toLowerCase() !== 'select') { + // this.element.nativeElement.querySelector('select').focus(); + } + } + + protected initializeForm(): void { + this.contentForm = this.fb.control([]); + this.searchValue = this.fb.control(''); + } + + protected updateForm(value: Selectable[] | null): void { + this.contentForm.setValue(value); } } diff --git a/client/src/app/shared/components/slide-container/slide-container.component.ts b/client/src/app/shared/components/slide-container/slide-container.component.ts index 747616a67..da565ca1f 100644 --- a/client/src/app/shared/components/slide-container/slide-container.component.ts +++ b/client/src/app/shared/components/slide-container/slide-container.component.ts @@ -59,7 +59,7 @@ export class SlideContainerComponent extends BaseComponent { } if (error) { - console.log(error); + console.error(error); } return; } diff --git a/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.html b/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.html new file mode 100644 index 000000000..d28ec9c38 --- /dev/null +++ b/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.html @@ -0,0 +1,18 @@ +

+ Online voting is impossible to secure +

+ +
+ + During voting, OpenSlides does not store the individual user ID of the voter. This in no way means that a + non-nominal vote is completely anonymous and secure. You cannot track the decisions of your voters after the + data has been submitted. The validity of the data cannot always be guaranteed, especially if you use OpenSlides + in a distributed online setup. You are responsible for your own actions. + +
+ +
+ +
diff --git a/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.scss b/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.spec.ts b/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.spec.ts new file mode 100644 index 000000000..9190d7963 --- /dev/null +++ b/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { VotingPrivacyWarningComponent } from './voting-privacy-warning.component'; + +describe('VotingPrivacyWarningComponent', () => { + let component: VotingPrivacyWarningComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(VotingPrivacyWarningComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.ts b/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.ts new file mode 100644 index 000000000..da99280e7 --- /dev/null +++ b/client/src/app/shared/components/voting-privacy-warning/voting-privacy-warning.component.ts @@ -0,0 +1,12 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'os-voting-privacy-warning', + templateUrl: './voting-privacy-warning.component.html', + styleUrls: ['./voting-privacy-warning.component.scss'] +}) +export class VotingPrivacyWarningComponent implements OnInit { + public constructor() {} + + public ngOnInit(): void {} +} diff --git a/client/src/app/shared/directives/perms.directive.ts b/client/src/app/shared/directives/perms.directive.ts index cdb450285..d9908cfef 100644 --- a/client/src/app/shared/directives/perms.directive.ts +++ b/client/src/app/shared/directives/perms.directive.ts @@ -108,7 +108,7 @@ export class PermsDirective implements OnInit, OnDestroy { } /** - * COmes from the view. + * Comes from the view. */ @Input('osPermsComplement') public set osPermsComplement(value: boolean) { diff --git a/client/src/app/shared/models/assignments/assignment-option.ts b/client/src/app/shared/models/assignments/assignment-option.ts new file mode 100644 index 000000000..2800cc02a --- /dev/null +++ b/client/src/app/shared/models/assignments/assignment-option.ts @@ -0,0 +1,12 @@ +import { BaseOption } from '../poll/base-option'; + +export class AssignmentOption extends BaseOption { + public static COLLECTIONSTRING = 'assignments/assignment-option'; + + public user_id: number; + public weight: number; + + public constructor(input?: any) { + super(AssignmentOption.COLLECTIONSTRING, input); + } +} diff --git a/client/src/app/shared/models/assignments/assignment-poll-option.ts b/client/src/app/shared/models/assignments/assignment-poll-option.ts deleted file mode 100644 index 43bdc5a3b..000000000 --- a/client/src/app/shared/models/assignments/assignment-poll-option.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { PollVoteValue } from 'app/core/ui-services/poll.service'; -import { BaseModel } from '../base/base-model'; - -export interface AssignmentOptionVote { - weight: number; - value: PollVoteValue; -} - -/** - * Representation of a poll option - * - * part of the 'polls-options'-array in poll - * @ignore - */ -export class AssignmentPollOption extends BaseModel { - public static COLLECTIONSTRING = 'assignments/assignment-poll-option'; - - public id: number; // The AssignmentPollOption id - public candidate_id: number; // the user id of the candidate - public is_elected: boolean; - public votes: AssignmentOptionVote[]; - public poll_id: number; - public weight: number; // weight to order the display - - /** - * @param input - */ - public constructor(input?: any) { - if (input && input.votes) { - input.votes.forEach(vote => { - if (vote.weight) { - vote.weight = parseFloat(vote.weight); - } - }); - } - super(AssignmentPollOption.COLLECTIONSTRING, input); - } -} diff --git a/client/src/app/shared/models/assignments/assignment-poll.ts b/client/src/app/shared/models/assignments/assignment-poll.ts index eef94c237..7fdda5b29 100644 --- a/client/src/app/shared/models/assignments/assignment-poll.ts +++ b/client/src/app/shared/models/assignments/assignment-poll.ts @@ -1,42 +1,79 @@ -import { AssignmentPollMethod } from 'app/site/assignments/services/assignment-poll.service'; -import { AssignmentPollOption } from './assignment-poll-option'; -import { BaseModel } from '../base/base-model'; +import { CalculablePollKey } from 'app/site/polls/services/poll.service'; +import { AssignmentOption } from './assignment-option'; +import { BasePoll } from '../poll/base-poll'; -export interface AssignmentPollWithoutNestedModels extends BaseModel { - id: number; - pollmethod: AssignmentPollMethod; - description: string; - published: boolean; - votesvalid: number; - votesno: number; - votesabstain: number; - votesinvalid: number; - votescast: number; - has_votes: boolean; - assignment_id: number; +export enum AssignmentPollMethod { + YN = 'YN', + YNA = 'YNA', + Votes = 'votes' +} + +export enum AssignmentPollPercentBase { + YN = 'YN', + YNA = 'YNA', + Votes = 'votes', + Valid = 'valid', + Cast = 'cast', + Disabled = 'disabled' } /** - * Content of the 'polls' property of assignments - * @ignore + * Class representing a poll for an assignment. */ -export class AssignmentPoll extends BaseModel { +export class AssignmentPoll extends BasePoll< + AssignmentPoll, + AssignmentOption, + AssignmentPollMethod, + AssignmentPollPercentBase +> { public static COLLECTIONSTRING = 'assignments/assignment-poll'; - private static DECIMAL_FIELDS = ['votesvalid', 'votesinvalid', 'votescast', 'votesno', 'votesabstain']; + public static defaultGroupsConfig = 'assignment_poll_default_groups'; + public static defaultPollMethodConfig = 'assignment_poll_method'; + public static DECIMAL_FIELDS = [ + 'votesvalid', + 'votesinvalid', + 'votescast', + 'amount_global_abstain', + 'amount_global_no' + ]; public id: number; - public options: AssignmentPollOption[]; + public assignment_id: number; + public votes_amount: number; + public allow_multiple_votes_per_candidate: boolean; + public global_no: boolean; + public global_abstain: boolean; + public amount_global_no: number; + public amount_global_abstain: number; + public description: string; + + public get isMethodY(): boolean { + return this.pollmethod === AssignmentPollMethod.Votes; + } + + public get isMethodYN(): boolean { + return this.pollmethod === AssignmentPollMethod.YN; + } + + public get isMethodYNA(): boolean { + return this.pollmethod === AssignmentPollMethod.YNA; + } + + public get pollmethodFields(): CalculablePollKey[] { + if (this.pollmethod === AssignmentPollMethod.YN) { + return ['yes', 'no']; + } else if (this.pollmethod === AssignmentPollMethod.YNA) { + return ['yes', 'no', 'abstain']; + } else if (this.pollmethod === AssignmentPollMethod.Votes) { + return ['yes']; + } + } public constructor(input?: any) { - // cast stringify numbers - if (input) { - AssignmentPoll.DECIMAL_FIELDS.forEach(field => { - if (input[field] && typeof input[field] === 'string') { - input[field] = parseFloat(input[field]); - } - }); - } super(AssignmentPoll.COLLECTIONSTRING, input); } + + protected getDecimalFields(): string[] { + return AssignmentPoll.DECIMAL_FIELDS; + } } -export interface AssignmentPoll extends AssignmentPollWithoutNestedModels {} diff --git a/client/src/app/shared/models/assignments/assignment-related-user.ts b/client/src/app/shared/models/assignments/assignment-related-user.ts index 5325d2a9f..07b931dc5 100644 --- a/client/src/app/shared/models/assignments/assignment-related-user.ts +++ b/client/src/app/shared/models/assignments/assignment-related-user.ts @@ -8,7 +8,6 @@ export class AssignmentRelatedUser extends BaseModel { public id: number; public user_id: number; - public elected: boolean; public assignment_id: number; public weight: number; diff --git a/client/src/app/shared/models/assignments/assignment-vote.ts b/client/src/app/shared/models/assignments/assignment-vote.ts new file mode 100644 index 000000000..0292cd7c7 --- /dev/null +++ b/client/src/app/shared/models/assignments/assignment-vote.ts @@ -0,0 +1,11 @@ +import { BaseVote } from '../poll/base-vote'; + +export class AssignmentVote extends BaseVote { + public static COLLECTIONSTRING = 'assignments/assignment-vote'; + + public id: number; + + public constructor(input?: any) { + super(AssignmentVote.COLLECTIONSTRING, input); + } +} diff --git a/client/src/app/shared/models/assignments/assignment.ts b/client/src/app/shared/models/assignments/assignment.ts index d05a90f4a..f58e01a38 100644 --- a/client/src/app/shared/models/assignments/assignment.ts +++ b/client/src/app/shared/models/assignments/assignment.ts @@ -1,4 +1,3 @@ -import { AssignmentPoll } from './assignment-poll'; import { AssignmentRelatedUser } from './assignment-related-user'; import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers'; @@ -8,9 +7,10 @@ export interface AssignmentWithoutNestedModels extends BaseModelWithAgendaItemAn description: string; open_posts: number; phase: number; // see Openslides constants - poll_description_default: number; + default_poll_description: string; tags_id: number[]; attachments_id: number[]; + number_poll_candidates: boolean; } /** @@ -22,18 +22,9 @@ export class Assignment extends BaseModelWithAgendaItemAndListOfSpeakers { - return a.weight - b.weight; - }) - .map((candidate: AssignmentRelatedUser) => candidate.user_id); - } } export interface Assignment extends AssignmentWithoutNestedModels {} diff --git a/client/src/app/shared/models/base/base-decimal-model.ts b/client/src/app/shared/models/base/base-decimal-model.ts new file mode 100644 index 000000000..7be77ea1f --- /dev/null +++ b/client/src/app/shared/models/base/base-decimal-model.ts @@ -0,0 +1,12 @@ +import { BaseModel } from './base-model'; + +export abstract class BaseDecimalModel extends BaseModel { + protected abstract getDecimalFields(): string[]; + + public deserialize(input: any): void { + if (input && typeof input === 'object') { + this.getDecimalFields().forEach(field => (input[field] = parseInt(input[field], 10))); + } + super.deserialize(input); + } +} diff --git a/client/src/app/shared/models/base/base-form-control.ts b/client/src/app/shared/models/base/base-form-control.ts new file mode 100644 index 000000000..865dda574 --- /dev/null +++ b/client/src/app/shared/models/base/base-form-control.ts @@ -0,0 +1,161 @@ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ElementRef, HostBinding, Input, OnDestroy, Optional, Self } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, NgControl } from '@angular/forms'; +import { MatFormFieldControl } from '@angular/material'; + +import { Subject, Subscription } from 'rxjs'; + +/** + * Abstract class to implement some simple logic and provide the subclass as a controllable form-control in `MatFormField`. + * + * Please remember to prepare the `providers` in the `@Component`-decorator. Something like: + * + * ```ts + * @Component({ + * selector: ..., + * templateUrl: ..., + * styleUrls: [...], + * providers: [{ provide: MatFormFieldControl, useExisting: }] + * }) + * ``` + */ +export abstract class BaseFormControlComponent extends MatFormFieldControl + implements OnDestroy, ControlValueAccessor { + public static nextId = 0; + + @HostBinding() public id = `base-form-control-${BaseFormControlComponent.nextId++}`; + + @HostBinding('class.floating') public get shouldLabelFloat(): boolean { + return this.focused || !this.empty; + } + + @HostBinding('attr.aria-describedby') public describedBy = ''; + + @Input() + public set value(value: T | null) { + this.updateForm(value); + this.stateChanges.next(); + } + + public get value(): T | null { + return this.contentForm.value || null; + } + + @Input() + public set placeholder(placeholder: string) { + this._placeholder = placeholder; + this.stateChanges.next(); + } + + public get placeholder(): string { + return this._placeholder; + } + + @Input() + public set required(required: boolean) { + this._required = coerceBooleanProperty(required); + this.stateChanges.next(); + } + + public get required(): boolean { + return this._required; + } + + @Input() + public set disabled(disable: boolean) { + this._disabled = coerceBooleanProperty(disable); + this._disabled ? this.contentForm.disable() : this.contentForm.enable(); + this.stateChanges.next(); + } + + public get disabled(): boolean { + return this._disabled; + } + + public abstract get empty(): boolean; + + public abstract get controlType(): string; + + public contentForm: FormControl | FormGroup; + + public stateChanges = new Subject(); + + public errorState = false; + + public focused = false; + + private _placeholder: string; + + private _required = false; + + private _disabled = false; + + protected subscriptions: Subscription[] = []; + + public constructor( + protected fb: FormBuilder, + protected fm: FocusMonitor, + protected element: ElementRef, + @Optional() @Self() public ngControl: NgControl + ) { + super(); + + this.initializeForm(); + + if (this.ngControl !== null) { + this.ngControl.valueAccessor = this; + } + + this.subscriptions.push( + fm.monitor(element.nativeElement, true).subscribe(origin => { + this.focused = origin === 'mouse' || origin === 'touch'; + this.stateChanges.next(); + }), + this.contentForm.valueChanges.subscribe(nextValue => this.push(nextValue)) + ); + } + + public ngOnDestroy(): void { + for (const subscription of this.subscriptions) { + subscription.unsubscribe(); + } + this.subscriptions = []; + + this.fm.stopMonitoring(this.element.nativeElement); + + this.stateChanges.complete(); + } + + public writeValue(value: T): void { + this.value = value; + } + public registerOnChange(fn: any): void { + this._onChange = fn; + } + public registerOnTouched(fn: any): void { + this._onTouched = fn; + } + public setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + public setDescribedByIds(ids: string[]): void { + this.describedBy = ids.join(' '); + } + + public abstract onContainerClick(event: MouseEvent): void; + + protected _onChange = (value: T) => {}; + + protected _onTouched = (value: T) => {}; + + protected abstract initializeForm(): void; + + protected abstract updateForm(value: T | null): void; + + protected push(value: T): void { + this._onChange(value); + this._onTouched(value); + } +} diff --git a/client/src/app/shared/models/core/config.ts b/client/src/app/shared/models/core/config.ts index dfc5367e3..5e1368e40 100644 --- a/client/src/app/shared/models/core/config.ts +++ b/client/src/app/shared/models/core/config.ts @@ -2,7 +2,7 @@ import { BaseModel } from '../base/base-model'; export interface ConfigChoice { value: string; - displayName: string; + display_name: string; } /** @@ -17,7 +17,8 @@ export type ConfigInputType = | 'choice' | 'datetimepicker' | 'colorpicker' - | 'translations'; + | 'translations' + | 'groups'; export interface ConfigData { defaultValue: any; diff --git a/client/src/app/shared/models/motions/motion-option.ts b/client/src/app/shared/models/motions/motion-option.ts new file mode 100644 index 000000000..765ebe484 --- /dev/null +++ b/client/src/app/shared/models/motions/motion-option.ts @@ -0,0 +1,9 @@ +import { BaseOption } from '../poll/base-option'; + +export class MotionOption extends BaseOption { + public static COLLECTIONSTRING = 'motions/motion-option'; + + public constructor(input?: any) { + super(MotionOption.COLLECTIONSTRING, input); + } +} diff --git a/client/src/app/shared/models/motions/motion-poll.ts b/client/src/app/shared/models/motions/motion-poll.ts index 12e1c1370..0e551bdbd 100644 --- a/client/src/app/shared/models/motions/motion-poll.ts +++ b/client/src/app/shared/models/motions/motion-poll.ts @@ -1,36 +1,32 @@ -import { Deserializer } from '../base/deserializer'; +import { CalculablePollKey } from 'app/site/polls/services/poll.service'; +import { BasePoll, PercentBase } from '../poll/base-poll'; +import { MotionOption } from './motion-option'; + +export enum MotionPollMethod { + YN = 'YN', + YNA = 'YNA' +} /** * Class representing a poll for a motion. */ -export class MotionPoll extends Deserializer { +export class MotionPoll extends BasePoll { + public static COLLECTIONSTRING = 'motions/motion-poll'; + public static defaultGroupsConfig = 'motion_poll_default_groups'; + public id: number; - public yes: number; - public no: number; - public abstain: number; - public votesvalid: number; - public votesinvalid: number; - public votescast: number; - public has_votes: boolean; public motion_id: number; - /** - * Needs to be completely optional because motion has (yet) the optional parameter 'polls' - * Tries to cast incoming strings as numbers - * @param input - */ - public constructor(input?: any) { - if (typeof input === 'object') { - Object.keys(input).forEach(key => { - if (typeof input[key] === 'string') { - input[key] = parseInt(input[key], 10); - } - }); + public get pollmethodFields(): CalculablePollKey[] { + const ynField: CalculablePollKey[] = ['yes', 'no']; + if (this.pollmethod === MotionPollMethod.YN) { + return ynField; + } else if (this.pollmethod === MotionPollMethod.YNA) { + return ynField.concat(['abstain']); } - super(input); } - public deserialize(input: any): void { - Object.assign(this, input); + public constructor(input?: any) { + super(MotionPoll.COLLECTIONSTRING, input); } } diff --git a/client/src/app/shared/models/motions/motion-vote.ts b/client/src/app/shared/models/motions/motion-vote.ts new file mode 100644 index 000000000..930e6b0c6 --- /dev/null +++ b/client/src/app/shared/models/motions/motion-vote.ts @@ -0,0 +1,11 @@ +import { BaseVote } from '../poll/base-vote'; + +export class MotionVote extends BaseVote { + public static COLLECTIONSTRING = 'motions/motion-vote'; + + public id: number; + + public constructor(input?: any) { + super(MotionVote.COLLECTIONSTRING, input); + } +} diff --git a/client/src/app/shared/models/motions/motion.ts b/client/src/app/shared/models/motions/motion.ts index 6aa35620e..2d3a7c9f2 100644 --- a/client/src/app/shared/models/motions/motion.ts +++ b/client/src/app/shared/models/motions/motion.ts @@ -1,5 +1,4 @@ import { BaseModelWithAgendaItemAndListOfSpeakers } from '../base/base-model-with-agenda-item-and-list-of-speakers'; -import { MotionPoll } from './motion-poll'; import { Submitter } from './submitter'; export interface MotionComment { @@ -33,7 +32,6 @@ export interface MotionWithoutNestedModels extends BaseModelWithAgendaItemAndLis recommendation_extension: string; tags_id: number[]; attachments_id: number[]; - polls: MotionPoll[]; weight: number; sort_parent_id: number; created: string; diff --git a/client/src/app/shared/models/poll/base-option.ts b/client/src/app/shared/models/poll/base-option.ts new file mode 100644 index 000000000..74d15e79c --- /dev/null +++ b/client/src/app/shared/models/poll/base-option.ts @@ -0,0 +1,14 @@ +import { BaseDecimalModel } from '../base/base-decimal-model'; + +export abstract class BaseOption extends BaseDecimalModel { + public id: number; + public yes: number; + public no: number; + public abstain: number; + public poll_id: number; + public voted_id: number[]; + + protected getDecimalFields(): string[] { + return ['yes', 'no', 'abstain']; + } +} diff --git a/client/src/app/shared/models/poll/base-poll.ts b/client/src/app/shared/models/poll/base-poll.ts new file mode 100644 index 000000000..b6334732f --- /dev/null +++ b/client/src/app/shared/models/poll/base-poll.ts @@ -0,0 +1,114 @@ +import { BaseDecimalModel } from '../base/base-decimal-model'; +import { BaseOption } from './base-option'; + +export enum PollColor { + yes = '#4caf50', + no = '#cc6c5b', + abstain = '#a6a6a6', + votesvalid = '#e2e2e2', + votesinvalid = '#e2e2e2', + votescast = '#e2e2e2' +} + +export enum PollState { + Created = 1, + Started, + Finished, + Published +} + +export enum PollType { + Analog = 'analog', + Named = 'named', + Pseudoanonymous = 'pseudoanonymous' +} + +export enum MajorityMethod { + Simple = 'simple', + TwoThirds = 'two_thirds', + ThreeQuarters = 'three_quarters', + Disabled = 'disabled' +} + +export enum PercentBase { + YN = 'YN', + YNA = 'YNA', + Valid = 'valid', + Cast = 'cast', + Disabled = 'disabled' +} + +export const VOTE_MAJORITY = -1; +export const VOTE_UNDOCUMENTED = -2; +export const LOWEST_VOTE_VALUE = VOTE_UNDOCUMENTED; + +export abstract class BasePoll< + T = any, + O extends BaseOption = any, + PM extends string = string, + PB extends string = string +> extends BaseDecimalModel { + public state: PollState; + public type: PollType; + public title: string; + public votesvalid: number; + public votesinvalid: number; + public votescast: number; + public groups_id: number[]; + public majority_method: MajorityMethod; + public user_has_voted: boolean; + + public pollmethod: PM; + public onehundred_percent_base: PB; + + public get isCreated(): boolean { + return this.state === PollState.Created; + } + + public get isStarted(): boolean { + return this.state === PollState.Started; + } + + public get isFinished(): boolean { + return this.state === PollState.Finished; + } + + public get isPublished(): boolean { + return this.state === PollState.Published; + } + + public get isPercentBaseCast(): boolean { + return this.onehundred_percent_base === PercentBase.Cast; + } + + public get isAnalog(): boolean { + return this.type === PollType.Analog; + } + + public get isNamed(): boolean { + return this.type === PollType.Named; + } + + public get isAnon(): boolean { + return this.type === PollType.Pseudoanonymous; + } + + public get isEVoting(): boolean { + return this.isNamed || this.isAnon; + } + + /** + * Determine if the state is finished or published + */ + public get stateHasVotes(): boolean { + return this.isFinished || this.isPublished; + } + + public get nextState(): PollState { + return this.state + 1; + } + + protected getDecimalFields(): string[] { + return ['votesvalid', 'votesinvalid', 'votescast']; + } +} diff --git a/client/src/app/shared/models/poll/base-vote.ts b/client/src/app/shared/models/poll/base-vote.ts new file mode 100644 index 000000000..b761984af --- /dev/null +++ b/client/src/app/shared/models/poll/base-vote.ts @@ -0,0 +1,32 @@ +import { BaseDecimalModel } from '../base/base-decimal-model'; + +export type VoteValue = 'Y' | 'N' | 'A'; + +export const VoteValueVerbose = { + Y: 'Yes', + N: 'No', + A: 'Abstain' +}; + +export const GeneralValueVerbose = { + votesvalid: 'Valid votes', + votesinvalid: 'Invalid votes', + votescast: 'Total votes cast', + votesno: 'Votes No', + votesabstain: 'Votes abstain' +}; + +export abstract class BaseVote extends BaseDecimalModel { + public weight: number; + public value: VoteValue; + public option_id: number; + public user_id?: number; + + public get valueVerbose(): string { + return VoteValueVerbose[this.value]; + } + + protected getDecimalFields(): string[] { + return ['weight']; + } +} diff --git a/client/src/app/shared/pipes/parse-poll-number.pipe.spec.ts b/client/src/app/shared/pipes/parse-poll-number.pipe.spec.ts new file mode 100644 index 000000000..5e2523140 --- /dev/null +++ b/client/src/app/shared/pipes/parse-poll-number.pipe.spec.ts @@ -0,0 +1,20 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { ParsePollNumberPipe } from './parse-poll-number.pipe'; + +describe('ParsePollNumberPipe', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ParsePollNumberPipe] + }); + TestBed.compileComponents(); + }); + + it('create an instance', inject([TranslateService], (translate: TranslateService) => { + const pipe = new ParsePollNumberPipe(translate); + expect(pipe).toBeTruthy(); + })); +}); diff --git a/client/src/app/shared/pipes/parse-poll-number.pipe.ts b/client/src/app/shared/pipes/parse-poll-number.pipe.ts new file mode 100644 index 000000000..1f49249ae --- /dev/null +++ b/client/src/app/shared/pipes/parse-poll-number.pipe.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { VOTE_MAJORITY, VOTE_UNDOCUMENTED } from '../models/poll/base-poll'; + +@Pipe({ + name: 'parsePollNumber' +}) +export class ParsePollNumberPipe implements PipeTransform { + public constructor(private translate: TranslateService) {} + + public transform(value: number): number | string { + const input = Math.trunc(value); + switch (input) { + case VOTE_MAJORITY: + return this.translate.instant('majority'); + case VOTE_UNDOCUMENTED: + return this.translate.instant('undocumented'); + default: + return input; + } + } +} diff --git a/client/src/app/shared/pipes/poll-key-verbose.pipe.spec.ts b/client/src/app/shared/pipes/poll-key-verbose.pipe.spec.ts new file mode 100644 index 000000000..1ffee9c1e --- /dev/null +++ b/client/src/app/shared/pipes/poll-key-verbose.pipe.spec.ts @@ -0,0 +1,8 @@ +import { PollKeyVerbosePipe } from './poll-key-verbose.pipe'; + +describe('PollKeyVerbosePipe', () => { + it('create an instance', () => { + const pipe = new PollKeyVerbosePipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/pipes/poll-key-verbose.pipe.ts b/client/src/app/shared/pipes/poll-key-verbose.pipe.ts new file mode 100644 index 000000000..ed71000b3 --- /dev/null +++ b/client/src/app/shared/pipes/poll-key-verbose.pipe.ts @@ -0,0 +1,26 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +const PollValues = { + votesvalid: 'Valid votes', + votesinvalid: 'Invalid votes', + votescast: 'Total votes cast', + votesno: 'Votes No', + votesabstain: 'Votes abstain', + yes: 'Yes', + no: 'No', + abstain: 'Abstain', + amount_global_abstain: 'General abstain', + amount_global_no: 'General no' +}; + +/** + * Pipe to transform a key from polls into a speaking word. + */ +@Pipe({ + name: 'pollKeyVerbose' +}) +export class PollKeyVerbosePipe implements PipeTransform { + public transform(value: string): string { + return PollValues[value] || value; + } +} diff --git a/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts b/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts new file mode 100644 index 000000000..880847114 --- /dev/null +++ b/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts @@ -0,0 +1,24 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service'; +import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; +import { PollPercentBasePipe } from './poll-percent-base.pipe'; + +describe('PollPercentBasePipe', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }); + TestBed.compileComponents(); + }); + + it('create an instance', inject( + [AssignmentPollService, MotionPollService], + (assignmentPollService: AssignmentPollService, motionPollService: MotionPollService) => { + const pipe = new PollPercentBasePipe(assignmentPollService, motionPollService); + expect(pipe).toBeTruthy(); + } + )); +}); diff --git a/client/src/app/shared/pipes/poll-percent-base.pipe.ts b/client/src/app/shared/pipes/poll-percent-base.pipe.ts new file mode 100644 index 000000000..2c15d8f0f --- /dev/null +++ b/client/src/app/shared/pipes/poll-percent-base.pipe.ts @@ -0,0 +1,44 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service'; +import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; +import { PollData } from 'app/site/polls/services/poll.service'; + +/** + * Uses a number and a ViewPoll-object. + * Converts the number to the voting percent base using the + * given 100%-Base option in the poll object + * + * returns null if a percent calculation is not possible + * or the result is 0 + * + * @example + * ```html + * {{ voteYes | pollPercentBase: poll }} + * ``` + */ +@Pipe({ + name: 'pollPercentBase' +}) +export class PollPercentBasePipe implements PipeTransform { + public constructor( + private assignmentPollService: AssignmentPollService, + private motionPollService: MotionPollService + ) {} + + public transform(value: number, poll: PollData): string | null { + // logic handles over the pollService to avoid circular dependencies + let voteValueInPercent: string; + if ((poll).assignment) { + voteValueInPercent = this.assignmentPollService.getVoteValueInPercent(value, poll); + } else { + voteValueInPercent = this.motionPollService.getVoteValueInPercent(value, poll); + } + + if (voteValueInPercent) { + return `(${voteValueInPercent})`; + } else { + return null; + } + } +} diff --git a/client/src/app/shared/pipes/reverse.pipe.spec.ts b/client/src/app/shared/pipes/reverse.pipe.spec.ts new file mode 100644 index 000000000..fc22c38d6 --- /dev/null +++ b/client/src/app/shared/pipes/reverse.pipe.spec.ts @@ -0,0 +1,8 @@ +import { ReversePipe } from './reverse.pipe'; + +describe('ReversePipe', () => { + it('create an instance', () => { + const pipe = new ReversePipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/pipes/reverse.pipe.ts b/client/src/app/shared/pipes/reverse.pipe.ts new file mode 100644 index 000000000..9e52edc6c --- /dev/null +++ b/client/src/app/shared/pipes/reverse.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * Invert the order of arrays in templates + * + * @example + * ```html + *
  • + * {{ user.name }} has the id: {{ user.id }} + *
  • + * ``` + */ +@Pipe({ + name: 'reverse' +}) +export class ReversePipe implements PipeTransform { + public transform(value: any[]): any[] { + return value.slice().reverse(); + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index ab33cbef2..e0b01e055 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -6,7 +6,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; // MaterialUI modules import { MatBadgeModule } from '@angular/material/badge'; import { MatBottomSheetModule } from '@angular/material/bottom-sheet'; -import { MatButtonModule } from '@angular/material/button'; +import { MatButtonModule, MatAnchor } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -21,6 +21,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSliderModule } from '@angular/material/slider'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSortModule } from '@angular/material/sort'; import { MatStepperModule } from '@angular/material/stepper'; @@ -64,6 +65,7 @@ import { PblNgridTargetEventsModule } from '@pebula/ngrid/target-events'; // time picker because angular still doesnt offer one!! import { NgxMaterialTimepickerModule } from 'ngx-material-timepicker'; +import { ChartsModule } from 'ng2-charts'; // components import { HeadBarComponent } from './components/head-bar/head-bar.component'; @@ -109,6 +111,18 @@ import { GlobalSpinnerComponent } from 'app/site/common/components/global-spinne import { HeightResizingDirective } from './directives/height-resizing.directive'; import { TrustPipe } from './pipes/trust.pipe'; import { LocalizedDatePipe } from './pipes/localized-date.pipe'; +import { ChartsComponent } from './components/charts/charts.component'; +import { CheckInputComponent } from './components/check-input/check-input.component'; +import { BannerComponent } from './components/banner/banner.component'; +import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component'; +import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component'; +import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component'; +import { ParsePollNumberPipe } from './pipes/parse-poll-number.pipe'; +import { ReversePipe } from './pipes/reverse.pipe'; +import { PollKeyVerbosePipe } from './pipes/poll-key-verbose.pipe'; +import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe'; +import { VotingPrivacyWarningComponent } from './components/voting-privacy-warning/voting-privacy-warning.component'; +import { MotionPollDetailContentComponent } from './components/motion-poll-detail-content/motion-poll-detail-content.component'; /** * Share Module for all "dumb" components and pipes. @@ -158,6 +172,7 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; MatStepperModule, MatTabsModule, MatSliderModule, + MatSlideToggleModule, MatDividerModule, DragDropModule, OpenSlidesTranslateModule.forChild(), @@ -171,7 +186,8 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; PblNgridMaterialModule, PblNgridTargetEventsModule, PdfViewerModule, - NgxMaterialTimepickerModule + NgxMaterialTimepickerModule, + ChartsModule ], exports: [ FormsModule, @@ -205,6 +221,7 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; MatButtonToggleModule, MatStepperModule, MatSliderModule, + MatSlideToggleModule, MatDividerModule, DragDropModule, NgxMatSelectSearchModule, @@ -257,8 +274,21 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; OverlayComponent, PreviewComponent, NgxMaterialTimepickerModule, + ChartsModule, TrustPipe, - LocalizedDatePipe + LocalizedDatePipe, + ChartsComponent, + CheckInputComponent, + BannerComponent, + PollFormComponent, + MotionPollDialogComponent, + AssignmentPollDialogComponent, + ParsePollNumberPipe, + ReversePipe, + PollKeyVerbosePipe, + PollPercentBasePipe, + VotingPrivacyWarningComponent, + MotionPollDetailContentComponent ], declarations: [ PermsDirective, @@ -305,7 +335,19 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; PreviewComponent, HeightResizingDirective, TrustPipe, - LocalizedDatePipe + LocalizedDatePipe, + ChartsComponent, + CheckInputComponent, + BannerComponent, + PollFormComponent, + MotionPollDialogComponent, + AssignmentPollDialogComponent, + ParsePollNumberPipe, + ReversePipe, + PollKeyVerbosePipe, + PollPercentBasePipe, + VotingPrivacyWarningComponent, + MotionPollDetailContentComponent ], providers: [ { @@ -321,7 +363,11 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; DecimalPipe, ProgressSnackBarComponent, TrustPipe, - LocalizedDatePipe + LocalizedDatePipe, + ParsePollNumberPipe, + ReversePipe, + PollKeyVerbosePipe, + PollPercentBasePipe ], entryComponents: [ SortBottomSheetComponent, @@ -330,7 +376,10 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; ChoiceDialogComponent, ProjectionDialogComponent, ProgressSnackBarComponent, - SuperSearchComponent + SuperSearchComponent, + MotionPollDialogComponent, + AssignmentPollDialogComponent, + VotingPrivacyWarningComponent ] }) export class SharedModule {} diff --git a/client/src/app/site/agenda/agenda.config.ts b/client/src/app/site/agenda/agenda.config.ts index 757dacae3..4d212b251 100644 --- a/client/src/app/site/agenda/agenda.config.ts +++ b/client/src/app/site/agenda/agenda.config.ts @@ -9,9 +9,8 @@ import { ViewListOfSpeakers } from './models/view-list-of-speakers'; export const AgendaAppConfig: AppConfig = { name: 'agenda', models: [ - { collectionString: 'agenda/item', model: Item, viewModel: ViewItem, repository: ItemRepositoryService }, + { model: Item, viewModel: ViewItem, repository: ItemRepositoryService }, { - collectionString: 'agenda/list-of-speakers', model: ListOfSpeakers, viewModel: ViewListOfSpeakers, repository: ListOfSpeakersRepositoryService diff --git a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html index 597f3f099..d19363f57 100644 --- a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html @@ -14,7 +14,7 @@ +

    @@ -8,7 +14,15 @@

    @@ -77,12 +91,7 @@
    - + @@ -124,13 +133,14 @@
    - + + +
    diff --git a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts index 764d702e4..e03151b4e 100644 --- a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts +++ b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts @@ -154,7 +154,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit private config: ConfigService ) { super(title, translate, snackBar); - this.addSpeakerForm = new FormGroup({ user_id: new FormControl([]) }); + this.addSpeakerForm = new FormGroup({ user_id: new FormControl() }); } /** @@ -201,6 +201,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit } }) ); + this.subscriptions.push( this.config.get('agenda_present_speakers_only').subscribe(() => { this.filterUsers(); diff --git a/client/src/app/site/assignments/assignments-routing.module.ts b/client/src/app/site/assignments/assignments-routing.module.ts index 85be8bddc..ef4dffdf9 100644 --- a/client/src/app/site/assignments/assignments-routing.module.ts +++ b/client/src/app/site/assignments/assignments-routing.module.ts @@ -3,11 +3,13 @@ import { RouterModule, Routes } from '@angular/router'; import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component'; import { AssignmentListComponent } from './components/assignment-list/assignment-list.component'; +import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component'; const routes: Routes = [ { path: '', component: AssignmentListComponent, pathMatch: 'full' }, { path: 'new', component: AssignmentDetailComponent, data: { basePerm: 'assignments.can_manage' } }, - { path: ':id', component: AssignmentDetailComponent, data: { basePerm: 'assignments.can_see' } } + { path: ':id', component: AssignmentDetailComponent, data: { basePerm: 'assignments.can_see' } }, + { path: 'polls', children: [{ path: ':id', component: AssignmentPollDetailComponent }] } ]; @NgModule({ diff --git a/client/src/app/site/assignments/assignments.config.ts b/client/src/app/site/assignments/assignments.config.ts index 6826f666a..78aa5bf01 100644 --- a/client/src/app/site/assignments/assignments.config.ts +++ b/client/src/app/site/assignments/assignments.config.ts @@ -1,17 +1,40 @@ import { AppConfig } from '../../core/definitions/app-config'; +import { AssignmentOptionRepositoryService } from 'app/core/repositories/assignments/assignment-option-repository.service'; +import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; +import { AssignmentVoteRepositoryService } from 'app/core/repositories/assignments/assignment-vote-repository.service'; +import { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; +import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; +import { AssignmentVote } from 'app/shared/models/assignments/assignment-vote'; import { Assignment } from '../../shared/models/assignments/assignment'; import { ViewAssignment } from './models/view-assignment'; +import { ViewAssignmentOption } from './models/view-assignment-option'; +import { ViewAssignmentPoll } from './models/view-assignment-poll'; +import { ViewAssignmentVote } from './models/view-assignment-vote'; export const AssignmentsAppConfig: AppConfig = { name: 'assignments', models: [ { - collectionString: 'assignments/assignment', model: Assignment, viewModel: ViewAssignment, - // searchOrder: 3, // TODO: enable, if there is a detail page and so on. + searchOrder: 3, repository: AssignmentRepositoryService + }, + { + model: AssignmentPoll, + viewModel: ViewAssignmentPoll, + repository: AssignmentPollRepositoryService + }, + { + model: AssignmentVote, + viewModel: ViewAssignmentVote, + repository: AssignmentVoteRepositoryService + }, + { + model: AssignmentOption, + viewModel: ViewAssignmentOption, + repository: AssignmentOptionRepositoryService } ], mainMenuEntries: [ diff --git a/client/src/app/site/assignments/assignments.module.ts b/client/src/app/site/assignments/assignments.module.ts index 70d8cf9da..ac6067455 100644 --- a/client/src/app/site/assignments/assignments.module.ts +++ b/client/src/app/site/assignments/assignments.module.ts @@ -3,19 +3,21 @@ import { NgModule } from '@angular/core'; import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component'; import { AssignmentListComponent } from './components/assignment-list/assignment-list.component'; -import { AssignmentPollDialogComponent } from './components/assignment-poll-dialog/assignment-poll-dialog.component'; +import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component'; +import { AssignmentPollVoteComponent } from './components/assignment-poll-vote/assignment-poll-vote.component'; import { AssignmentPollComponent } from './components/assignment-poll/assignment-poll.component'; import { AssignmentsRoutingModule } from './assignments-routing.module'; +import { PollsModule } from '../polls/polls.module'; import { SharedModule } from '../../shared/shared.module'; @NgModule({ - imports: [CommonModule, AssignmentsRoutingModule, SharedModule], + imports: [CommonModule, AssignmentsRoutingModule, SharedModule, PollsModule], declarations: [ - AssignmentListComponent, AssignmentDetailComponent, + AssignmentListComponent, AssignmentPollComponent, - AssignmentPollDialogComponent - ], - entryComponents: [AssignmentPollDialogComponent] + AssignmentPollDetailComponent, + AssignmentPollVoteComponent + ] }) export class AssignmentsModule {} 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 dba66ddda..74bf74784 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 @@ -24,7 +24,6 @@
    @@ -58,13 +57,31 @@ -
    +
    + - + + + + + + + + + +
    + +
    + + +
    @@ -116,114 +133,81 @@ - - - - - - - - - - - - -
    -
    -

    Election result

    -
    - -
    - -
    -
    - - - - - - -
    - - -

    Candidates

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

    Candidates

    +
    +
    - - -
    + + + + + + + + +
    - -
    -
    - - +
    + +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    -
    -
    - + + @@ -238,11 +222,7 @@
    - + {{ 'The title is required' | translate }}
    @@ -256,22 +236,20 @@ > -
    - -
    +
    + + + - -
    @@ -280,13 +258,13 @@ [form]="assignmentForm" > - +
    @@ -301,15 +279,21 @@ type="number" required /> - {{ - 'This field is required.' | translate - }} - {{ - 'The number has to be greater than 0.' | translate - }} + + {{ 'This field is required.' | translate }} + + + {{ 'The number has to be greater than 0.' | translate }} +
    - + + +
    + + Number candidates + +
    diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss index 0f60e5056..1f87518de 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss @@ -26,6 +26,14 @@ } } +.new-ballot-button { + display: flex; + > * { + margin-left: auto; + margin-right: auto; + } +} + .election-document-list mat-list-item { height: 20px; } @@ -39,11 +47,8 @@ padding: 15px 25px 0 25px; width: auto; - .search-bar { - display: grid; - .mat-form-field { - width: 100%; - } + .mat-form-field { + width: 100%; } } } @@ -59,10 +64,6 @@ margin: 0; } } - - .ballot-button { - grid-column: 2; - } } .candidate-list-separator { diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts index a1788b75a..1c51f8bb8 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts @@ -1,8 +1,11 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component'; import { AssignmentDetailComponent } from './assignment-detail.component'; +import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component'; import { AssignmentPollComponent } from '../assignment-poll/assignment-poll.component'; -import { E2EImportsModule } from '../../../../../e2e-imports.module'; describe('AssignmentDetailComponent', () => { let component: AssignmentDetailComponent; @@ -11,7 +14,12 @@ describe('AssignmentDetailComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [E2EImportsModule], - declarations: [AssignmentDetailComponent, AssignmentPollComponent] + declarations: [ + AssignmentDetailComponent, + AssignmentPollComponent, + AssignmentPollVoteComponent, + PollProgressComponent + ] }).compileComponents(); })); diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts index 2d57c5c9c..dd431bd41 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts @@ -15,7 +15,6 @@ import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository. import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; import { Assignment } from 'app/shared/models/assignments/assignment'; -import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; import { ViewItem } from 'app/site/agenda/models/view-item'; import { BaseViewComponent } from 'app/site/base/base-view'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; @@ -23,8 +22,10 @@ import { LocalPermissionsService } from 'app/site/motions/services/local-permiss import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewUser } from 'app/site/users/models/view-user'; import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service'; +import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment'; +import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user'; /** @@ -171,12 +172,13 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn formBuilder: FormBuilder, public repo: AssignmentRepositoryService, private userRepo: UserRepositoryService, - public pollService: AssignmentPollService, private itemRepo: ItemRepositoryService, private tagRepo: TagRepositoryService, private promptService: PromptService, private pdfService: AssignmentPdfExportService, - private mediafileRepo: MediafileRepositoryService + private mediafileRepo: MediafileRepositoryService, + private pollDialog: AssignmentPollDialogService, + private assignmentPollService: AssignmentPollService ) { super(title, translate, matSnackBar); this.subscriptions.push( @@ -192,11 +194,12 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn attachments_id: [], title: ['', Validators.required], description: [''], - poll_description_default: [''], + default_poll_description: [''], open_posts: [1, [Validators.required, Validators.min(1)]], agenda_create: [''], agenda_parent_id: [], - agenda_type: [''] + agenda_type: [''], + number_poll_candidates: [false] }); this.candidatesForm = formBuilder.group({ userId: null @@ -303,10 +306,16 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn /** * Creates a new Poll - * TODO: directly open poll dialog? */ - public async createPoll(): Promise { - await this.repo.addPoll(this.assignment).catch(this.raiseError); + public openDialog(): void { + const dialogData = { + collectionString: ViewAssignmentPoll.COLLECTIONSTRING, + assignment_id: this.assignment.id, + assignment: this.assignment, + ...this.assignmentPollService.getDefaultPollData(this.assignment.id) + }; + + this.pollDialog.openDialog(dialogData); } /** @@ -370,6 +379,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn // resetting a form triggers a form.next(null) - check if data is present if (formResult && formResult.userId) { this.addUser(formResult.userId); + this.candidatesForm.setValue({ userId: null }); } }) ); @@ -455,24 +465,6 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn } } - /** - * Assemble a meaningful label for the poll - * Published polls will look like 'Ballot 2' - * other polls will be named 'Ballot 2' for normal users, with the hint - * '(unpulished)' appended for manager users - * - * @param poll - * @param index the index of the poll relative to the assignment - */ - public getPollLabel(poll: AssignmentPoll, index: number): string { - const title = `${this.translate.instant('Ballot')} ${index + 1}`; - if (!poll.published && this.hasPerms('manage')) { - return title + ` (${this.translate.instant('unpublished')})`; - } else { - return title; - } - } - /** * Triggers an update of the filter for the list of available candidates * (triggered on an autoupdate of either users or the assignment) diff --git a/client/src/app/site/assignments/components/assignment-list/assignment-list.component.html b/client/src/app/site/assignments/components/assignment-list/assignment-list.component.html index f1583959f..c9ba01d89 100644 --- a/client/src/app/site/assignments/components/assignment-list/assignment-list.component.html +++ b/client/src/app/site/assignments/components/assignment-list/assignment-list.component.html @@ -20,7 +20,7 @@ +
    +

    {{ poll.title }}

    +
    + + + + + + + + + + +
    +

    {{ poll.title }}

    +
    + + {{ poll.typeVerbose | translate }} · + + + + {{ poll.stateVerbose | translate }} + +
    + +
    + +
    + + + + + + + + + + + + + +
    Candidates + + Yes + + + Votes + + NoAbstain
    +
    + + {{ row.votingOption | pollKeyVerbose | translate }} + + +
    + {{ row.votingOptionSubtitle }} +
    +
    +
    +
    + + + {{ vote.amount | pollPercentBase: poll }} + + {{ vote.amount | parsePollNumber }} + +
    +
    +
    + + +
    + +
    + + +
    +

    {{ 'Single votes' | translate }}

    + + +
    + {{ col.label | translate }} +
    +
    + {{ col.label | translate }} +
    + + +
    +
    + {{ vote.user.getShortName() }} +
    + {{ vote.user.getLevelAndNumber() }} +
    +
    +
    + {{ 'Anonymous' | translate }} +
    +
    + +
    +
    {{ candidate }}
    +
    +
    +
    + {{ 'The individual votes were anonymized.' | translate }} +
    +
    +
    +
    + + +
    + + {{ 'Groups' | translate }}: + + + {{ group.getTitle() | translate }}, + + + + + {{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }} + +
    +
    + + + + + + + + + diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.scss b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.scss new file mode 100644 index 000000000..df8b0a1ed --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.scss @@ -0,0 +1,108 @@ +@import '~assets/styles/poll-colors.scss'; +@import '~assets/styles/poll-styles-common.scss'; + +.assignment-result-wrapper { + .assignment-result-table { + margin-top: 2em; + display: block; + overflow-x: auto; + border-collapse: collapse; + + th { + font-weight: initial; + } + + tr { + height: 48px; + + td:first-child { + padding-right: 1em; + } + } + + tr.sums { + border-bottom: none; + td { + padding-top: 1em; + padding-bottom: 1em; + } + } + + .result { + text-align: right; + padding-left: 1em; + } + + .voting-option { + min-width: 200px; + width: 100%; + text-align: left; + } + + .user + .sums { + td { + padding-top: 2em; + } + } + + .single-result { + white-space: pre; + } + } + + .chart-wrapper { + margin-top: 2em; + .pie-chart { + margin-left: auto; + margin-right: auto; + width: 50%; + } + } + + .named-result-table { + .mat-form-field { + font-size: 14px; + width: 100%; + } + + .single-votes-table { + display: block; + height: 500px; + + .single-vote-result + .single-vote-result { + margin-top: 1em; + } + } + + .vote-field { + text-align: center; + width: 100%; + padding-right: 12px; + } + } +} + +.assignment-poll-meta { + display: grid; + text-align: right; + padding-top: 20px; +} + +.openslides-theme .pbl-ngrid-no-data { + top: 10%; +} + +.openslides-theme .pbl-ngrid-header-cell:first-child { + & { + overflow: visible; + } + + &::after { + content: ''; + display: block; + position: absolute; + top: 0; + right: -1px; + height: 100%; + } +} diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts new file mode 100644 index 000000000..d47056211 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentPollDetailComponent } from './assignment-poll-detail.component'; + +describe('AssignmentPollDetailComponent', () => { + let component: AssignmentPollDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [AssignmentPollDetailComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AssignmentPollDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts new file mode 100644 index 000000000..57549902b --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts @@ -0,0 +1,153 @@ +import { Component, ViewEncapsulation } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; +import { PblColumnDefinition } from '@pebula/ngrid'; + +import { OperatorService } from 'app/core/core-services/operator.service'; +import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; +import { AssignmentVoteRepositoryService } from 'app/core/repositories/assignments/assignment-vote-repository.service'; +import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { ChartType } from 'app/shared/components/charts/charts.component'; +import { VoteValue } from 'app/shared/models/poll/base-vote'; +import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component'; +import { PollTableData, VotingResult } from 'app/site/polls/services/poll.service'; +import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; +import { AssignmentPollService } from '../../services/assignment-poll.service'; +import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; + +@Component({ + selector: 'os-assignment-poll-detail', + templateUrl: './assignment-poll-detail.component.html', + styleUrls: ['./assignment-poll-detail.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class AssignmentPollDetailComponent extends BasePollDetailComponent { + public columnDefinitionSingleVotes: PblColumnDefinition[]; + + public filterProps = ['user.getFullName']; + + public isReady = false; + + public candidatesLabels: string[] = []; + + public get chartType(): ChartType { + return 'stackedBar'; + } + + public constructor( + title: Title, + translate: TranslateService, + matSnackbar: MatSnackBar, + repo: AssignmentPollRepositoryService, + route: ActivatedRoute, + groupRepo: GroupRepositoryService, + prompt: PromptService, + pollDialog: AssignmentPollDialogService, + protected pollService: AssignmentPollService, + votesRepo: AssignmentVoteRepositoryService, + private operator: OperatorService, + private router: Router + ) { + super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog, pollService, votesRepo); + } + + protected createVotesData(): void { + const votes = {}; + const definitions: PblColumnDefinition[] = [ + { + prop: 'user', + label: 'Participant', + width: '40%', + minWidth: 300 + }, + { + prop: 'votes', + label: 'Votes', + width: '60%', + minWidth: 300 + } + ]; + + for (const option of this.poll.options) { + for (const vote of option.votes) { + const userId = vote.user_id; + if (!votes[userId]) { + votes[userId] = { + user: vote.user, + votes: [] + }; + } + + if (vote.weight > 0) { + if (this.poll.isMethodY) { + if (vote.value === 'Y') { + votes[userId].votes.push(option.user.getFullName()); + } else { + votes[userId].votes.push(this.voteValueToLabel(vote.value)); + } + } else { + votes[userId].votes.push(`${option.user.getShortName()}: ${this.voteValueToLabel(vote.value)}`); + } + } + } + } + for (const user of this.poll.voted) { + if (!votes[user.id]) { + votes[user.id] = { + user: user, + votes: [this.translate.instant('empty vote')] + }; + } + } + + this.setVotesData(Object.values(votes)); + this.candidatesLabels = this.pollService.getChartLabels(this.poll); + this.columnDefinitionSingleVotes = definitions; + this.isReady = true; + } + + private voteValueToLabel(vote: VoteValue): string { + if (vote === 'Y') { + return this.translate.instant('Yes'); + } else if (vote === 'N') { + return this.translate.instant('No'); + } else if (vote === 'A') { + return this.translate.instant('Abstain'); + } else { + throw new Error(`voteValueToLabel received illegal arguments: ${vote}`); + } + } + + protected hasPerms(): boolean { + return this.operator.hasPerms('assignments.can_manage'); + } + + public getVoteClass(votingResult: VotingResult): string { + return votingResult.vote; + } + + public voteFitsMethod(result: VotingResult): boolean { + if (this.poll.isMethodY) { + if (result.vote === 'abstain' || result.vote === 'no') { + return false; + } + } else if (this.poll.isMethodYN) { + if (result.vote === 'abstain') { + return false; + } + } + return true; + } + + public getTableData(): PollTableData[] { + return this.pollService.generateTableData(this.poll); + } + + protected onDeleted(): void { + this.router.navigate(['assignments', this.poll.assignment_id]); + } +} diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.html b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.html index 3339fea4b..b75d68271 100644 --- a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.html +++ b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.html @@ -1,42 +1,94 @@ -

    Voting result

    -
    - Special values:
    - -1 =  majority  - -2 =  - undocumented -
    -
    -
    - -
    -
    - {{ candidate.user.full_name }} + + + + +
    + +
    +
    +
    + {{ option.user.getFullName() }} + {{ 'Unknown user' | translate }} +
    + +
    +
    + +
    +
    +
    -
    - - - {{ key | translate }} - + + +
    +
    +
    + + +
    + + + +
    + + + +
    + + Publish immediately + + + Error in form field. +
    - -
    - - - {{ pollService.getLabel(sumValue) | translate }} - -
    -
    -
    - - + + + +
    + + + + +
    diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss index 0b4d4e0f7..4d89a4034 100644 --- a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss +++ b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss @@ -19,36 +19,12 @@ border-bottom: 1px solid grey; } -.votes-grid-1 { +.votes-grid { display: grid; grid-gap: 5px; margin-bottom: 10px; - grid-template-columns: auto 60px; - align-items: center; - .mat-form-field { - width: 100%; - } -} - -// TODO: more elegant way. Only grid-template-columns is different -.votes-grid-2 { - display: grid; - grid-gap: 5px; - margin-bottom: 10px; - align-items: center; - grid-template-columns: auto 60px 60px; - .mat-form-field { - width: 100%; - } -} - -// TODO: more elegant way. Only grid-template-columns is different -.votes-grid-3 { - display: grid; - grid-gap: 5px; - margin-bottom: 10px; - align-items: center; - grid-template-columns: auto 60px 60px 60px; + align-items: baseline; + grid-template-columns: auto max-content; .mat-form-field { width: 100%; } diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.spec.ts b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.spec.ts new file mode 100644 index 000000000..a13727a88 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.spec.ts @@ -0,0 +1,34 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentPollDialogComponent } from './assignment-poll-dialog.component'; + +describe('AssignmentPollDialogComponent', () => { + let component: AssignmentPollDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: {} + } + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AssignmentPollDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts index 1fe9d7ac5..bf030cde9 100644 --- a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts +++ b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts @@ -1,21 +1,25 @@ -import { Component, Inject } from '@angular/core'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; -import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; -import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service'; -import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; -import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option'; -import { AssignmentPollService, SummaryPollKey } from '../../services/assignment-poll.service'; +import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; +import { LOWEST_VOTE_VALUE, PollType } from 'app/shared/models/poll/base-poll'; +import { GeneralValueVerbose, VoteValue, VoteValueVerbose } from 'app/shared/models/poll/base-vote'; +import { + AssignmentPollMethodVerbose, + AssignmentPollPercentBaseVerbose +} from 'app/site/assignments/models/view-assignment-poll'; +import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component'; +import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component'; +import { ViewUser } from 'app/site/users/models/view-user'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; -import { ViewAssignmentPollOption } from '../../models/view-assignment-poll-option'; -/** - * Vote entries included once for summary (e.g. total votes cast) - */ -type summaryPollKey = 'votescast' | 'votesvalid' | 'votesinvalid' | 'votesno' | 'votesabstain'; +type OptionsObject = { user_id: number; user: ViewUser }[]; /** * A dialog for updating the values of an assignment-related poll. @@ -25,22 +29,12 @@ type summaryPollKey = 'votescast' | 'votesvalid' | 'votesinvalid' | 'votesno' | templateUrl: './assignment-poll-dialog.component.html', styleUrls: ['./assignment-poll-dialog.component.scss'] }) -export class AssignmentPollDialogComponent { - /** - * The actual poll data to work on - */ - public poll: AssignmentPoll; - +export class AssignmentPollDialogComponent extends BasePollDialogComponent implements OnInit { /** * The summary values that will have fields in the dialog */ - public get sumValues(): summaryPollKey[] { - const generalValues: summaryPollKey[] = ['votesvalid', 'votesinvalid', 'votescast']; - if (this.data.pollmethod === 'votes') { - return ['votesno', 'votesabstain', ...generalValues]; - } else { - return generalValues; - } + public get sumValues(): string[] { + return ['votesvalid', 'votesinvalid', 'votescast']; } /** @@ -49,147 +43,145 @@ export class AssignmentPollDialogComponent { */ public specialValues: [number, string][]; + @ViewChild('pollForm', { static: true }) + protected pollForm: PollFormComponent; + /** * vote entries for each option in this component. Is empty if method * requires one vote per candidate */ - public optionPollKeys: PollVoteValue[]; + public analogPollValues: VoteValue[]; + + public voteValueVerbose = VoteValueVerbose; + public generalValueVerbose = GeneralValueVerbose; + + public AssignmentPollMethodVerbose = AssignmentPollMethodVerbose; + public AssignmentPollPercentBaseVerbose = AssignmentPollPercentBaseVerbose; + + public options: OptionsObject; + + public globalNoEnabled: boolean; + public globalAbstainEnabled: boolean; + + public get isAnalogPoll(): boolean { + return ( + this.pollForm && + this.pollForm.contentForm && + this.pollForm.contentForm.get('type').value === PollType.Analog + ); + } /** * Constructor. Retrieves necessary metadata from the pollService, * injects the poll itself */ public constructor( - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: ViewAssignmentPoll, - private matSnackBar: MatSnackBar, - private translate: TranslateService, - public pollService: AssignmentPollService, - private userRepo: UserRepositoryService + private fb: FormBuilder, + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + public dialogRef: MatDialogRef>, + @Inject(MAT_DIALOG_DATA) public pollData: Partial ) { - this.specialValues = this.pollService.specialPollVotes; - this.poll = this.data.poll; + super(title, translate, matSnackbar, dialogRef); + } - switch (this.poll.pollmethod) { - case 'votes': - this.optionPollKeys = ['Votes']; - break; - case 'yn': - this.optionPollKeys = ['Yes', 'No']; - break; - case 'yna': - this.optionPollKeys = ['Yes', 'No', 'Abstain']; - break; + public ngOnInit(): void { + // TODO: not solid. + // on new poll creation, poll.options does not exist, so we have to build a substitute from the assignment candidates + if (this.pollData) { + if (this.pollData.options) { + this.options = this.pollData.options; + } else if ( + this.pollData.assignment && + this.pollData.assignment.candidates && + this.pollData.assignment.candidates.length + ) { + this.options = this.pollData.assignment.candidates.map( + user => ({ + user_id: user.id, + user: user + }), + {} + ); + } + } + + this.subscriptions.push( + this.pollForm.contentForm.valueChanges.pipe(debounceTime(150), distinctUntilChanged()).subscribe(() => { + this.createDialog(); + }) + ); + } + + private setAnalogPollValues(): void { + const pollmethod = this.pollForm.contentForm.get('pollmethod').value; + this.globalNoEnabled = this.pollForm.contentForm.get('global_no').value; + this.globalAbstainEnabled = this.pollForm.contentForm.get('global_abstain').value; + const analogPollValues: VoteValue[] = ['Y']; + if (pollmethod !== AssignmentPollMethod.Votes) { + analogPollValues.push('N'); + } + if (pollmethod === AssignmentPollMethod.YNA) { + analogPollValues.push('A'); + } + this.analogPollValues = analogPollValues; + } + + private updateDialogVoteForm(data: Partial): void { + const update = { + options: {}, + votesvalid: data.votesvalid, + votesinvalid: data.votesinvalid, + votescast: data.votescast, + amount_global_no: data.amount_global_no, + amount_global_abstain: data.amount_global_abstain + }; + for (const option of data.options) { + const votes: any = {}; + votes.Y = option.yes; + if (data.pollmethod !== AssignmentPollMethod.Votes) { + votes.N = option.no; + } + if (data.pollmethod === AssignmentPollMethod.YNA) { + votes.A = option.abstain; + } + update.options[option.user_id] = votes; + } + + if (this.dialogVoteForm) { + const result = this.undoReplaceEmptyValues(update); + this.dialogVoteForm.patchValue(result); } } /** - * Close the dialog, submitting nothing. Triggered by the cancel button and - * default angular cancelling behavior + * Pre-executed method to initialize the dialog-form depending on the poll-method. */ - public cancel(): void { - this.dialogRef.close(); - } + private createDialog(): void { + this.setAnalogPollValues(); - /** - * Validates candidates input (every candidate has their options filled in), - * submits and closes the dialog if successful, else displays an error popup. - * TODO better validation - */ - public submit(): void { - const error = this.data.options.find(dataoption => { - this.optionPollKeys.some(key => { - const keyValue = dataoption.votes.find(o => o.value === key); - return !keyValue || keyValue.weight === undefined; - }); + this.dialogVoteForm = this.fb.group({ + options: this.fb.group( + // create a form group for each option with the user id as key + this.options.mapToObject(option => ({ + [option.user_id]: this.fb.group( + // for each user, create a form group with a control for each valid input (Y, N, A) + this.analogPollValues.mapToObject(value => ({ + [value]: ['', [Validators.min(LOWEST_VOTE_VALUE)]] + })) + ) + })) + ), + amount_global_no: ['', [Validators.min(LOWEST_VOTE_VALUE)]], + amount_global_abstain: ['', [Validators.min(LOWEST_VOTE_VALUE)]], + // insert all used global fields + ...this.sumValues.mapToObject(sumValue => ({ + [sumValue]: ['', [Validators.min(LOWEST_VOTE_VALUE)]] + })) }); - if (error) { - this.matSnackBar.open( - this.translate.instant('Please fill in the values for each candidate'), - this.translate.instant('OK'), - { - duration: 1000 - } - ); - } else { - this.dialogRef.close(this.poll); + if (this.isAnalogPoll && this.pollData.poll) { + this.updateDialogVoteForm(this.pollData); } } - - /** - * TODO: currently unused - * - * @param key poll option to be labeled - * @returns a label for a poll option - */ - public getLabel(key: CalculablePollKey): string { - return this.pollService.getLabel(key); - } - - /** - * Updates a vote value - * - * @param value the value to update - * @param candidate the candidate for whom to update the value - * @param newData the new value - */ - public setValue(value: PollVoteValue, candidate: ViewAssignmentPollOption, newData: string): void { - const vote = candidate.votes.find(v => v.value === value); - if (vote) { - vote.weight = parseFloat(newData); - } else { - candidate.votes.push({ - value: value, - weight: parseFloat(newData) - }); - } - } - - /** - * Retrieves the current value for a voting option - * - * @param value the vote value (e.g. 'Abstain') - * @param candidate the pollOption - * @returns the currently entered number or undefined if no number has been set - */ - public getValue(value: PollVoteValue, candidate: AssignmentPollOption): number | undefined { - const val = candidate.votes.find(v => v.value === value); - return val ? val.weight : undefined; - } - - /** - * Retrieves a per-poll value - * - * @param value - * @returns integer or undefined - */ - public getSumValue(value: SummaryPollKey): number | undefined { - return this.data[value] || undefined; - } - - /** - * Sets a per-poll value - * - * @param value - * @param weight - */ - public setSumValue(value: SummaryPollKey, weight: string): void { - this.poll[value] = parseFloat(weight); - } - - public getGridClass(): string { - return `votes-grid-${this.optionPollKeys.length}`; - } - - /** - * Fetches the name for a poll option - * TODO: observable. Note that the assignment.related_user may not contain the user (anymore?) - * - * @param option Any poll option - * @returns the full_name for the candidate - */ - public getCandidateName(option: AssignmentPollOption): string { - const user = this.userRepo.getViewModel(option.candidate_id); - return user ? user.full_name : ''; - } } diff --git a/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.html b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.html new file mode 100644 index 000000000..7534b3d4c --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.html @@ -0,0 +1,124 @@ + + + +

    + {{ pollHint }} +

    + + +

    + {{ 'Available votes' | translate }}: {{ getVotesCount() }}/{{ poll.votes_amount }} +

    + + +
    +
    +
    +
    + + {{ option.user.short_name }} +
    + {{ option.user.getLevelAndNumber() }} +
    +
    + {{ 'Unknown user' | translate }} +
    + +
    + + + {{ action.label | translate }} + +
    +
    + +
    +
    + + + + +
    +
    + + + {{ 'General No' | translate }} + +
    + +
    + + + {{ 'General Abstain' | translate }} + +
    +
    +
    + + + +
    + + + + {{ vmanager.getVotePermissionErrorVerbose(poll) | translate }} + +
    + + +
    +
    + +
    + {{ 'Voting successful.' | translate }} +
    +
    +
    + + +
    + +
    +
    diff --git a/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.scss b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.scss new file mode 100644 index 000000000..18a1aa6c8 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.scss @@ -0,0 +1,69 @@ +@import '~assets/styles/poll-colors.scss'; +@import '~assets/styles/poll-styles-common.scss'; + +%vote-grid-base { + display: grid; + grid-gap: 10px; + margin: 20px 0; +} + +.yn-grid { + @extend %vote-grid-base; + grid-template-areas: + 'name name' + 'yes no'; +} + +.yna-grid { + @extend %vote-grid-base; + grid-template-areas: + 'name name name' + 'yes no abstain'; +} + +.single-vote-grid { + @extend %vote-grid-base; + grid-template-areas: 'yes name'; + grid-template-columns: min-content auto; +} + +.global-option-grid { + @extend %vote-grid-base; + grid-template-columns: auto auto; +} + +.vote-candidate-name { + grid-area: name; + display: flex; + span { + margin-top: auto; + margin-bottom: auto; + } +} + +.centered-button-wrapper { + display: flex; + text-align: center; + > * { + margin-left: auto; + margin-right: auto; + } + + .vote-submitted { + color: $votes-yes-color; + font-size: 200%; + } +} + +.vote-button { + min-width: 50px; + min-height: 50px; +} + +.vote-label { + margin-left: 10px; +} + +.mat-divider-horizontal { + position: initial; +} diff --git a/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts new file mode 100644 index 000000000..a1b3d7d7f --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentPollVoteComponent } from './assignment-poll-vote.component'; + +describe('AssignmentPollVoteComponent', () => { + let component: AssignmentPollVoteComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [AssignmentPollVoteComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AssignmentPollVoteComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.ts b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.ts new file mode 100644 index 000000000..4ba7cbed5 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.ts @@ -0,0 +1,178 @@ +import { Component, OnInit } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { OperatorService } from 'app/core/core-services/operator.service'; +import { + AssignmentPollRepositoryService, + GlobalVote, + VotingData +} from 'app/core/repositories/assignments/assignment-poll-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { VotingService } from 'app/core/ui-services/voting.service'; +import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; +import { PollType } from 'app/shared/models/poll/base-poll'; +import { VoteValue } from 'app/shared/models/poll/base-vote'; +import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component'; +import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; + +// TODO: Duplicate +interface VoteActions { + vote: VoteValue; + css: string; + icon: string; + label: string; +} + +@Component({ + selector: 'os-assignment-poll-vote', + templateUrl: './assignment-poll-vote.component.html', + styleUrls: ['./assignment-poll-vote.component.scss'] +}) +export class AssignmentPollVoteComponent extends BasePollVoteComponent implements OnInit { + public AssignmentPollMethod = AssignmentPollMethod; + public PollType = PollType; + public voteActions: VoteActions[] = []; + public voteRequestData: VotingData = { + votes: {} + }; + public alreadyVoted: boolean; + + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + operator: OperatorService, + public vmanager: VotingService, + private pollRepo: AssignmentPollRepositoryService, + private promptService: PromptService + ) { + super(title, translate, matSnackbar, operator); + } + + public ngOnInit(): void { + if (this.poll && !this.poll.user_has_voted) { + this.alreadyVoted = false; + this.defineVoteOptions(); + } else { + this.alreadyVoted = true; + } + } + + public get pollHint(): string { + return this.poll.assignment.default_poll_description; + } + + private defineVoteOptions(): void { + this.voteActions.push({ + vote: 'Y', + css: 'voted-yes', + icon: 'thumb_up', + label: 'Yes' + }); + + if (this.poll.pollmethod !== AssignmentPollMethod.Votes) { + this.voteActions.push({ + vote: 'N', + css: 'voted-no', + icon: 'thumb_down', + label: 'No' + }); + } + + if (this.poll.pollmethod === AssignmentPollMethod.YNA) { + this.voteActions.push({ + vote: 'A', + css: 'voted-abstain', + icon: 'trip_origin', + label: 'Abstain' + }); + } + } + + public getVotesCount(): number { + return Object.keys(this.voteRequestData.votes).filter(key => this.voteRequestData.votes[key]).length; + } + + private isGlobalOptionSelected(): boolean { + return !!this.voteRequestData.global; + } + + public submitVote(): void { + const title = this.translate.instant('Submit selection now?'); + const content = this.translate.instant('Your decision cannot be changed afterwards.'); + this.promptService.open(title, content).then(confirmed => { + if (confirmed) { + this.pollRepo + .vote(this.voteRequestData, this.poll.id) + .then(() => { + this.alreadyVoted = true; + }) + .catch(this.raiseError); + } + }); + } + + public saveSingleVote(optionId: number, vote: VoteValue): void { + if (this.isGlobalOptionSelected()) { + delete this.voteRequestData.global; + } + + if (this.poll.pollmethod === AssignmentPollMethod.Votes) { + const votesAmount = this.poll.votes_amount; + const tmpVoteRequest = this.poll.options + .map(option => option.id) + .reduce((o, n) => { + o[n] = 0; + if (votesAmount === 1) { + if (n === optionId && this.voteRequestData.votes[n] !== 1) { + o[n] = 1; + } + } else if ((n === optionId) !== (this.voteRequestData.votes[n] === 1)) { + o[n] = 1; + } + + return o; + }, {}); + + // check if you can still vote + const countedVotes = Object.keys(tmpVoteRequest).filter(key => tmpVoteRequest[key]).length; + if (countedVotes <= votesAmount) { + this.voteRequestData.votes = tmpVoteRequest; + + // if you have no options anymore, try to send + if (this.getVotesCount() === votesAmount) { + this.submitVote(); + } + } else { + this.raiseError( + this.translate.instant('You reached the maximum amount of votes. Deselect somebody first.') + ); + } + } else { + // YN/YNA + if (this.voteRequestData.votes[optionId] && this.voteRequestData.votes[optionId] === vote) { + delete this.voteRequestData.votes[optionId]; + } else { + this.voteRequestData.votes[optionId] = vote; + } + + // if you filled out every option, try to send + if (Object.keys(this.voteRequestData.votes).length === this.poll.options.length) { + this.submitVote(); + } + } + } + + public saveGlobalVote(globalVote: GlobalVote): void { + this.voteRequestData.votes = {}; + if (this.voteRequestData.global && this.voteRequestData.global === globalVote) { + delete this.voteRequestData.global; + } else { + this.voteRequestData.global = globalVote; + this.submitVote(); + } + } +} diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html index 92e2766c0..7d2af0cc0 100644 --- a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html @@ -1,194 +1,130 @@ -
    -
    - - - -
    - - - -
    -
    - -
    -
    - - -
    -
    -
    + +
    +
    + + + + {{ poll.title }} + + -
    -
    -
    -
    -
    Candidates
    -
    Votes
    -
    -
    - Quorum -
    -
    - - - {{ majorityChoice.display_name | translate }} - - - - - -
    -
    + +
    + + + + {{ poll.typeVerbose | translate }} · + + {{ poll.stateVerbose | translate }} +
    -
    -
    -
    - -
    -
    - -
    - {{ option.user.full_name }} -
    - -
    -
    -
    -
    - {{ pollService.getLabel(vote.value) | translate }}: - {{ pollService.getSpecialLabel(vote.weight) | translate }} - ({{ pollService.getPercent(poll, option, vote.value) }}%) -
    -
    - - -
    -
    -
    -
    -
    -
    - {{ pollService.yesQuorum(majorityChoice, poll, option) }} - - {{ pollService.getIcon('yes') }} - {{ pollService.getIcon('no') }} - -
    -
    + + +
    + +
    - -
    -
    -
    -
    - {{ pollService.getLabel(key) | translate }}: -
    -
    - {{ pollService.getSpecialLabel(poll[key]) | translate }} - - ({{ pollService.getValuePercent(poll, key) }} %) - -
    -
    + + +
    + +
    + + +
    + {{ 'Edit to enter votes.' | translate }}
    -
    -

    Candidates

    -
    - {{ option.user.getFullName() }} - No user {{ option.candidate_id }} + + +
    +
    + +
    + + +
    + + {{ 'Counting of votes is in progress ...' | translate }} +
    -
    - -
    -

    Election method

    - {{ pollMethodName | translate }} -
    + +
    + +
    - -
    - - Hint for ballot paper - - -
    + + + +
    +
    -
    +
    + +
    +
    + + + + + +
    + diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss index 7e088701d..d41715abc 100644 --- a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss @@ -1,21 +1,9 @@ +@import '~assets/styles/poll-colors.scss'; +@import '~assets/styles/poll-styles-common.scss'; + .assignment-poll-wrapper { - @import '~assets/styles/poll-common-styles.scss'; position: relative; - padding: 0 15px; - - .poll-main-content { - padding-top: 10px; - } - - .poll-grid { - display: grid; - grid-gap: 5px; - padding: 5px; - grid-template-columns: 30px auto 250px 150px; - .candidate-name { - word-wrap: break-word; - } - } + margin: 0 15px; .poll-menu { position: absolute; @@ -23,26 +11,11 @@ right: 0; } - .poll-quorum { - text-align: right; - margin-right: 10px; - mat-icon { - vertical-align: middle; - font-size: 100%; + .poll-detail-button-wrapper { + display: flex; + margin: auto 0; + > a { + margin-left: auto; } } - - .top-aligned { - position: absolute; - top: 0; - left: 0; - } - - .wide { - width: 90%; - } - - .hint-form { - margin-top: 20px; - } } diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.spec.ts b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.spec.ts index 177e7506e..baccf34c2 100644 --- a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.spec.ts +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.spec.ts @@ -2,6 +2,8 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { E2EImportsModule } from 'e2e-imports.module'; +import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component'; +import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component'; import { AssignmentPollComponent } from './assignment-poll.component'; describe('AssignmentPollComponent', () => { @@ -10,8 +12,8 @@ describe('AssignmentPollComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [AssignmentPollComponent], - imports: [E2EImportsModule] + imports: [E2EImportsModule], + declarations: [AssignmentPollComponent, AssignmentPollVoteComponent, PollProgressComponent] }).compileComponents(); })); diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts index 0d6dee5c0..67ececee1 100644 --- a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -6,18 +6,16 @@ import { Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; -import { OperatorService } from 'app/core/core-services/operator.service'; -import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; -import { CalculablePollKey, MajorityMethod } from 'app/core/ui-services/poll.service'; +import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; -import { mediumDialogSettings } from 'app/shared/utils/dialog-settings'; -import { BaseViewComponent } from 'app/site/base/base-view'; -import { AssignmentPollDialogComponent } from '../assignment-poll-dialog/assignment-poll-dialog.component'; +import { ChartType } from 'app/shared/components/charts/charts.component'; +import { VotingPrivacyWarningComponent } from 'app/shared/components/voting-privacy-warning/voting-privacy-warning.component'; +import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; +import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; +import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service'; import { AssignmentPollService } from '../../services/assignment-poll.service'; -import { ViewAssignment } from '../../models/view-assignment'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; -import { ViewAssignmentPollOption } from '../../models/view-assignment-poll-option'; /** * Component for a single assignment poll. Used in assignment detail view @@ -25,53 +23,32 @@ import { ViewAssignmentPollOption } from '../../models/view-assignment-poll-opti @Component({ selector: 'os-assignment-poll', templateUrl: './assignment-poll.component.html', - styleUrls: ['./assignment-poll.component.scss'], - encapsulation: ViewEncapsulation.None + styleUrls: ['./assignment-poll.component.scss'] }) -export class AssignmentPollComponent extends BaseViewComponent implements OnInit { - /** - * The related assignment (used for metainfos, e.g. related user names) - */ +export class AssignmentPollComponent extends BasePollComponent implements OnInit { @Input() - public assignment: ViewAssignment; + public set poll(value: ViewAssignmentPoll) { + this.initPoll(value); + this.candidatesLabels = this.pollService.getChartLabels(value); + const chartData = this.pollService.generateChartData(value); + this.chartDataSubject.next(chartData); + } - /** - * The poll represented in this component - */ - @Input() - public poll: ViewAssignmentPoll; + public get poll(): ViewAssignmentPoll { + return this._poll; + } + + public get chartType(): ChartType { + return 'stackedBar'; + } + + public candidatesLabels: string[] = []; /** * Form for updating the poll's description */ public descriptionForm: FormGroup; - /** - * The selected Majority method to display quorum calculations. Will be - * set/changed by the user - */ - public majorityChoice: MajorityMethod | null; - - /** - * permission checks. - * TODO stub - * - * @returns true if the user is permitted to do operations - */ - public get canManage(): boolean { - return this.operator.hasPerms('assignments.can_manage'); - } - - /** - * Gets the voting options - * - * @returns all used (not undefined) option-independent values that are - * used in this poll (e.g.) - */ - public get pollValues(): CalculablePollKey[] { - return this.pollService.getVoteOptionsByPoll(this.poll); - } - /** * @returns true if the description on the form differs from the poll's description */ @@ -79,193 +56,35 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit return this.descriptionForm.get('description').value !== this.poll.description; } - /** - * @returns true if vote results can be seen by the user - */ - public get pollData(): boolean { - if (!this.poll.has_votes) { - return false; - } - return this.poll.published || this.canManage; - } - - /** - * Gets the translated poll method name - * - * TODO: check/improve text here - * - * @returns a name for the poll method this poll is set to (which is determined - * by the number of candidates and config settings). - */ - public get pollMethodName(): string { - if (!this.poll) { - return ''; - } - switch (this.poll.pollmethod) { - case 'votes': - return this.translate.instant('One vote per candidate'); - case 'yna': - return this.translate.instant('Yes/No/Abstain per candidate'); - case 'yn': - return this.translate.instant('Yes/No per candidate'); - default: - return ''; - } - } - - /** - * constructor. Does nothing - * - * @param titleService - * @param matSnackBar - * @param pollService poll related calculations - * @param operator permission checks - * @param assignmentRepo The repository to the assignments - * @param translate Translation service - * @param dialog MatDialog for the vote entering dialog - * @param promptService Prompts for confirmation dialogs - * @param pdfService pdf service - */ public constructor( titleService: Title, matSnackBar: MatSnackBar, - public pollService: AssignmentPollService, - private operator: OperatorService, - private assignmentRepo: AssignmentRepositoryService, - public translate: TranslateService, - public dialog: MatDialog, - private promptService: PromptService, + translate: TranslateService, + dialog: MatDialog, + promptService: PromptService, + repo: AssignmentPollRepositoryService, + pollDialog: AssignmentPollDialogService, + private pollService: AssignmentPollService, private formBuilder: FormBuilder, private pdfService: AssignmentPollPdfService ) { - super(titleService, translate, matSnackBar); + super(titleService, matSnackBar, translate, dialog, promptService, repo, pollDialog); } - /** - * Gets the currently selected majority choice option from the repo - */ public ngOnInit(): void { - this.majorityChoice = - this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) || - null; this.descriptionForm = this.formBuilder.group({ description: this.poll ? this.poll.description : '' }); } - /** - * Handler for the 'delete poll' button - * - * TODO: Some confirmation (advanced logic (e.g. not deleting published?)) - */ - public async onDeletePoll(): Promise { - const title = this.translate.instant('Are you sure you want to delete this ballot?'); - if (await this.promptService.open(title)) { - await this.assignmentRepo.deletePoll(this.poll).catch(this.raiseError); - } - } - /** * Print the PDF of this poll with the corresponding options and numbers - * */ public printBallot(): void { this.pdfService.printBallots(this.poll); } - /** - * Determines whether the candidate has reached the majority needed to pass - * the quorum - * - * @param option - * @returns true if the quorum is successfully met - */ - public quorumReached(option: ViewAssignmentPollOption): boolean { - const yesValue = this.poll.pollmethod === 'votes' ? 'Votes' : 'Yes'; - const amount = option.votes.find(v => v.value === yesValue).weight; - const yesQuorum = this.pollService.yesQuorum( - this.majorityChoice, - this.pollService.calculationDataFromPoll(this.poll), - option - ); - return yesQuorum && amount >= yesQuorum; - } - - /** - * Opens the {@link AssignmentPollDialogComponent} dialog and then updates the votes, if the dialog - * closes successfully (validation is done there) - */ - public enterVotes(): void { - const dialogRef = this.dialog.open(AssignmentPollDialogComponent, { - data: this.poll, - ...mediumDialogSettings - }); - dialogRef.afterClosed().subscribe(result => { - if (result) { - this.assignmentRepo.updateVotes(result, this.poll).catch(this.raiseError); - } - }); - } - - /** - * Updates the majority method for this poll - * - * @param method the selected majority method - */ - public setMajority(method: MajorityMethod): void { - this.majorityChoice = method; - } - - /** - * Toggles the 'published' state - */ - public togglePublished(): void { - this.assignmentRepo.updatePoll({ published: !this.poll.published }, this.poll); - } - - /** - * Mark/unmark an option as elected - * - * @param option - */ - public toggleElected(option: ViewAssignmentPollOption): void { - if (!this.operator.hasPerms('assignments.can_manage')) { - return; - } - - // TODO additional conditions: assignment not finished? - const viewAssignmentRelatedUser = this.assignment.assignment_related_users.find( - user => user.user_id === option.candidate_id - ); - if (viewAssignmentRelatedUser) { - this.assignmentRepo.markElected(viewAssignmentRelatedUser, this.assignment, !option.is_elected); - } - } - - /** - * Sends the edited poll description to the server - * TODO: Better feedback - */ - public async onEditDescriptionButton(): Promise { - const desc: string = this.descriptionForm.get('description').value; - await this.assignmentRepo.updatePoll({ description: desc }, this.poll).catch(this.raiseError); - } - - /** - * Fetches a tooltip string about the quorum - * @param option - * @returns a translated - */ - public getQuorumReachedString(option: ViewAssignmentPollOption): string { - const name = this.translate.instant(this.majorityChoice.display_name); - const quorum = this.pollService.yesQuorum( - this.majorityChoice, - this.pollService.calculationDataFromPoll(this.poll), - option - ); - const isReached = this.quorumReached(option) - ? this.translate.instant('reached') - : this.translate.instant('not reached'); - return `${name} (${quorum}) ${isReached}`; + public openVotingWarning(): void { + this.dialog.open(VotingPrivacyWarningComponent, infoDialogSettings); } } diff --git a/client/src/app/site/assignments/models/view-assignment-option.ts b/client/src/app/site/assignments/models/view-assignment-option.ts new file mode 100644 index 000000000..443cb6613 --- /dev/null +++ b/client/src/app/site/assignments/models/view-assignment-option.ts @@ -0,0 +1,12 @@ +import { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; +import { ViewBaseOption } from 'app/site/polls/models/view-base-option'; +import { ViewUser } from 'app/site/users/models/view-user'; + +export class ViewAssignmentOption extends ViewBaseOption { + public static COLLECTIONSTRING = AssignmentOption.COLLECTIONSTRING; + protected _collectionString = AssignmentOption.COLLECTIONSTRING; +} + +export interface ViewAssignmentOption extends AssignmentOption { + user: ViewUser; +} diff --git a/client/src/app/site/assignments/models/view-assignment-poll-option.ts b/client/src/app/site/assignments/models/view-assignment-poll-option.ts deleted file mode 100644 index 76a090974..000000000 --- a/client/src/app/site/assignments/models/view-assignment-poll-option.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { PollVoteValue } from 'app/core/ui-services/poll.service'; -import { AssignmentOptionVote, AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option'; -import { BaseViewModel } from 'app/site/base/base-view-model'; -import { ViewUser } from 'app/site/users/models/view-user'; - -/** - * Defines the order the option's votes are sorted in (server might send raw data in any order) - */ -const votesOrder: PollVoteValue[] = ['Votes', 'Yes', 'No', 'Abstain']; - -export class ViewAssignmentPollOption extends BaseViewModel { - public static COLLECTIONSTRING = AssignmentPollOption.COLLECTIONSTRING; - protected _collectionString = AssignmentPollOption.COLLECTIONSTRING; - - public get option(): AssignmentPollOption { - return this._model; - } - - public get votes(): AssignmentOptionVote[] { - return this.option.votes.sort((a, b) => votesOrder.indexOf(a.value) - votesOrder.indexOf(b.value)); - } -} -export interface ViewAssignmentPollOption extends AssignmentPollOption { - user: ViewUser; -} diff --git a/client/src/app/site/assignments/models/view-assignment-poll.ts b/client/src/app/site/assignments/models/view-assignment-poll.ts index ca89e297b..6a4110aca 100644 --- a/client/src/app/site/assignments/models/view-assignment-poll.ts +++ b/client/src/app/site/assignments/models/view-assignment-poll.ts @@ -1,39 +1,76 @@ -import { AssignmentPoll, AssignmentPollWithoutNestedModels } from 'app/shared/models/assignments/assignment-poll'; -import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; -import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; -import { ViewAssignmentPollOption } from './view-assignment-poll-option'; +import { BehaviorSubject } from 'rxjs'; -export class ViewAssignmentPoll extends BaseProjectableViewModel { +import { _ } from 'app/core/translate/translation-marker'; +import { ChartData } from 'app/shared/components/charts/charts.component'; +import { + AssignmentPoll, + AssignmentPollMethod, + AssignmentPollPercentBase +} from 'app/shared/models/assignments/assignment-poll'; +import { BaseViewModel } from 'app/site/base/base-view-model'; +import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; +import { PollClassType, ViewBasePoll } from 'app/site/polls/models/view-base-poll'; +import { ViewAssignment } from './view-assignment'; +import { ViewAssignmentOption } from './view-assignment-option'; + +export interface AssignmentPollTitleInformation { + title: string; +} + +export const AssignmentPollMethodVerbose = { + votes: _('Yes per candidate'), + YN: _('Yes/No per candidate'), + YNA: _('Yes/No/Abstain per candidate') +}; + +export const AssignmentPollPercentBaseVerbose = { + YN: _('Yes/No per candidate'), + YNA: _('Yes/No/Abstain per candidate'), + votes: _('Sum of votes including general No/Abstain'), + valid: _('All valid ballots'), + cast: _('All casted ballots'), + disabled: _('Disabled (no percents)') +}; + +export class ViewAssignmentPoll extends ViewBasePoll + implements AssignmentPollTitleInformation { public static COLLECTIONSTRING = AssignmentPoll.COLLECTIONSTRING; protected _collectionString = AssignmentPoll.COLLECTIONSTRING; - public get poll(): AssignmentPoll { - return this._model; + public readonly tableChartData: Map> = new Map(); + public readonly pollClassType = PollClassType.Assignment; + + public get pollmethodVerbose(): string { + return AssignmentPollMethodVerbose[this.pollmethod]; } - public getListTitle = () => { - return this.getTitle(); - }; + public get percentBaseVerbose(): string { + return AssignmentPollPercentBaseVerbose[this.onehundred_percent_base]; + } - public getProjectorTitle = () => { - return this.getTitle(); - }; + public getContentObject(): BaseViewModel { + return this.assignment; + } public getSlide(): ProjectorElementBuildDeskriptor { return { getBasicProjectorElement: options => ({ - name: 'assignments/poll', - assignment_id: this.assignment_id, - poll_id: this.id, - getIdentifiers: () => ['name', 'assignment_id', 'poll_id'] + name: AssignmentPoll.COLLECTIONSTRING, + id: this.id, + getIdentifiers: () => ['name', 'id'] }), slideOptions: [], - projectionDefaultName: 'assignments', - getDialogTitle: () => 'TODO' + projectionDefaultName: 'assignment_poll', + getDialogTitle: this.getTitle }; } + + protected getDecimalFields(): string[] { + return AssignmentPoll.DECIMAL_FIELDS; + } } -export interface ViewAssignmentPoll extends AssignmentPollWithoutNestedModels { - options: ViewAssignmentPollOption[]; +export interface ViewAssignmentPoll extends AssignmentPoll { + options: ViewAssignmentOption[]; + assignment: ViewAssignment; } diff --git a/client/src/app/site/assignments/models/view-assignment-vote.ts b/client/src/app/site/assignments/models/view-assignment-vote.ts new file mode 100644 index 000000000..5bd40ae17 --- /dev/null +++ b/client/src/app/site/assignments/models/view-assignment-vote.ts @@ -0,0 +1,9 @@ +import { AssignmentVote } from 'app/shared/models/assignments/assignment-vote'; +import { ViewBaseVote } from 'app/site/polls/models/view-base-vote'; + +export class ViewAssignmentVote extends ViewBaseVote { + public static COLLECTIONSTRING = AssignmentVote.COLLECTIONSTRING; + protected _collectionString = AssignmentVote.COLLECTIONSTRING; +} + +export interface ViewAssignmentVote extends AssignmentVote {} diff --git a/client/src/app/site/assignments/models/view-assignment.ts b/client/src/app/site/assignments/models/view-assignment.ts index 9214b1cc5..b036262b1 100644 --- a/client/src/app/site/assignments/models/view-assignment.ts +++ b/client/src/app/site/assignments/models/view-assignment.ts @@ -4,6 +4,7 @@ import { TitleInformationWithAgendaItem } from 'app/site/base/base-view-model-wi import { BaseViewModelWithAgendaItemAndListOfSpeakers } from 'app/site/base/base-view-model-with-agenda-item-and-list-of-speakers'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; +import { HasViewPolls } from 'app/site/polls/models/has-view-polls'; import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewUser } from 'app/site/users/models/view-user'; import { ViewAssignmentPoll } from './view-assignment-poll'; @@ -27,7 +28,7 @@ export const AssignmentPhases: { name: string; value: number; display_name: stri { name: 'PHASE_VOTING', value: 1, - display_name: 'Voting' + display_name: 'In the election process' }, { name: 'PHASE_FINISHED', @@ -45,9 +46,6 @@ export class ViewAssignment extends BaseViewModelWithAgendaItemAndListOfSpeakers return this._model; } - /** - * TODO: Fix assignment creation: DO NOT create a ViewUser there... - */ public get candidates(): ViewUser[] { if (!this.assignment_related_users) { return []; @@ -105,9 +103,8 @@ export class ViewAssignment extends BaseViewModelWithAgendaItemAndListOfSpeakers }; } } -interface IAssignmentRelations { +interface IAssignmentRelations extends HasViewPolls { assignment_related_users: ViewAssignmentRelatedUser[]; - polls?: ViewAssignmentPoll[]; tags?: ViewTag[]; attachments?: ViewMediafile[]; } diff --git a/client/src/app/site/assignments/services/assignment-filter.service.ts b/client/src/app/site/assignments/services/assignment-filter-list.service.ts similarity index 100% rename from client/src/app/site/assignments/services/assignment-filter.service.ts rename to client/src/app/site/assignments/services/assignment-filter-list.service.ts diff --git a/client/src/app/site/assignments/services/assignment-pdf.service.ts b/client/src/app/site/assignments/services/assignment-pdf.service.ts index 473f0c7d2..8fff65abf 100644 --- a/client/src/app/site/assignments/services/assignment-pdf.service.ts +++ b/client/src/app/site/assignments/services/assignment-pdf.service.ts @@ -3,11 +3,13 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { HtmlToPdfService } from 'app/core/pdf-services/html-to-pdf.service'; -import { PollVoteValue } from 'app/core/ui-services/poll.service'; +import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe'; +import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; +import { PollPercentBasePipe } from 'app/shared/pipes/poll-percent-base.pipe'; +import { PollTableData } from 'app/site/polls/services/poll.service'; import { AssignmentPollService } from './assignment-poll.service'; import { ViewAssignment } from '../models/view-assignment'; import { ViewAssignmentPoll } from '../models/view-assignment-poll'; -import { ViewAssignmentPollOption } from '../models/view-assignment-poll-option'; /** * Creates a PDF document from a single assignment @@ -16,12 +18,6 @@ import { ViewAssignmentPollOption } from '../models/view-assignment-poll-option' providedIn: 'root' }) export class AssignmentPdfService { - /** - * Will be set to `true` of a person was elected. - * Determines that in indicator is shown under the table - */ - private showIsElected = false; - /** * Constructor * @@ -32,8 +28,11 @@ export class AssignmentPdfService { */ public constructor( private translate: TranslateService, - private pollService: AssignmentPollService, - private htmlToPdfService: HtmlToPdfService + private htmlToPdfService: HtmlToPdfService, + private pollKeyVerbose: PollKeyVerbosePipe, + private parsePollNumber: ParsePollNumberPipe, + private pollPercentBase: PollPercentBasePipe, + private assignmentPollService: AssignmentPollService ) {} /** @@ -134,6 +133,7 @@ export class AssignmentPdfService { margin: [0, 0, 0, 10] }; }); + const listType = assignment.number_poll_candidates ? 'ol' : 'ul'; return { columns: [ @@ -144,7 +144,7 @@ export class AssignmentPdfService { style: 'textItem' }, { - ul: userList, + [listType]: userList, style: 'textItem' } ] @@ -154,27 +154,6 @@ export class AssignmentPdfService { } } - /** - * Creates a candidate line in the results table - * - * @param candidateName The name of the candidate - * @param pollOption the poll options (yes, no, maybe [...]) - * @returns a line in the table - */ - private electedCandidateLine(candidateName: string, pollOption: ViewAssignmentPollOption): object { - if (pollOption.is_elected) { - this.showIsElected = true; - return { - text: candidateName + '*', - bold: true - }; - } else { - return { - text: candidateName - }; - } - } - /** * Creates the poll result table for all published polls * @@ -183,13 +162,12 @@ export class AssignmentPdfService { */ private createPollResultTable(assignment: ViewAssignment): object { const resultBody = []; - for (let pollIndex = 0; pollIndex < assignment.polls.length; pollIndex++) { - const poll = assignment.polls[pollIndex]; - if (poll.published) { + for (const poll of assignment.polls) { + if (poll.isPublished) { const pollTableBody = []; resultBody.push({ - text: `${this.translate.instant('Ballot')} ${pollIndex + 1}`, + text: poll.title, bold: true, style: 'textItem', margin: [0, 15, 0, 0] @@ -206,56 +184,22 @@ export class AssignmentPdfService { } ]); - for (let optionIndex = 0; optionIndex < poll.options.length; optionIndex++) { - const pollOption = poll.options[optionIndex]; + const tableData = this.assignmentPollService.generateTableData(poll); - const candidateName = pollOption.user.full_name; - const votes = pollOption.votes; // 0 = yes, 1 = no, 2 = abstain0 = yes, 1 = no, 2 = abstain - const tableLine = []; - tableLine.push(this.electedCandidateLine(candidateName, pollOption)); - - if (poll.pollmethod === 'votes') { - tableLine.push({ - text: this.parseVoteValue(votes[0].value, votes[0].weight, poll, pollOption) - }); - } else { - const resultBlock = votes.map(vote => - this.parseVoteValue(vote.value, vote.weight, poll, pollOption) - ); - - tableLine.push({ - text: resultBlock - }); - } - pollTableBody.push(tableLine); - } - - // push the result lines - const summaryLine = this.pollService.getVoteOptionsByPoll(poll).map(key => { - // TODO: Refractor into pollService to make this easier. - // Return an object with untranslated lable: string, specialLabel: string and (opt) percent: number - const conclusionLabel = this.translate.instant(this.pollService.getLabel(key)); - const specialLabel = this.translate.instant(this.pollService.getSpecialLabel(poll[key])); - let percentLabel = ''; - if (!this.pollService.isAbstractValue(this.pollService.calculationDataFromPoll(poll), key)) { - percentLabel = ` (${this.pollService.getValuePercent( - this.pollService.calculationDataFromPoll(poll), - key - )}%)`; - } - return [ + for (const pollResult of tableData) { + const voteOption = this.translate.instant(this.pollKeyVerbose.transform(pollResult.votingOption)); + const resultLine = this.getPollResult(pollResult, poll); + const tableLine = [ { - text: conclusionLabel, - style: 'tableConclude' + text: voteOption }, { - text: specialLabel + percentLabel, - style: 'tableConclude' + text: resultLine } ]; - }); - pollTableBody.push(...summaryLine); + pollTableBody.push(tableLine); + } resultBody.push({ table: { @@ -268,51 +212,21 @@ export class AssignmentPdfService { } } - // add the legend to the result body - // if (assignment.polls.length > 0 && isElectedSemaphore) { - if (assignment.polls.length > 0 && this.showIsElected) { - resultBody.push({ - text: `* = ${this.translate.instant('is elected')}`, - margin: [0, 5, 0, 0] - }); - } - return resultBody; } /** - * Creates a translated voting result with numbers and percent-value depending in the polloptions - * I.e: "Yes 25 (22,2%)" or just "10" - * - * @param optionLabel Usually Yes or No - * @param value the amount of votes - * @param poll the specific poll - * @param pollOption the corresponding poll option - * @returns a string a nicer number representation: "Yes 25 (22,2%)" or just "10" + * Converts pollData to a printable string representation */ - private parseVoteValue( - optionLabel: PollVoteValue, - value: number, - poll: ViewAssignmentPoll, - pollOption: ViewAssignmentPollOption - ): string { - let resultString = ''; - const label = this.translate.instant(this.pollService.getLabel(optionLabel)); - const valueString = this.pollService.getSpecialLabel(value); - const percentNr = this.pollService.getPercent( - this.pollService.calculationDataFromPoll(poll), - pollOption, - optionLabel - ); - - resultString += `${label} ${valueString}`; - if ( - percentNr && - !this.pollService.isAbstractOption(this.pollService.calculationDataFromPoll(poll), pollOption, optionLabel) - ) { - resultString += ` (${percentNr}%)`; - } - - return `${resultString}\n`; + private getPollResult(votingResult: PollTableData, poll: ViewAssignmentPoll): string { + const resultList = votingResult.value.map(singleResult => { + const votingKey = this.translate.instant(this.pollKeyVerbose.transform(singleResult.vote)); + const resultValue = this.parsePollNumber.transform(singleResult.amount); + const resultInPercent = this.pollPercentBase.transform(singleResult.amount, poll); + return `${votingKey}${!!votingKey ? ': ' : ''}${resultValue} ${ + singleResult.showPercent && resultInPercent ? resultInPercent : '' + }`; + }); + return resultList.join('\n'); } } diff --git a/client/src/app/site/assignments/services/assignment-poll-dialog.service.spec.ts b/client/src/app/site/assignments/services/assignment-poll-dialog.service.spec.ts new file mode 100644 index 000000000..4df85f9d2 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-poll-dialog.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentPollDialogService } from './assignment-poll-dialog.service'; + +describe('AssignmentPollDialogService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: AssignmentPollDialogService = TestBed.get(AssignmentPollDialogService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/services/assignment-poll-dialog.service.ts b/client/src/app/site/assignments/services/assignment-poll-dialog.service.ts new file mode 100644 index 000000000..5432c4863 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-poll-dialog.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material'; + +import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; +import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; +import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component'; +import { ViewAssignmentPoll } from '../models/view-assignment-poll'; + +/** + * Subclassed to provide the right `PollService` and `DialogComponent` + */ +@Injectable({ + providedIn: 'root' +}) +export class AssignmentPollDialogService extends BasePollDialogService { + protected dialogComponent = AssignmentPollDialogComponent; + + public constructor(dialog: MatDialog, mapper: CollectionStringMapperService) { + super(dialog, mapper); + } +} diff --git a/client/src/app/site/assignments/services/assignment-poll-pdf.service.ts b/client/src/app/site/assignments/services/assignment-poll-pdf.service.ts index 621aafa8f..3cb908c06 100644 --- a/client/src/app/site/assignments/services/assignment-poll-pdf.service.ts +++ b/client/src/app/site/assignments/services/assignment-poll-pdf.service.ts @@ -7,7 +7,7 @@ import { PdfDocumentService } from 'app/core/pdf-services/pdf-document.service'; import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { ConfigService } from 'app/core/ui-services/config.service'; -import { AssignmentPollMethod } from './assignment-poll.service'; +import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { ViewAssignmentPoll } from '../models/view-assignment-poll'; /** @@ -145,16 +145,25 @@ export class AssignmentPollPdfService extends PollPdfService { ? this.createBallotOption(cand.user.full_name) : this.createYNBallotEntry(cand.user.full_name, poll.pollmethod); }); + if (poll.pollmethod === 'votes') { - const noEntry = this.createBallotOption(this.translate.instant('No')); - noEntry.margin[1] = 25; - resultObject.push(noEntry); + if (poll.global_no) { + const noEntry = this.createBallotOption(this.translate.instant('No')); + noEntry.margin[1] = 25; + resultObject.push(noEntry); + } + + if (poll.global_abstain) { + const abstainEntry = this.createBallotOption(this.translate.instant('Abstain')); + abstainEntry.margin[1] = 25; + resultObject.push(abstainEntry); + } } return resultObject; } private createYNBallotEntry(option: string, method: AssignmentPollMethod): object { - const choices = method === 'yna' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No']; + const choices = method === 'YNA' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No']; const columnstack = choices.map(choice => { return { width: 'auto', @@ -181,7 +190,7 @@ export class AssignmentPollPdfService extends PollPdfService { */ private createPollHint(poll: ViewAssignmentPoll): object { return { - text: poll.description || '', + text: poll.assignment.default_poll_description || '', style: 'description' }; } diff --git a/client/src/app/site/assignments/services/assignment-poll.service.spec.ts b/client/src/app/site/assignments/services/assignment-poll.service.spec.ts index 63c7046b1..7bfd1f954 100644 --- a/client/src/app/site/assignments/services/assignment-poll.service.spec.ts +++ b/client/src/app/site/assignments/services/assignment-poll.service.spec.ts @@ -1,9 +1,15 @@ import { TestBed } from '@angular/core/testing'; +import { E2EImportsModule } from 'e2e-imports.module'; + import { AssignmentPollService } from './assignment-poll.service'; -describe('PollService', () => { - beforeEach(() => TestBed.configureTestingModule({})); +describe('AssignmentPollService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); it('should be created', () => { const service: AssignmentPollService = TestBed.get(AssignmentPollService); diff --git a/client/src/app/site/assignments/services/assignment-poll.service.ts b/client/src/app/site/assignments/services/assignment-poll.service.ts index ad964e421..85cc98f64 100644 --- a/client/src/app/site/assignments/services/assignment-poll.service.ts +++ b/client/src/app/site/assignments/services/assignment-poll.service.ts @@ -1,308 +1,184 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { ConstantsService } from 'app/core/core-services/constants.service'; +import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import { ConfigService } from 'app/core/ui-services/config.service'; import { - CalculablePollKey, - MajorityMethod, - PollMajorityMethod, - PollService, - PollVoteValue -} from 'app/core/ui-services/poll.service'; -import { AssignmentOptionVote } from 'app/shared/models/assignments/assignment-poll-option'; + AssignmentPoll, + AssignmentPollMethod, + AssignmentPollPercentBase +} from 'app/shared/models/assignments/assignment-poll'; +import { MajorityMethod, VOTE_UNDOCUMENTED } from 'app/shared/models/poll/base-poll'; +import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe'; +import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; +import { PollData, PollService, PollTableData, VotingResult } from 'app/site/polls/services/poll.service'; import { ViewAssignmentPoll } from '../models/view-assignment-poll'; -import { ViewAssignmentPollOption } from '../models/view-assignment-poll-option'; -type AssignmentPollValues = 'auto' | 'votes' | 'yesnoabstain' | 'yesno'; -export type AssignmentPollMethod = 'yn' | 'yna' | 'votes'; -export type AssignmentPercentBase = 'YES_NO_ABSTAIN' | 'YES_NO' | 'VALID' | 'CAST' | 'DISABLED'; - -/** - * interface common to data in a ViewAssignmentPoll and PollSlideData - * - * TODO: simplify - */ -export interface CalculationData { - pollMethod: AssignmentPollMethod; - votesno: number; - votesabstain: number; - votescast: number; - votesvalid: number; - votesinvalid: number; - percentBase?: AssignmentPercentBase; - pollOptions?: { - votes: AssignmentOptionVote[]; - }[]; -} - -interface CalculationOption { - votes: AssignmentOptionVote[]; -} - -/** - * Vote entries included once for summary (e.g. total votes cast) - */ -export type SummaryPollKey = 'votescast' | 'votesvalid' | 'votesinvalid' | 'votesno' | 'votesabstain'; - -/** - * Service class for assignment polls. - */ @Injectable({ providedIn: 'root' }) export class AssignmentPollService extends PollService { /** - * list of poll keys that are numbers and can be part of a quorum calculation + * The default percentage base */ - public pollValues: CalculablePollKey[] = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast']; + public defaultPercentBase: AssignmentPollPercentBase; /** - * the method used for polls (as per config) + * The default majority method */ - public pollMethod: AssignmentPollValues; + public defaultMajorityMethod: MajorityMethod; - /** - * the method used to determine the '100%' base (set in config) - */ - public percentBase: AssignmentPercentBase; + public defaultGroupIds: number[]; - /** - * convenience function for displaying the available majorities - */ - public get majorityMethods(): MajorityMethod[] { - return PollMajorityMethod; - } + public defaultPollMethod: AssignmentPollMethod; + + private sortByVote: boolean; /** * Constructor. Subscribes to the configuration values needed - * * @param config ConfigService */ - public constructor(config: ConfigService) { - super(); + public constructor( + config: ConfigService, + constants: ConstantsService, + pollKeyVerbose: PollKeyVerbosePipe, + parsePollNumber: ParsePollNumberPipe, + protected translate: TranslateService, + private pollRepo: AssignmentPollRepositoryService + ) { + super(constants, translate, pollKeyVerbose, parsePollNumber); config - .get('assignments_poll_default_majority_method') + .get('assignment_poll_default_100_percent_base') + .subscribe(base => (this.defaultPercentBase = base)); + config + .get('assignment_poll_default_majority_method') .subscribe(method => (this.defaultMajorityMethod = method)); + config.get(AssignmentPoll.defaultGroupsConfig).subscribe(ids => (this.defaultGroupIds = ids)); config - .get('assignments_poll_vote_values') - .subscribe(method => (this.pollMethod = method)); - config - .get('assignments_poll_100_percent_base') - .subscribe(base => (this.percentBase = base)); + .get(AssignmentPoll.defaultPollMethodConfig) + .subscribe(method => (this.defaultPollMethod = method)); + config.get('assignment_poll_sort_poll_result_by_votes').subscribe(sort => (this.sortByVote = sort)); } - public getVoteOptionsByPoll(poll: ViewAssignmentPoll): CalculablePollKey[] { - return this.pollValues.filter(name => poll[name] !== undefined); + public getDefaultPollData(contextId?: number): AssignmentPoll { + const poll = new AssignmentPoll({ + ...super.getDefaultPollData() + }); + + poll.title = this.translate.instant('Ballot'); + poll.pollmethod = this.defaultPollMethod; + + if (contextId) { + const length = this.pollRepo.getViewModelList().filter(item => item.assignment_id === contextId).length; + if (length) { + poll.title += ` (${length + 1})`; + } + } + + return poll; } - /** - * Get the base amount for the 100% calculations. Note that some poll methods - * (e.g. yes/no/abstain may have a different percentage base and will return null here) - * - * @param data - * @returns The amount of votes indicating the 100% base - */ - public getBaseAmount(data: CalculationData): number | null { - const percentBase = data.percentBase || this.percentBase; - switch (percentBase) { - case 'DISABLED': - return null; - case 'YES_NO': - case 'YES_NO_ABSTAIN': - if (data.pollMethod === 'votes') { - const yes = data.pollOptions.map(option => { - const yesValue = option.votes.find(v => v.value === 'Votes'); - return yesValue ? yesValue.weight : -99; - }); - if (Math.min(...yes) < 0) { - return null; - } else { - // TODO: Counting 'No (and possibly 'Abstain') here? - return yes.reduce((a, b) => a + b); - } + private getGlobalVoteKeys(poll: ViewAssignmentPoll): VotingResult[] { + return [ + { + vote: 'amount_global_no', + showPercent: this.showPercentOfValidOrCast(poll), + hide: poll.amount_global_no === VOTE_UNDOCUMENTED || !poll.amount_global_no + }, + { + vote: 'amount_global_abstain', + showPercent: this.showPercentOfValidOrCast(poll), + hide: poll.amount_global_abstain === VOTE_UNDOCUMENTED || !poll.amount_global_abstain + } + ]; + } + + public generateTableData(poll: ViewAssignmentPoll): PollTableData[] { + const tableData: PollTableData[] = poll.options + .sort((a, b) => { + if (this.sortByVote) { + return b.yes - a.yes; } else { - return null; + return b.weight - a.weight; } - case 'CAST': - return data.votescast > 0 && data.votesinvalid >= 0 ? data.votescast : null; - case 'VALID': - return data.votesvalid > 0 ? data.votesvalid : null; + }) + .map(candidate => ({ + votingOption: candidate.user.short_name, + votingOptionSubtitle: candidate.user.getLevelAndNumber(), + class: 'user', + value: super.getVoteTableKeys(poll).map( + key => + ({ + vote: key.vote, + amount: candidate[key.vote], + icon: key.icon, + hide: key.hide, + showPercent: key.showPercent + } as VotingResult) + ) + })); + tableData.push(...this.formatVotingResultToTableData(this.getGlobalVoteKeys(poll), poll)); + tableData.push(...this.formatVotingResultToTableData(super.getSumTableKeys(poll), poll)); + return tableData; + } + + private formatVotingResultToTableData(resultList: VotingResult[], poll: PollData): PollTableData[] { + return resultList + .filter(key => { + return !key.hide; + }) + .map(key => ({ + votingOption: key.vote, + class: 'sums', + value: [ + { + amount: poll[key.vote], + hide: key.hide, + showPercent: key.showPercent + } as VotingResult + ] + })); + } + + private sumOptionsYN(poll: PollData): number { + return poll.options.reduce((o, n) => { + o += n.yes > 0 ? n.yes : 0; + o += n.no > 0 ? n.no : 0; + return o; + }, 0); + } + + private sumOptionsYNA(poll: PollData): number { + return poll.options.reduce((o, n) => { + o += n.abstain > 0 ? n.abstain : 0; + return o; + }, this.sumOptionsYN(poll)); + } + + public getPercentBase(poll: PollData): number { + const base: AssignmentPollPercentBase = poll.onehundred_percent_base as AssignmentPollPercentBase; + let totalByBase: number; + switch (base) { + case AssignmentPollPercentBase.YN: + totalByBase = this.sumOptionsYN(poll); + break; + case AssignmentPollPercentBase.YNA: + totalByBase = this.sumOptionsYNA(poll); + break; + case AssignmentPollPercentBase.Votes: + totalByBase = this.sumOptionsYNA(poll); + break; + case AssignmentPollPercentBase.Valid: + totalByBase = poll.votesvalid; + break; + case AssignmentPollPercentBase.Cast: + totalByBase = poll.votescast; + break; default: - return null; - } - } - - /** - * Get the percentage for an option - * - * @param poll - * @param data - * @returns a percentage number with two digits, null if the value cannot be calculated - */ - public getPercent(data: CalculationData, option: CalculationOption, key: PollVoteValue): number | null { - const percentBase = data.percentBase || this.percentBase; - let base = 0; - if (percentBase === 'DISABLED') { - return null; - } else if (percentBase === 'VALID') { - base = data.votesvalid; - } else if (percentBase === 'CAST') { - base = data.votescast; - } else { - base = data.pollMethod === 'votes' ? this.getBaseAmount(data) : this.getOptionBaseAmount(data, option); - } - if (!base || base < 0) { - return null; - } - const vote = option.votes.find(v => v.value === key); - if (!vote) { - return null; - } - return Math.round(((vote.weight * 100) / base) * 100) / 100; - } - - /** - * get the percentage for a non-abstract per-poll value - * TODO: similar code to getPercent. Mergeable? - * - * @param data - * @param value a per-poll value (e.g. 'votesvalid') - * @returns a percentage number with two digits, null if the value cannot be calculated - */ - public getValuePercent(data: CalculationData, value: CalculablePollKey): number | null { - const percentBase = data.percentBase || this.percentBase; - switch (percentBase) { - case 'YES_NO': - case 'YES_NO_ABSTAIN': - case 'DISABLED': - return null; - case 'VALID': - if (value === 'votesinvalid' || value === 'votescast') { - return null; - } break; } - const baseAmount = this.getBaseAmount(data); - if (!baseAmount) { - return null; - } - const amount = data[value]; - if (amount === undefined || amount < 0) { - return null; - } - return Math.round(((amount * 100) / baseAmount) * 100) / 100; - } - - /** - * Check if the option in a poll is abstract (percentages should not be calculated) - * - * @param data - * @param option - * @param key (optional) the key to calculate - * @returns true if the poll has no percentages, the poll option is a special value, - * or if the calculations are disabled in the config - */ - public isAbstractOption(data: CalculationData, option: ViewAssignmentPollOption, key?: PollVoteValue): boolean { - const percentBase = data.percentBase || this.percentBase; - if (percentBase === 'DISABLED' || !option.votes || !option.votes.length) { - return true; - } - if (key === 'Abstain' && percentBase === 'YES_NO') { - return true; - } - if (data.pollMethod === 'votes') { - return this.getBaseAmount(data) > 0 ? false : true; - } else { - return option.votes.some(v => v.weight < 0); - } - } - - /** - * Check for abstract (not usable as percentage) options in non-option - * 'meta' values - * - * @param data - * @param value - * @returns true if percentages cannot be calculated - * TODO: Yes, No, etc. in an option will always return true. - * Use {@link isAbstractOption} for these - */ - public isAbstractValue(data: CalculationData, value: CalculablePollKey): boolean { - const percentBase = data.percentBase || this.percentBase; - if (percentBase === 'DISABLED' || !this.getBaseAmount(data) || !this.pollValues.includes(value)) { - return true; - } - if (percentBase === 'CAST' && data[value] >= 0) { - return false; - } else if (percentBase === 'VALID' && value === 'votesvalid' && data[value] > 0) { - return false; - } - return true; - } - - /** - * Calculate the base amount inside an option. Only useful if poll method is not 'votes' - * - * @param data - * @param option - * @returns an positive integer to be used as percentage base, or null - */ - private getOptionBaseAmount(data: CalculationData, option: CalculationOption): number | null { - const percentBase = data.percentBase || this.percentBase; - if (percentBase === 'DISABLED' || data.pollMethod === 'votes') { - return null; - } else if (percentBase === 'CAST') { - return data.votescast > 0 ? data.votescast : null; - } else if (percentBase === 'VALID') { - return data.votesvalid > 0 ? data.votesvalid : null; - } - const yes = option.votes.find(v => v.value === 'Yes'); - const no = option.votes.find(v => v.value === 'No'); - if (percentBase === 'YES_NO') { - if (!yes || yes.weight === undefined || !no || no.weight === undefined) { - return null; - } - return yes.weight >= 0 && no.weight >= 0 ? yes.weight + no.weight : null; - } else if (percentBase === 'YES_NO_ABSTAIN') { - const abstain = option.votes.find(v => v.value === 'Abstain'); - if (!abstain || abstain.weight === undefined) { - return null; - } - return yes.weight >= 0 && no.weight >= 0 && abstain.weight >= 0 - ? yes.weight + no.weight + abstain.weight - : null; - } - } - - /** - * Get the minimum amount of votes needed for an option to pass the quorum - * - * @param method - * @param data - * @param option - * @returns a positive integer number; may return null if quorum is not calculable - */ - public yesQuorum(method: MajorityMethod, data: CalculationData, option: ViewAssignmentPollOption): number | null { - const baseAmount = - data.pollMethod === 'votes' ? this.getBaseAmount(data) : this.getOptionBaseAmount(data, option); - return method.calc(baseAmount); - } - - /** - * helper function to tuirn a Poll into calculation data for this service - * TODO: temp until better method to normalize Poll ans PollSlideData is implemented - * - * @param poll - * @returns calculationData ready to be used - */ - public calculationDataFromPoll(poll: ViewAssignmentPoll): CalculationData { - return { - pollMethod: poll.pollmethod, - votesno: poll.votesno, - votesabstain: poll.votesabstain, - votescast: poll.votescast, - votesinvalid: poll.votesinvalid, - votesvalid: poll.votesvalid, - pollOptions: poll.options - }; + return totalByBase; } } 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 6336fc4e5..f146b2a10 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 @@ -7,6 +7,9 @@ + + + @@ -29,6 +32,17 @@ + + + + (); + /** used by the groups config type */ + public groupObservable: Observable = null; + /** * The usual component constructor. datetime pickers will set their locale * to the current language chosen @@ -130,7 +136,8 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes protected translate: TranslateService, private formBuilder: FormBuilder, private cd: ChangeDetectorRef, - public repo: ConfigRepositoryService + public repo: ConfigRepositoryService, + private groupRepo: GroupRepositoryService ) { super(titleService, translate); } @@ -139,6 +146,11 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes * Sets up the form for this config field. */ public ngOnInit(): void { + // filter out empty results in group observable. We never have no groups and it messes up the settings change detection + this.groupObservable = this.groupRepo + .getViewModelListObservableWithoutDefaultGroup() + .pipe(filter(groups => !!groups.length)); + this.form = this.formBuilder.group({ value: [''], date: [''], @@ -226,6 +238,14 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes const time = this.form.get('time').value; value = this.dateAndTimeToUnix(date, time); } + if (this.configItem.inputType === 'groups') { + // we have to check here explicitly if nothing changed because of the search value selector + const newS = new Set(value); + const oldS = new Set(this.configItem.value); + if (newS.equals(oldS)) { + return; + } + } this.sendUpdate(value); this.cd.detectChanges(); } 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 index 140deffee..30b0f6261 100644 --- 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 @@ -25,6 +25,7 @@ home today assignment + pie_chart how_to_vote groups language diff --git a/client/src/app/site/config/config.config.ts b/client/src/app/site/config/config.config.ts index 6208dad30..9622f04e2 100644 --- a/client/src/app/site/config/config.config.ts +++ b/client/src/app/site/config/config.config.ts @@ -5,9 +5,7 @@ import { ViewConfig } from './models/view-config'; export const ConfigAppConfig: AppConfig = { name: 'settings', - models: [ - { collectionString: 'core/config', model: Config, viewModel: ViewConfig, repository: ConfigRepositoryService } - ], + models: [{ model: Config, viewModel: ViewConfig, repository: ConfigRepositoryService }], mainMenuEntries: [ { route: '/settings', diff --git a/client/src/app/site/history/components/history-list/history-list.component.html b/client/src/app/site/history/components/history-list/history-list.component.html index f92137f09..897133b57 100644 --- a/client/src/app/site/history/components/history-list/history-list.component.html +++ b/client/src/app/site/history/components/history-list/history-list.component.html @@ -13,15 +13,16 @@
    - - + + + +
    @@ -301,16 +302,17 @@

    Please enter a name for the new directory:

    - + - + + +
    @@ -328,16 +330,17 @@

    Move into directory

    -
    +

    Please select the directory:

    - + + +
    diff --git a/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.html b/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.html index 067edb1ac..516afe296 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.html +++ b/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.html @@ -34,13 +34,14 @@
    - + + +

    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 fb9411c67..fbb3e8346 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 @@ -133,7 +133,12 @@ {{ getTitleWithChanges() }} -

    @@ -459,14 +464,17 @@
    - - -
    - -
    + +
    @@ -601,15 +609,16 @@
    -
    - -
    + + + + +
    @@ -783,13 +792,14 @@
    - + + +
    @@ -805,8 +815,8 @@
    @@ -818,25 +828,27 @@
    - + + +
    - + + +
    diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.scss b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.scss index 43f3fdebf..87e61c8ad 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.scss +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.scss @@ -4,6 +4,10 @@ span { margin: 0; } +.create-poll-button { + margin-bottom: 1em; +} + .extra-controls-slot { div { padding: 0px; @@ -207,13 +211,6 @@ span { } } -.create-poll-button { - margin-top: 10px; - button { - padding: 0px; - } -} - .mat-chip-list-stacked { .mat-chip { margin: 4px 4px 4px 4px; diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.spec.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.spec.ts index 6575de8bd..e676a45b6 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.spec.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.spec.ts @@ -2,12 +2,14 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { E2EImportsModule } from 'e2e-imports.module'; +import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component'; import { ManageSubmittersComponent } from '../manage-submitters/manage-submitters.component'; import { MotionCommentsComponent } from '../motion-comments/motion-comments.component'; import { MotionDetailDiffComponent } from '../motion-detail-diff/motion-detail-diff.component'; import { MotionDetailOriginalChangeRecommendationsComponent } from '../motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; import { MotionDetailComponent } from './motion-detail.component'; -import { MotionPollComponent } from '../motion-poll/motion-poll.component'; +import { MotionPollVoteComponent } from '../../../motion-poll/motion-poll-vote/motion-poll-vote.component'; +import { MotionPollComponent } from '../../../motion-poll/motion-poll/motion-poll.component'; import { PersonalNoteComponent } from '../personal-note/personal-note.component'; describe('MotionDetailComponent', () => { @@ -24,7 +26,9 @@ describe('MotionDetailComponent', () => { ManageSubmittersComponent, MotionPollComponent, MotionDetailOriginalChangeRecommendationsComponent, - MotionDetailDiffComponent + MotionDetailDiffComponent, + MotionPollVoteComponent, + PollProgressComponent ] }).compileComponents(); })); diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts index 84917b049..c398651fe 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts @@ -47,6 +47,7 @@ import { ViewCreateMotion } from 'app/site/motions/models/view-create-motion'; import { ViewMotion } from 'app/site/motions/models/view-motion'; import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block'; import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph'; import { ViewWorkflow } from 'app/site/motions/models/view-workflow'; import { MotionEditNotification } from 'app/site/motions/motion-edit-notification'; @@ -62,6 +63,8 @@ import { AmendmentSortListService } from 'app/site/motions/services/amendment-so import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service'; import { MotionFilterListService } from 'app/site/motions/services/motion-filter-list.service'; import { MotionPdfExportService } from 'app/site/motions/services/motion-pdf-export.service'; +import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service'; +import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; import { MotionSortListService } from 'app/site/motions/services/motion-sort-list.service'; import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewUser } from 'app/site/users/models/view-user'; @@ -464,7 +467,9 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, private amendmentSortService: AmendmentSortListService, private motionFilterService: MotionFilterListService, private amendmentFilterService: AmendmentFilterListService, - private cd: ChangeDetectorRef + private cd: ChangeDetectorRef, + private pollDialog: MotionPollDialogService, + private motionPollService: MotionPollService ) { super(title, translate, matSnackBar); } @@ -1378,13 +1383,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, window.open(attachment.url); } - /** - * Handler for creating a poll - */ - public createPoll(): void { - this.repo.createPoll(this.motion).catch(this.raiseError); - } - /** * Check if a recommendation can be followed. Checks for permissions and additionally if a recommentadion is present */ @@ -1568,12 +1566,12 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, * Function to prevent automatically closing the window/tab, * if the user is editing a motion. * - * @param $event The event object from 'onUnbeforeUnload'. + * @param event The event object from 'onUnbeforeUnload'. */ @HostListener('window:beforeunload', ['$event']) - public stopClosing($event: Event): void { + public stopClosing(event: Event): void { if (this.editMotion) { - $event.returnValue = null; + event.returnValue = null; } } @@ -1628,4 +1626,15 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, public detectChanges(): void { this.cd.markForCheck(); } + + public openDialog(): void { + const dialogData = { + collectionString: ViewMotionPoll.COLLECTIONSTRING, + motion_id: this.motion.id, + motion: this.motion, + ...this.motionPollService.getDefaultPollData(this.motion.id) + }; + + this.pollDialog.openDialog(dialogData); + } } diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.html b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.html deleted file mode 100644 index 7b3cc3f30..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.html +++ /dev/null @@ -1,20 +0,0 @@ -

    Voting result

    -
    - Special values:
    - -1 =  - majority
    - -2 =  - undocumented -
    -
    - - {{ getLabel(key) | translate }} - - - -
    -
    - - -
    - diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.scss b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.scss deleted file mode 100644 index 22e1c860e..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.scss +++ /dev/null @@ -1,14 +0,0 @@ -.submit-buttons { - display: flex; - justify-content: flex-end; -} - -.meta-text { - font-style: italic; - margin-left: 10px; - margin-right: 10px; - mat-chip { - margin-left: 5px; - margin-right: 2px; - } -} diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts deleted file mode 100644 index b3c952ee2..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Component, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; - -import { TranslateService } from '@ngx-translate/core'; - -import { CalculablePollKey } from 'app/core/ui-services/poll.service'; -import { MotionPoll } from 'app/shared/models/motions/motion-poll'; -import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; - -/** - * A dialog for updating the values of a poll. - */ -@Component({ - selector: 'os-motion-poll-dialog', - templateUrl: './motion-poll-dialog.component.html', - styleUrls: ['./motion-poll-dialog.component.scss'] -}) -export class MotionPollDialogComponent { - /** - * List of accepted special non-numerical values. - * See {@link PollService.specialPollVotes} - */ - public specialValues: [number, string][]; - - /** - * Array of vote entries in this component - */ - public pollKeys: CalculablePollKey[]; - - /** - * Constructor. Retrieves necessary metadata from the pollService, - * injects the poll itself - */ - public constructor( - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: MotionPoll, - private matSnackBar: MatSnackBar, - private translate: TranslateService, - private pollService: MotionPollService - ) { - this.pollKeys = this.pollService.pollValues; - this.specialValues = this.pollService.specialPollVotes; - } - - /** - * Close the dialog, submitting nothing. Triggered by the cancel button and - * default angular cancelling behavior - */ - public cancel(): void { - this.dialogRef.close(); - } - - /** - * validates if 'yes', 'no' and 'abstain' have values, submits and closes - * the dialog if successfull, else displays an error popup. - * TODO better validation - */ - public submit(): void { - if (this.data.yes === undefined || this.data.no === undefined || this.data.abstain === undefined) { - this.matSnackBar.open( - this.translate.instant('Please fill in all required values'), - this.translate.instant('OK'), - { - duration: 1000 - } - ); - } else { - this.dialogRef.close(this.data); - } - } - - /** - * Returns a label for a poll option - * @param key poll option to be labeled - */ - public getLabel(key: CalculablePollKey): string { - return this.pollService.getLabel(key); - } -} diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.html b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.html deleted file mode 100644 index d0a6295a7..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.html +++ /dev/null @@ -1,73 +0,0 @@ - - - Voting result -  ({{ pollIndex + 1 }}) - - -
    -
    -
    -
    - - {{ getIcon(key) }} - -
    -
    - {{ getLabel(key) | translate }}: {{ getNumber(key) }} - ({{ getPercent(key) }}%) -
    -
    - - -
    -
    -
    -
    -
    - -
    -
    - - - thumb_down - thumb_up - - - - reached. - not reached. - - -
    -
    -
    -
    -
    - - - - - -
    - - - - diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.scss b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.scss deleted file mode 100644 index bf93cef22..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.motion-poll-wrapper { - @import '~assets/styles/poll-common-styles.scss'; -} diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.spec.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.spec.ts deleted file mode 100644 index fc0ee30c5..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -// import { MotionPollComponent } from './motion-poll.component'; -// import { E2EImportsModule } from 'e2e-imports.module'; -// import { MetaTextBlockComponent } from '../meta-text-block/meta-text-block.component'; - -describe('MotionPollComponent', () => { - // TODO testing fails if personalNotesModule (also having the MetaTextBlockComponent) - // is running its' test at the same time. One of the two tests fail, but run fine if tested - // separately; so this is some async duplication stuff - // let component: MotionPollComponent; - // let fixture: ComponentFixture; - // beforeEach(async(() => { - // TestBed.configureTestingModule({ - // imports: [E2EImportsModule], - // declarations: [MetaTextBlockComponent, MotionPollComponent] - // }).compileComponents(); - // })); - // beforeEach(() => { - // fixture = TestBed.createComponent(MotionPollComponent); - // component = fixture.componentInstance; - // fixture.detectChanges(); - // }); - // it('should create', () => { - // expect(component).toBeTruthy(); - // }); -}); diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts deleted file mode 100644 index 64b71b9dd..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; -import { MatSnackBar } from '@angular/material'; -import { MatDialog } from '@angular/material/dialog'; -import { Title } from '@angular/platform-browser'; - -import { TranslateService } from '@ngx-translate/core'; - -import { ConstantsService } from 'app/core/core-services/constants.service'; -import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; -import { CalculablePollKey } from 'app/core/ui-services/poll.service'; -import { PromptService } from 'app/core/ui-services/prompt.service'; -import { MotionPoll } from 'app/shared/models/motions/motion-poll'; -import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; -import { BaseViewComponent } from 'app/site/base/base-view'; -import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service'; -import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service'; -import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; -import { MotionPollDialogComponent } from './motion-poll-dialog.component'; - -/** - * A component used to display and edit polls of a motion. - */ -@Component({ - selector: 'os-motion-poll', - templateUrl: './motion-poll.component.html', - styleUrls: ['./motion-poll.component.scss'], - encapsulation: ViewEncapsulation.None -}) -export class MotionPollComponent extends BaseViewComponent implements OnInit { - /** - * A representation of all values of the current poll. - */ - public pollValues: CalculablePollKey[]; - - /** - * The motion poll as coming from the server. Needs conversion of strings to numbers first - * (see {@link ngOnInit}) - */ - @Input() - public rawPoll: any; - - /** - * (optional) number of poll iffor dispaly purpose - */ - @Input() - public pollIndex: number; - - /** - * The current poll - */ - public poll: MotionPoll; - - /** - * The current choice for calulating a Quorum - */ - public majorityChoice: string; - - /** - * The constants available for calulating a quorum - */ - public majorityChoices: { display_name: string; value: string }[] = []; - - /** - * Getter for calulating the current quorum via pollService - * - * @returns the number required to be reached for a vote to match the quorum - */ - public get yesQuorum(): number { - return this.pollService.calculateQuorum(this.poll, this.majorityChoice); - } - - /** - * Indicates if the poll can be expressed with percentages and calculated quorums or is abstract - * - * @returns true if abstract (no calculations possible) - */ - public get abstractPoll(): boolean { - return this.pollService.getBaseAmount(this.poll) <= 0; - } - - /** - * Constructor. Subscribes to the constants and settings for motion polls - * - * @param title - * @param translate TranslateService - * @param matSnackbar - * @param dialog Dialog Service for entering poll data - * @param pollService MotionPollService - * @param motionRepo Subscribing to the motion to update poll from the server - * @param constants ConstantsService - * @param config ConfigService - * @param perms LocalPermissionService - */ - public constructor( - title: Title, - translate: TranslateService, - matSnackBar: MatSnackBar, - public dialog: MatDialog, - public pollService: MotionPollService, - private motionRepo: MotionRepositoryService, - private constants: ConstantsService, - private promptService: PromptService, - public perms: LocalPermissionsService, - private pdfService: MotionPollPdfService - ) { - super(title, translate, matSnackBar); - this.pollValues = this.pollService.pollValues; - this.majorityChoice = this.pollService.defaultMajorityMethod; - this.subscribeMajorityChoices(); - } - - /** - * Subscribes to updates of itself - */ - public ngOnInit(): void { - this.poll = new MotionPoll(this.rawPoll); - this.motionRepo.getViewModelObservable(this.poll.motion_id).subscribe(viewmotion => { - if (viewmotion) { - const updatePoll = viewmotion.motion.polls.find(poll => poll.id === this.poll.id); - if (updatePoll) { - this.poll = new MotionPoll(updatePoll); - } - } - }); - } - - /** - * Sends a delete request for this poll after a confirmation dialog has been accepted. - */ - public async deletePoll(): Promise { - const title = this.translate.instant('Are you sure you want to delete this vote?'); - if (await this.promptService.open(title)) { - this.motionRepo.deletePoll(this.poll).catch(this.raiseError); - } - } - - /** - * @returns the label for a poll option - */ - public getLabel(key: CalculablePollKey): string { - return this.pollService.getLabel(key); - } - - /** - * @returns the icon's name for the icon of a poll option - */ - public getIcon(key: CalculablePollKey): string { - return this.pollService.getIcon(key); - } - - /** - * Transform special case numbers into their strings - * @param key - * - * @returns the number if positive or the special values' translated string - */ - public getNumber(key: CalculablePollKey): number | string { - if (this.poll[key] >= 0) { - return this.poll[key]; - } else { - return this.translate.instant(this.pollService.getSpecialLabel(this.poll[key])); - } - } - - /** - * Check if the value cannot be expressed in percentages. - * @param key - * @returns if the value cannot be calculated - */ - public isAbstractValue(key: CalculablePollKey): boolean { - return this.pollService.isAbstractValue(this.poll, key); - } - - /** - * Calculates the percentages of a value. See {@link MotionPollService.getPercent} - * - * @param value - * @returns a number with two digits, 100.00 representing 100 percent. May be null if the value cannot be calulated - */ - public getPercent(value: CalculablePollKey): number { - return this.pollService.calculatePercentage(this.poll, value); - } - - /** - * Triggers the printing of the ballots - */ - public printBallots(): void { - this.pdfService.printBallots(this.poll); - } - - /** - * Triggers the 'edit poll' dialog' - */ - public editPoll(): void { - const dialogRef = this.dialog.open(MotionPollDialogComponent, { - data: { ...this.poll }, - ...infoDialogSettings - }); - dialogRef.afterClosed().subscribe(result => { - if (result) { - this.motionRepo.updatePoll(result).catch(this.raiseError); - } - }); - } - - /** - * Indicates if the necessary quorum is reached by the 'yes' votes - * - * @returns true if the quorum is reached - */ - public get quorumYesReached(): boolean { - return this.poll.yes >= this.yesQuorum; - } - - /** - * Subscribe to the available majority choices as given in the server-side constants - */ - private subscribeMajorityChoices(): void { - this.constants.get('ConfigVariables').subscribe(constants => { - const motionconst = constants.find(c => c.name === 'Motions'); - if (motionconst) { - const ballotConst = motionconst.subgroups.find(s => s.name === 'Voting and ballot papers'); - if (ballotConst) { - const methods = ballotConst.items.find(b => b.key === 'motions_poll_default_majority_method'); - this.majorityChoices = methods.choices; - } - } - }); - } - - /** - * Get a label for the quorum selection button. See {@link majorityChoices} - * for possible values - * - * @returns a string from the angular material-icon font, or an empty string - */ - public getQuorumLabel(): string { - const choice = this.majorityChoices.find(ch => ch.value === this.majorityChoice); - return choice ? choice.display_name : ''; - } -} diff --git a/client/src/app/site/motions/modules/motion-detail/motion-detail.module.ts b/client/src/app/site/motions/modules/motion-detail/motion-detail.module.ts index 518f37b1e..0fa440b9a 100644 --- a/client/src/app/site/motions/modules/motion-detail/motion-detail.module.ts +++ b/client/src/app/site/motions/modules/motion-detail/motion-detail.module.ts @@ -10,21 +10,18 @@ import { MotionDetailDiffComponent } from './components/motion-detail-diff/motio import { MotionDetailOriginalChangeRecommendationsComponent } from './components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; import { MotionDetailRoutingModule } from './motion-detail-routing.module'; import { MotionDetailComponent } from './components/motion-detail/motion-detail.component'; -import { MotionPollDialogComponent } from './components/motion-poll/motion-poll-dialog.component'; -import { MotionPollComponent } from './components/motion-poll/motion-poll.component'; +import { MotionPollModule } from '../motion-poll/motion-poll.module'; import { MotionTitleChangeRecommendationDialogComponent } from './components/motion-title-change-recommendation-dialog/motion-title-change-recommendation-dialog.component'; import { PersonalNoteComponent } from './components/personal-note/personal-note.component'; @NgModule({ - imports: [CommonModule, MotionDetailRoutingModule, SharedModule], + imports: [CommonModule, MotionDetailRoutingModule, SharedModule, MotionPollModule], declarations: [ MotionDetailComponent, AmendmentCreateWizardComponent, MotionCommentsComponent, PersonalNoteComponent, ManageSubmittersComponent, - MotionPollComponent, - MotionPollDialogComponent, MotionDetailDiffComponent, MotionDetailOriginalChangeRecommendationsComponent, MotionChangeRecommendationDialogComponent, @@ -34,7 +31,6 @@ import { PersonalNoteComponent } from './components/personal-note/personal-note. MotionCommentsComponent, PersonalNoteComponent, ManageSubmittersComponent, - MotionPollDialogComponent, MotionChangeRecommendationDialogComponent, MotionTitleChangeRecommendationDialogComponent ] diff --git a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html index d2c4b56b1..4e6df6ac9 100644 --- a/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html +++ b/client/src/app/site/motions/modules/motion-list/components/motion-list/motion-list.component.html @@ -42,7 +42,7 @@ +
    +

    {{ 'Motion' | translate }} {{ poll.motion.identifierOrTitle }}

    +
    + + + + + + + + + + + +

    {{ poll.title | translate }}

    + +
    + + {{ poll.typeVerbose | translate }} · + + + + {{ poll.stateVerbose | translate }} + +
    + +

    {{ 'No results yet.' | translate }}

    + +
    + + + + +
    +

    {{ 'Single votes' | translate }}

    + + +
    + {{ col.label | translate }} +
    + + +
    +
    {{ vote.user.getFullName() }}
    +
    {{ 'Anonymous' | translate }}
    +
    +
    +
    + {{ voteOptionStyle[vote.value].icon }} +
    +
    {{ vote.valueVerbose | translate }}
    +
    +
    +
    + {{ 'The individual votes were anonymized.' | translate }} +
    +
    +
    + +
    + + {{ 'Groups' | translate }}: + + + {{ group.getTitle() | translate }}, + + + {{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }} +
    +
    +
    + + + + + + + + + diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss new file mode 100644 index 000000000..1372bdc13 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss @@ -0,0 +1,34 @@ +@import '~assets/styles/poll-styles-common.scss'; + +.poll-content { + text-align: right; + display: grid; + padding-top: 20px; +} + +.named-result-table { + grid-area: names; + .mat-form-field { + font-size: 14px; + width: 100%; + } + + .vote-cell { + display: flex; + align-items: center; + + .vote-cell-icon-container { + display: flex; + align-items: center; + margin-right: 7px; + } + } +} + +.single-votes-table { + height: 500px; +} + +.openslides-theme .pbl-ngrid-no-data { + top: 10%; +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss new file mode 100644 index 000000000..1d83fa7bc --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss @@ -0,0 +1,12 @@ +@import '~@angular/material/theming'; + +@mixin os-motion-poll-detail-style($theme) { + $background: map-get($theme, background); + + os-list-view-table os-sort-filter-bar .custom-table-header { + &, + .action-buttons .input-container input { + background: mat-color($background, card); + } + } +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.spec.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.spec.ts new file mode 100644 index 000000000..29e8cf621 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionPollDetailComponent } from './motion-poll-detail.component'; + +describe('MotionPollDetailComponent', () => { + let component: MotionPollDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionPollDetailComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionPollDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts new file mode 100644 index 000000000..408347d86 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts @@ -0,0 +1,75 @@ +import { Component, ViewEncapsulation } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; +import { PblColumnDefinition } from '@pebula/ngrid'; + +import { OperatorService } from 'app/core/core-services/operator.service'; +import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; +import { MotionVoteRepositoryService } from 'app/core/repositories/motions/motion-vote-repository.service'; +import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { ViewMotion } from 'app/site/motions/models/view-motion'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service'; +import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; +import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component'; + +@Component({ + selector: 'os-motion-poll-detail', + templateUrl: './motion-poll-detail.component.html', + styleUrls: ['./motion-poll-detail.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MotionPollDetailComponent extends BasePollDetailComponent { + public motion: ViewMotion; + public columnDefinition: PblColumnDefinition[] = [ + { + prop: 'user', + width: 'auto', + label: 'Participant' + }, + { + prop: 'vote', + width: 'auto', + label: 'Vote' + } + ]; + + public filterProps = ['user.getFullName', 'valueVerbose']; + + public constructor( + title: Title, + translate: TranslateService, + matSnackbar: MatSnackBar, + repo: MotionPollRepositoryService, + route: ActivatedRoute, + groupRepo: GroupRepositoryService, + prompt: PromptService, + pollDialog: MotionPollDialogService, + pollService: MotionPollService, + votesRepo: MotionVoteRepositoryService, + private operator: OperatorService, + private router: Router + ) { + super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog, pollService, votesRepo); + } + + protected createVotesData(): void { + this.setVotesData(this.poll.options[0].votes); + } + + public openDialog(): void { + this.pollDialog.openDialog(this.poll); + } + + protected onDeleted(): void { + this.router.navigate(['motions', this.poll.motion_id]); + } + + protected hasPerms(): boolean { + return this.operator.hasPerms('motions.can_manage_polls'); + } +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.html new file mode 100644 index 000000000..1f01116f8 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.html @@ -0,0 +1,61 @@ + + +
    +
    + + + + + + +
    +
    + + +
    + + Publish immediately + + + Error in form field. + +
    +
    +
    + + +
    diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.scss b/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.spec.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.spec.ts new file mode 100644 index 000000000..773ff4751 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.spec.ts @@ -0,0 +1,34 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionPollDialogComponent } from './motion-poll-dialog.component'; + +describe('MotionPollDialogComponent', () => { + let component: MotionPollDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: {} + } + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionPollDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.ts new file mode 100644 index 000000000..dc927389e --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.ts @@ -0,0 +1,73 @@ +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef, MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { LOWEST_VOTE_VALUE } from 'app/shared/models/poll/base-poll'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component'; +import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component'; +import { PercentBaseVerbose } from 'app/site/polls/models/view-base-poll'; + +@Component({ + selector: 'os-motion-poll-dialog', + templateUrl: './motion-poll-dialog.component.html', + styleUrls: ['./motion-poll-dialog.component.scss'] +}) +export class MotionPollDialogComponent extends BasePollDialogComponent implements OnInit { + public PercentBaseVerbose = PercentBaseVerbose; + + @ViewChild('pollForm', { static: false }) + protected pollForm: PollFormComponent; + + public constructor( + title: Title, + translate: TranslateService, + matSnackbar: MatSnackBar, + public dialogRef: MatDialogRef>, + private formBuilder: FormBuilder, + @Inject(MAT_DIALOG_DATA) public pollData: Partial + ) { + super(title, translate, matSnackbar, dialogRef); + } + + public ngOnInit(): void { + this.createDialog(); + } + + private updateDialogVoteForm(data: Partial): void { + const update: any = { + Y: data.options[0].yes, + N: data.options[0].no, + A: data.options[0].abstain, + votesvalid: data.votesvalid, + votesinvalid: data.votesinvalid, + votescast: data.votescast + }; + + if (this.dialogVoteForm) { + const result = this.undoReplaceEmptyValues(update); + this.dialogVoteForm.setValue(result); + } + } + + /** + * Pre-executed method to initialize the dialog-form depending on the poll-method. + */ + private createDialog(): void { + this.dialogVoteForm = this.formBuilder.group({ + Y: ['', [Validators.min(LOWEST_VOTE_VALUE)]], + N: ['', [Validators.min(LOWEST_VOTE_VALUE)]], + A: ['', [Validators.min(LOWEST_VOTE_VALUE)]], + votesvalid: ['', [Validators.min(LOWEST_VOTE_VALUE)]], + votesinvalid: ['', [Validators.min(LOWEST_VOTE_VALUE)]], + votescast: ['', [Validators.min(LOWEST_VOTE_VALUE)]] + }); + + if (this.pollData.poll) { + this.updateDialogVoteForm(this.pollData); + } + } +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-routing.module.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-routing.module.ts new file mode 100644 index 000000000..95a4197db --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-routing.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { MotionPollDetailComponent } from './motion-poll-detail/motion-poll-detail.component'; + +const routes: Routes = [ + { path: 'new', component: MotionPollDetailComponent }, + { path: ':id', component: MotionPollDetailComponent } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class MotionPollRoutingModule {} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.html new file mode 100644 index 000000000..d80d051a0 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.html @@ -0,0 +1,30 @@ +
    + +
    + +
    + +
    + + {{ option.label | translate }} +
    +
    +
    + + +
    +
    + +
    + {{ 'Voting successful.' | translate }} +
    +
    +
    diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.scss b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.scss new file mode 100644 index 000000000..d13eb0a9c --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.scss @@ -0,0 +1,34 @@ +@import '~assets/styles/poll-colors.scss'; +@import '~assets/styles/poll-styles-common.scss'; + +.vote-button-grid { + display: grid; + grid-gap: 20px; + margin-top: 2em; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); +} + +.vote-button { + display: inline-grid; + grid-gap: 1em; + margin: auto; + + .vote-label { + text-align: center; + } +} + +.user-has-voted { + display: flex; + text-align: center; + > * { + margin-top: 1em; + margin-left: auto; + margin-right: auto; + } + + .vote-submitted { + color: $votes-yes-color; + font-size: 200%; + } +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.spec.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.spec.ts new file mode 100644 index 000000000..e04ef7674 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.spec.ts @@ -0,0 +1,28 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component'; +import { MotionPollVoteComponent } from './motion-poll-vote.component'; + +describe('MotionPollVoteComponent', () => { + let component: MotionPollVoteComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionPollVoteComponent, PollProgressComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionPollVoteComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts new file mode 100644 index 000000000..d8a839fff --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts @@ -0,0 +1,72 @@ +import { Component } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { OperatorService } from 'app/core/core-services/operator.service'; +import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { VotingService } from 'app/core/ui-services/voting.service'; +import { VoteValue } from 'app/shared/models/poll/base-vote'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component'; + +interface VoteOption { + vote?: VoteValue; + css?: string; + icon?: string; + label?: string; +} + +@Component({ + selector: 'os-motion-poll-vote', + templateUrl: './motion-poll-vote.component.html', + styleUrls: ['./motion-poll-vote.component.scss'] +}) +export class MotionPollVoteComponent extends BasePollVoteComponent { + public currentVote: VoteOption = {}; + public voteOptions: VoteOption[] = [ + { + vote: 'Y', + css: 'voted-yes', + icon: 'thumb_up', + label: 'Yes' + }, + { + vote: 'N', + css: 'voted-no', + icon: 'thumb_down', + label: 'No' + }, + { + vote: 'A', + css: 'voted-abstain', + icon: 'trip_origin', + label: 'Abstain' + } + ]; + + public constructor( + title: Title, + translate: TranslateService, + matSnackbar: MatSnackBar, + operator: OperatorService, + public vmanager: VotingService, + private pollRepo: MotionPollRepositoryService, + private promptService: PromptService + ) { + super(title, translate, matSnackbar, operator); + } + + public saveVote(vote: VoteValue): void { + this.currentVote.vote = vote; + const title = this.translate.instant('Submit selection now?'); + const content = this.translate.instant('Your decision cannot be changed afterwards.'); + this.promptService.open(title, content).then(confirmed => { + if (confirmed) { + this.pollRepo.vote(vote, this.poll.id).catch(this.raiseError); + } + }); + } +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts new file mode 100644 index 000000000..1c781d657 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts @@ -0,0 +1,16 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SharedModule } from 'app/shared/shared.module'; +import { PollsModule } from 'app/site/polls/polls.module'; +import { MotionPollDetailComponent } from './motion-poll-detail/motion-poll-detail.component'; +import { MotionPollRoutingModule } from './motion-poll-routing.module'; +import { MotionPollVoteComponent } from './motion-poll-vote/motion-poll-vote.component'; +import { MotionPollComponent } from './motion-poll/motion-poll.component'; + +@NgModule({ + imports: [CommonModule, SharedModule, MotionPollRoutingModule, PollsModule], + exports: [MotionPollComponent], + declarations: [MotionPollComponent, MotionPollDetailComponent, MotionPollVoteComponent] +}) +export class MotionPollModule {} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html new file mode 100644 index 000000000..ffe9db2b6 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html @@ -0,0 +1,140 @@ + + +
    + + + + + + + +
    + + +
    + + + + + + + {{ poll.typeVerbose | translate }} · + + + + + {{ poll.stateVerbose | translate }} + +
    + + +
    + +
    + + + + + + + + +
    + + + + + + + +
    +
    +
    + + {{ row.value[0].amount | parsePollNumber }} + + {{ row.value[0].amount | pollPercentBase: poll }} + + +
    +
    +
    + + +
    +
    + + +
    + + {{ 'Counting of votes is in progress ...' | translate }} + +
    +
    + + +
    + {{ 'Edit to enter votes.' | translate }} +
    +
    + + + + + + +
    + + + + + + +
    +
    diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss new file mode 100644 index 000000000..77539c2f7 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss @@ -0,0 +1,70 @@ +@import '~assets/styles/poll-colors.scss'; +@import '~assets/styles/poll-styles-common.scss'; + +.poll-link-wrapper { + outline: none; +} + +.motion-poll-wrapper { + margin-bottom: 30px; + + .poll-title-wrapper { + display: grid; + grid-gap: 10px; + grid-template-areas: 'title actions'; + grid-template-columns: auto min-content; + + .poll-title-area { + grid-area: title; + margin-top: 1em; + + .poll-title { + font-size: 125%; + } + } + + .poll-actions { + grid-area: actions; + } + } + + .poll-chart-wrapper { + cursor: pointer; + display: grid; + grid-gap: 20px; + margin: 2em; + // try to find max scale + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + + .doughnut-chart { + display: block; + max-width: 200px; + margin-top: auto; + margin-bottom: auto; + } + + .vote-legend { + div + div { + margin-top: 20px; + } + } + } +} + +.poll-detail-button-wrapper { + display: flex; + margin: auto 0; + > a { + margin-left: auto; + } +} + +.next-state-label { + margin-top: auto; + margin-bottom: auto; +} + +.motion-couting-in-progress-hint { + margin-top: 1em; + font-style: italic; +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss-theme.scss b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss-theme.scss new file mode 100644 index 000000000..58f63bc8c --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss-theme.scss @@ -0,0 +1,4 @@ +@import '~@angular/material/theming'; + +@mixin os-motion-poll-style($theme) { +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.spec.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.spec.ts new file mode 100644 index 000000000..2c4399234 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component'; +import { MotionPollVoteComponent } from '../motion-poll-vote/motion-poll-vote.component'; +import { MotionPollComponent } from './motion-poll.component'; + +describe('MotionPollComponent', () => { + let component: MotionPollComponent; + let fixture: ComponentFixture; + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionPollComponent, MotionPollVoteComponent, PollProgressComponent] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(MotionPollComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts new file mode 100644 index 000000000..4bcd7516a --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts @@ -0,0 +1,107 @@ +import { Component, Input } from '@angular/core'; +import { MatDialog, MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { OperatorService } from 'app/core/core-services/operator.service'; +import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { VotingPrivacyWarningComponent } from 'app/shared/components/voting-privacy-warning/voting-privacy-warning.component'; +import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service'; +import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service'; +import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; +import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; +import { PollService, PollTableData } from 'app/site/polls/services/poll.service'; + +/** + * Component to show a motion-poll. + */ +@Component({ + selector: 'os-motion-poll', + templateUrl: './motion-poll.component.html', + styleUrls: ['./motion-poll.component.scss'] +}) +export class MotionPollComponent extends BasePollComponent { + @Input() + public set poll(value: ViewMotionPoll) { + this.initPoll(value); + const chartData = this.pollService.generateChartData(value); + this.chartDataSubject.next(chartData); + } + + public get poll(): ViewMotionPoll { + return this._poll; + } + + public get pollLink(): string { + return `/motions/polls/${this.poll.id}`; + } + + public get showChart(): boolean { + return this.motionPollService.showChart(this.poll); + } + + public get reducedPollTableData(): PollTableData[] { + return this.motionPollService + .generateTableData(this.poll) + .filter(data => ['yes', 'no', 'abstain', 'votesinvalid'].includes(data.votingOption)); + } + + public get showPoll(): boolean { + if (this.poll) { + if ( + this.operator.hasPerms('motions.can_manage_polls') || + this.poll.isPublished || + (this.poll.isEVoting && !this.poll.isCreated) + ) { + return true; + } + } + return false; + } + + /** + * Constructor. + * + * @param title + * @param translate + * @param matSnackbar + * @param router + * @param motionRepo + */ + public constructor( + titleService: Title, + matSnackBar: MatSnackBar, + protected translate: TranslateService, + dialog: MatDialog, + promptService: PromptService, + public pollRepo: MotionPollRepositoryService, + pollDialog: MotionPollDialogService, + public pollService: PollService, + private pdfService: MotionPollPdfService, + private motionPollService: MotionPollService, + private operator: OperatorService + ) { + super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog); + } + + public openVotingWarning(): void { + this.dialog.open(VotingPrivacyWarningComponent, infoDialogSettings); + } + + public downloadPdf(): void { + this.pdfService.printBallots(this.poll); + } + + public async deletePoll(): Promise { + const title = this.translate.instant('Are you sure you want to delete this vote?'); + const content = this.poll.getTitle(); + + if (await this.promptService.open(title, content)) { + this.repo.delete(this.poll).catch(this.raiseError); + } + } +} diff --git a/client/src/app/site/motions/modules/motion-workflow/components/workflow-detail/workflow-detail.component.ts b/client/src/app/site/motions/modules/motion-workflow/components/workflow-detail/workflow-detail.component.ts index 7a490dea5..5ef0902fd 100644 --- a/client/src/app/site/motions/modules/motion-workflow/components/workflow-detail/workflow-detail.component.ts +++ b/client/src/app/site/motions/modules/motion-workflow/components/workflow-detail/workflow-detail.component.ts @@ -146,7 +146,7 @@ export class WorkflowDetailComponent extends BaseViewComponent implements OnInit * @param title Set the page title * @param translate Handle translations * @param matSnackBar Showing error - * @param promtService Promts + * @param promptService Promts * @param dialog Opening dialogs * @param workflowRepo The repository for workflows * @param route Read out URL paramters @@ -155,7 +155,7 @@ export class WorkflowDetailComponent extends BaseViewComponent implements OnInit title: Title, protected translate: TranslateService, // protected required for ng-translate-extract matSnackBar: MatSnackBar, - private promtService: PromptService, + private promptService: PromptService, private dialog: MatDialog, private workflowRepo: WorkflowRepositoryService, private stateRepo: StateRepositoryService, @@ -208,7 +208,7 @@ export class WorkflowDetailComponent extends BaseViewComponent implements OnInit } else if (result.action === 'delete') { const content = this.translate.instant('Delete') + ` ${state.name}?`; - this.promtService.open('Are you sure', content).then(promptResult => { + this.promptService.open('Are you sure', content).then(promptResult => { if (promptResult) { this.stateRepo.delete(state).then(() => {}, this.raiseError); } diff --git a/client/src/app/site/motions/modules/motion-workflow/components/workflow-list/workflow-list.component.html b/client/src/app/site/motions/modules/motion-workflow/components/workflow-list/workflow-list.component.html index 5961d3d46..8b3b21b93 100644 --- a/client/src/app/site/motions/modules/motion-workflow/components/workflow-list/workflow-list.component.html +++ b/client/src/app/site/motions/modules/motion-workflow/components/workflow-list/workflow-list.component.html @@ -4,7 +4,7 @@ import('./modules/amendment-list/amendment-list.module').then(m => m.AmendmentListModule), data: { basePerm: 'motions.can_see' } }, + { + path: 'polls', + loadChildren: () => import('./modules/motion-poll/motion-poll.module').then(m => m.MotionPollModule), + data: { basePerm: 'motions.can_see' } + }, { path: ':id', loadChildren: () => import('./modules/motion-detail/motion-detail.module').then(m => m.MotionDetailModule), diff --git a/client/src/app/site/motions/motions.config.ts b/client/src/app/site/motions/motions.config.ts index 6d3633403..e2cad9b83 100644 --- a/client/src/app/site/motions/motions.config.ts +++ b/client/src/app/site/motions/motions.config.ts @@ -3,10 +3,16 @@ import { CategoryRepositoryService } from 'app/core/repositories/motions/categor import { ChangeRecommendationRepositoryService } from 'app/core/repositories/motions/change-recommendation-repository.service'; import { MotionBlockRepositoryService } from 'app/core/repositories/motions/motion-block-repository.service'; import { MotionCommentSectionRepositoryService } from 'app/core/repositories/motions/motion-comment-section-repository.service'; +import { MotionOptionRepositoryService } from 'app/core/repositories/motions/motion-option-repository.service'; +import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; +import { MotionVoteRepositoryService } from 'app/core/repositories/motions/motion-vote-repository.service'; import { StateRepositoryService } from 'app/core/repositories/motions/state-repository.service'; import { StatuteParagraphRepositoryService } from 'app/core/repositories/motions/statute-paragraph-repository.service'; import { WorkflowRepositoryService } from 'app/core/repositories/motions/workflow-repository.service'; +import { MotionOption } from 'app/shared/models/motions/motion-option'; +import { MotionPoll } from 'app/shared/models/motions/motion-poll'; +import { MotionVote } from 'app/shared/models/motions/motion-vote'; import { State } from 'app/shared/models/motions/state'; import { Category } from '../../shared/models/motions/category'; import { Motion } from '../../shared/models/motions/motion'; @@ -19,6 +25,9 @@ import { ViewMotion } from './models/view-motion'; import { ViewMotionBlock } from './models/view-motion-block'; import { ViewMotionChangeRecommendation } from './models/view-motion-change-recommendation'; import { ViewMotionCommentSection } from './models/view-motion-comment-section'; +import { ViewMotionOption } from './models/view-motion-option'; +import { ViewMotionPoll } from './models/view-motion-poll'; +import { ViewMotionVote } from './models/view-motion-vote'; import { ViewState } from './models/view-state'; import { ViewStatuteParagraph } from './models/view-statute-paragraph'; import { ViewWorkflow } from './models/view-workflow'; @@ -28,57 +37,52 @@ export const MotionsAppConfig: AppConfig = { name: 'motions', models: [ { - collectionString: 'motions/motion', model: Motion, viewModel: ViewMotion, searchOrder: 2, repository: MotionRepositoryService }, { - collectionString: 'motions/category', model: Category, viewModel: ViewCategory, searchOrder: 6, repository: CategoryRepositoryService }, { - collectionString: 'motions/workflow', model: Workflow, viewModel: ViewWorkflow, repository: WorkflowRepositoryService }, { - collectionString: 'motions/state', model: State, viewModel: ViewState, repository: StateRepositoryService }, { - collectionString: 'motions/motion-comment-section', model: MotionCommentSection, viewModel: ViewMotionCommentSection, repository: MotionCommentSectionRepositoryService }, { - collectionString: 'motions/motion-change-recommendation', model: MotionChangeRecommendation, viewModel: ViewMotionChangeRecommendation, repository: ChangeRecommendationRepositoryService }, { - collectionString: 'motions/motion-block', model: MotionBlock, viewModel: ViewMotionBlock, searchOrder: 7, repository: MotionBlockRepositoryService }, { - collectionString: 'motions/statute-paragraph', model: StatuteParagraph, viewModel: ViewStatuteParagraph, searchOrder: 9, repository: StatuteParagraphRepositoryService - } + }, + { model: MotionPoll, viewModel: ViewMotionPoll, repository: MotionPollRepositoryService }, + { model: MotionOption, viewModel: ViewMotionOption, repository: MotionOptionRepositoryService }, + { model: MotionVote, viewModel: ViewMotionVote, repository: MotionVoteRepositoryService } ], mainMenuEntries: [ { diff --git a/client/src/app/site/motions/services/motion-pdf.service.ts b/client/src/app/site/motions/services/motion-pdf.service.ts index 428099d76..fd4310cba 100644 --- a/client/src/app/site/motions/services/motion-pdf.service.ts +++ b/client/src/app/site/motions/services/motion-pdf.service.ts @@ -10,8 +10,10 @@ import { MotionRepositoryService } from 'app/core/repositories/motions/motion-re import { StatuteParagraphRepositoryService } from 'app/core/repositories/motions/statute-paragraph-repository.service'; import { ConfigService } from 'app/core/ui-services/config.service'; import { LinenumberingService } from 'app/core/ui-services/linenumbering.service'; -import { CalculablePollKey } from 'app/core/ui-services/poll.service'; import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change'; +import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe'; +import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; +import { PollPercentBasePipe } from 'app/shared/pipes/poll-percent-base.pipe'; import { getRecommendationTypeName } from 'app/shared/utils/recommendation-type-names'; import { MotionExportInfo } from './motion-export.service'; import { MotionPollService } from './motion-poll.service'; @@ -62,9 +64,12 @@ export class MotionPdfService { private configService: ConfigService, private pdfDocumentService: PdfDocumentService, private htmlToPdfService: HtmlToPdfService, - private pollService: MotionPollService, private linenumberingService: LinenumberingService, - private commentRepo: MotionCommentSectionRepositoryService + private commentRepo: MotionCommentSectionRepositoryService, + private pollKeyVerbose: PollKeyVerbosePipe, + private pollPercentBase: PollPercentBasePipe, + private parsePollNumber: ParsePollNumberPipe, + private motionPollService: MotionPollService ) {} /** @@ -361,33 +366,26 @@ export class MotionPdfService { } // voting results - if (motion.motion.polls.length && (!infoToExport || infoToExport.includes('polls'))) { + if (motion.polls.length && (!infoToExport || infoToExport.includes('polls'))) { const column1 = []; const column2 = []; const column3 = []; - motion.motion.polls.map((poll, index) => { - if (poll.has_votes) { - if (motion.motion.polls.length > 1) { - column1.push(index + 1 + '. ' + this.translate.instant('Vote')); - column2.push(''); - column3.push(''); - } - const values: CalculablePollKey[] = ['yes', 'no', 'abstain']; - if (poll.votesvalid) { - values.push('votesvalid'); - } - if (poll.votesinvalid) { - values.push('votesinvalid'); - } - if (poll.votescast) { - values.push('votescast'); - } - values.map(value => { - column1.push(`${this.translate.instant(this.pollService.getLabel(value))}:`); - column2.push(`${this.translate.instant(this.pollService.getSpecialLabel(poll[value]))}`); - this.pollService.isAbstractValue(poll, value) - ? column3.push('') - : column3.push(`(${this.pollService.calculatePercentage(poll, value)} %)`); + motion.polls.forEach(poll => { + if (poll.hasVotes) { + const tableData = this.motionPollService.generateTableData(poll); + + tableData.forEach(votingResult => { + const votingOption = this.translate.instant( + this.pollKeyVerbose.transform(votingResult.votingOption) + ); + const value = votingResult.value[0]; + const resultValue = this.parsePollNumber.transform(value.amount); + column1.push(`${votingOption}:`); + column2.push(resultValue); + if (value.showPercent) { + const resultInPercent = this.pollPercentBase.transform(value.amount, poll); + column3.push(resultInPercent); + } }); } }); @@ -656,15 +654,6 @@ export class MotionPdfService { margin: [0, 25, 0, 10] }); - // determine the width of the reason depending on line numbering - // currently not used - // let columnWidth: string; - // if (lnMode === LineNumberingMode.Outside) { - // columnWidth = '80%'; - // } else { - // columnWidth = '100%'; - // } - reason.push(this.htmlToPdfService.addPlainText(motion.reason)); return reason; diff --git a/client/src/app/site/motions/services/motion-poll-dialog.service.spec.ts b/client/src/app/site/motions/services/motion-poll-dialog.service.spec.ts new file mode 100644 index 000000000..3dc9f384f --- /dev/null +++ b/client/src/app/site/motions/services/motion-poll-dialog.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionPollDialogService } from './motion-poll-dialog.service'; + +describe('MotionPollDialogService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: MotionPollDialogService = TestBed.get(MotionPollDialogService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/services/motion-poll-dialog.service.ts b/client/src/app/site/motions/services/motion-poll-dialog.service.ts new file mode 100644 index 000000000..8e83ced21 --- /dev/null +++ b/client/src/app/site/motions/services/motion-poll-dialog.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material'; + +import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; +import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; +import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component'; +import { ViewMotionPoll } from '../models/view-motion-poll'; + +/** + * Subclassed to provide the right `PollService` and `DialogComponent` + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionPollDialogService extends BasePollDialogService { + protected dialogComponent = MotionPollDialogComponent; + + public constructor(dialog: MatDialog, mapper: CollectionStringMapperService) { + super(dialog, mapper); + } +} diff --git a/client/src/app/site/motions/services/motion-poll-pdf.service.ts b/client/src/app/site/motions/services/motion-poll-pdf.service.ts index a67998eda..228f3ecad 100644 --- a/client/src/app/site/motions/services/motion-poll-pdf.service.ts +++ b/client/src/app/site/motions/services/motion-poll-pdf.service.ts @@ -17,7 +17,7 @@ type BallotCountChoices = 'NUMBER_OF_DELEGATES' | 'NUMBER_OF_ALL_PARTICIPANTS' | * * @example * ```ts - * this.MotionPollPdfService.printBallos(this.poll); + * this.MotionPollPdfService.printBallots(this.poll); * ``` */ @Injectable({ @@ -71,8 +71,8 @@ export class MotionPollPdfService extends PollPdfService { )}`; if (!title) { title = `${this.translate.instant('Motion')} - ${motion.identifier}`; - if (motion.motion.polls.length > 1) { - title += ` (${this.translate.instant('Vote')} ${motion.motion.polls.length})`; + if (motion.polls.length > 1) { + title += ` (${this.translate.instant('Vote')} ${motion.polls.length})`; } } if (!subtitle) { diff --git a/client/src/app/site/motions/services/motion-poll.service.spec.ts b/client/src/app/site/motions/services/motion-poll.service.spec.ts index 53e999f3f..afd571ad8 100644 --- a/client/src/app/site/motions/services/motion-poll.service.spec.ts +++ b/client/src/app/site/motions/services/motion-poll.service.spec.ts @@ -1,9 +1,12 @@ import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; import { MotionPollService } from './motion-poll.service'; -describe('PollService', () => { - beforeEach(() => TestBed.configureTestingModule({})); +describe('MotionPollService', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule, RouterTestingModule] })); it('should be created', () => { const service: MotionPollService = TestBed.get(MotionPollService); diff --git a/client/src/app/site/motions/services/motion-poll.service.ts b/client/src/app/site/motions/services/motion-poll.service.ts index 87e91b343..f96f0acc1 100644 --- a/client/src/app/site/motions/services/motion-poll.service.ts +++ b/client/src/app/site/motions/services/motion-poll.service.ts @@ -1,8 +1,23 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { ConstantsService } from 'app/core/core-services/constants.service'; +import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { ConfigService } from 'app/core/ui-services/config.service'; -import { CalculablePollKey, PollMajorityMethod, PollService } from 'app/core/ui-services/poll.service'; -import { MotionPoll } from 'app/shared/models/motions/motion-poll'; +import { MotionPoll, MotionPollMethod } from 'app/shared/models/motions/motion-poll'; +import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll'; +import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe'; +import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; +import { PollData, PollService, PollTableData, VotingResult } from 'app/site/polls/services/poll.service'; +import { ViewMotionOption } from '../models/view-motion-option'; +import { ViewMotionPoll } from '../models/view-motion-poll'; + +interface PollResultData { + yes?: number; + no?: number; + abstain?: number; +} /** * Service class for motion polls. @@ -12,152 +27,132 @@ import { MotionPoll } from 'app/shared/models/motions/motion-poll'; }) export class MotionPollService extends PollService { /** - * list of poll keys that are numbers and can be part of a quorum calculation + * The default percentage base */ - public pollValues: CalculablePollKey[] = ['yes', 'no', 'abstain', 'votesvalid', 'votesinvalid', 'votescast']; + public defaultPercentBase: PercentBase; + + /** + * The default majority method + */ + public defaultMajorityMethod: MajorityMethod; + + public defaultGroupIds: number[]; /** * Constructor. Subscribes to the configuration values needed * @param config ConfigService */ - public constructor(config: ConfigService) { - super(); - config.get('motions_poll_100_percent_base').subscribe(base => (this.percentBase = base)); + public constructor( + config: ConfigService, + constants: ConstantsService, + pollKeyVerbose: PollKeyVerbosePipe, + parsePollNumber: ParsePollNumberPipe, + protected translate: TranslateService, + private pollRepo: MotionPollRepositoryService + ) { + super(constants, translate, pollKeyVerbose, parsePollNumber); config - .get('motions_poll_default_majority_method') + .get('motion_poll_default_100_percent_base') + .subscribe(base => (this.defaultPercentBase = base)); + config + .get('motion_poll_default_majority_method') .subscribe(method => (this.defaultMajorityMethod = method)); + + config.get(MotionPoll.defaultGroupsConfig).subscribe(ids => (this.defaultGroupIds = ids)); } - /** - * Calculates the percentage the given key reaches. - * - * @param poll - * @param key - * @returns a percentage number with two digits, null if the value cannot be calculated (consider 0 !== null) - */ - public calculatePercentage(poll: MotionPoll, key: CalculablePollKey): number | null { - const baseNumber = this.getBaseAmount(poll); - if (!baseNumber) { - return null; + public getDefaultPollData(contextId?: number): MotionPoll { + const poll = new MotionPoll(super.getDefaultPollData()); + + poll.title = this.translate.instant('Vote'); + poll.pollmethod = MotionPollMethod.YNA; + + if (contextId) { + const length = this.pollRepo.getViewModelList().filter(item => item.motion_id === contextId).length; + if (length) { + poll.title += ` (${length + 1})`; + } } - switch (key) { - case 'abstain': - if (this.percentBase === 'YES_NO') { - return null; - } - break; - case 'votesinvalid': - if (this.percentBase !== 'CAST') { - return null; - } - break; - case 'votesvalid': - if (!['CAST', 'VALID'].includes(this.percentBase)) { - return null; - } - break; - case 'votescast': - if (this.percentBase !== 'CAST') { - return null; - } - } - return Math.round(((poll[key] * 100) / baseNumber) * 100) / 100; + + return poll; } - /** - * Gets the number representing 100 percent for a given MotionPoll, depending - * on the configuration and the votes given. - * - * @param poll - * @returns the positive number representing 100 percent of the poll, 0 if - * the base cannot be calculated - */ - public getBaseAmount(poll: MotionPoll): number { - if (!poll) { - return 0; - } - switch (this.percentBase) { - case 'CAST': - if (!poll.votescast) { - return 0; - } - if (poll.votesinvalid < 0) { - return 0; - } - return poll.votescast; - case 'VALID': - if (poll.yes < 0 || poll.no < 0 || poll.abstain < 0) { - return 0; - } - return poll.votesvalid ? poll.votesvalid : 0; - case 'YES_NO_ABSTAIN': - if (poll.yes < 0 || poll.no < 0 || poll.abstain < 0) { - return 0; - } - return poll.yes + poll.no + poll.abstain; - case 'YES_NO': - if (poll.yes < 0 || poll.no < 0 || poll.abstain === -1) { - // It is not allowed to set 'Abstain' to 'majority' but exclude it from calculation. - // Setting 'Abstain' to 'undocumented' is possible, of course. - return 0; - } - return poll.yes + poll.no; - } + public generateTableData(poll: PollData | ViewMotionPoll): PollTableData[] { + let tableData: PollTableData[] = poll.options.flatMap(vote => + super.getVoteTableKeys(poll).map(key => this.createTableDataEntry(poll, key, vote)) + ); + tableData.push(...super.getSumTableKeys(poll).map(key => this.createTableDataEntry(poll, key))); + + tableData = tableData.filter(localeTableData => !localeTableData.value.some(result => result.hide)); + return tableData; } - /** - * Calculates which number is needed for the quorum to be surpassed - * TODO: Methods still hard coded to mirror the server's. - * - * @param poll - * @param method (optional) majority calculation method. If none is given, - * the default as set in the config will be used. - * @returns the first integer number larger than the required majority, - * undefined if a quorum cannot be calculated. - */ - public calculateQuorum(poll: MotionPoll, method?: string): number { - if (!method) { - method = this.defaultMajorityMethod; - } - const baseNumber = this.getBaseAmount(poll); - if (!baseNumber) { - return undefined; - } - const calc = PollMajorityMethod.find(m => m.value === method); - return calc && calc.calc ? calc.calc(baseNumber) : null; + private createTableDataEntry( + poll: PollData | ViewMotionPoll, + result: VotingResult, + vote?: ViewMotionOption + ): PollTableData { + return { + votingOption: result.vote, + value: [ + { + amount: vote ? vote[result.vote] : poll[result.vote], + hide: result.hide, + icon: result.icon, + showPercent: result.showPercent + } + ] + }; } - /** - * Determines if a value is abstract (percentages cannot be calculated) - * - * @param poll - * @param value - * @returns true if the percentages should not be calculated - */ - public isAbstractValue(poll: MotionPoll, value: CalculablePollKey): boolean { - if (this.getBaseAmount(poll) === 0) { - return true; - } - switch (this.percentBase) { - case 'YES_NO': - if (['votescast', 'votesinvalid', 'votesvalid', 'abstain'].includes(value)) { - return true; + public showChart(poll: PollData): boolean { + return poll && poll.options && poll.options.some(option => option.yes >= 0 && option.no >= 0); + } + + public getPercentBase(poll: PollData): number { + const base: PercentBase = poll.onehundred_percent_base as PercentBase; + + let totalByBase: number; + const result = poll.options[0]; + switch (base) { + case PercentBase.YN: + if (result.yes >= 0 && result.no >= 0) { + totalByBase = this.sumYN(result); } break; - case 'YES_NO_ABSTAIN': - if (['votescast', 'votesinvalid', 'votesvalid'].includes(value)) { - return true; + case PercentBase.YNA: + if (result.yes >= 0 && result.no >= 0 && result.abstain >= 0) { + totalByBase = this.sumYNA(result); } break; - case 'VALID': - if (['votesinvalid', 'votescast'].includes(value)) { - return true; + case PercentBase.Valid: + // auslagern + if (result.yes >= 0 && result.no >= 0 && result.abstain >= 0) { + totalByBase = poll.votesvalid; } break; + case PercentBase.Cast: + totalByBase = poll.votescast; + break; + case PercentBase.Disabled: + break; + default: + throw new Error('The given poll has no percent base: ' + this); } - if (poll[value] < 0) { - return true; - } - return false; + + return totalByBase; + } + + private sumYN(result: PollResultData): number { + let sum = 0; + sum += result.yes > 0 ? result.yes : 0; + sum += result.no > 0 ? result.no : 0; + return sum; + } + + private sumYNA(result: PollResultData): number { + let sum = this.sumYN(result); + sum += result.abstain > 0 ? result.abstain : 0; + return sum; } } diff --git a/client/src/app/site/polls/components/base-poll-detail.component.ts b/client/src/app/site/polls/components/base-poll-detail.component.ts new file mode 100644 index 000000000..ed4400d34 --- /dev/null +++ b/client/src/app/site/polls/components/base-poll-detail.component.ts @@ -0,0 +1,217 @@ +import { OnInit } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; +import { Label } from 'ng2-charts'; +import { BehaviorSubject, from, Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; + +import { Deferred } from 'app/core/promises/deferred'; +import { BaseRepository } from 'app/core/repositories/base-repository'; +import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; +import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { ChartData } from 'app/shared/components/charts/charts.component'; +import { BaseVote } from 'app/shared/models/poll/base-vote'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BasePollRepositoryService } from '../services/base-poll-repository.service'; +import { PollService } from '../services/poll.service'; +import { ViewBasePoll } from '../models/view-base-poll'; +import { ViewBaseVote } from '../models/view-base-vote'; + +export interface BaseVoteData { + user?: ViewUser; +} + +export abstract class BasePollDetailComponent extends BaseViewComponent + implements OnInit { + /** + * All the groups of users. + */ + public userGroups: ViewGroup[] = []; + + /** + * Holding all groups. + */ + public groupObservable: Observable = null; + + /** + * Details for the iconification of the votes + */ + public voteOptionStyle = { + Y: { + css: 'yes', + icon: 'thumb_up' + }, + N: { + css: 'no', + icon: 'thumb_down' + }, + A: { + css: 'abstain', + icon: 'trip_origin' + } + }; + + /** + * The reference to the poll. + */ + public poll: V = null; + + /** + * The different labels for the votes (used for chart). + */ + public labels: Label[] = []; + + /** + * Subject, that holds the data for the chart. + */ + public chartDataSubject: BehaviorSubject = new BehaviorSubject(null); + + // The observable for the votes-per-user table + public votesDataObservable: Observable; + + protected optionsLoaded = new Deferred(); + + /** + * Constructor + * + * @param title + * @param translate + * @param matSnackbar + * @param repo + * @param route + * @param router + * @param fb + * @param groupRepo + * @param location + * @param promptService + * @param dialog + */ + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + protected repo: BasePollRepositoryService, + protected route: ActivatedRoute, + protected groupRepo: GroupRepositoryService, + protected promptService: PromptService, + protected pollDialog: BasePollDialogService, + protected pollService: S, + protected votesRepo: BaseRepository + ) { + super(title, translate, matSnackbar); + this.setup(); + } + + private async setup(): Promise { + await this.optionsLoaded; + + this.votesRepo + .getViewModelListObservable() + .pipe( + filter(() => this.poll && this.canSeeVotes), // filter first for valid poll state to avoid unneccessary iteration of potentially thousands of votes + map(votes => votes.filter(vote => vote.option.poll_id === this.poll.id)), + filter(votes => !!votes.length) + ) + .subscribe(() => { + this.createVotesData(); + }); + } + + /** + * OnInit-method. + */ + public ngOnInit(): void { + this.findComponentById(); + + this.groupObservable = this.groupRepo.getViewModelListObservable(); + this.subscriptions.push( + this.groupRepo.getViewModelListObservable().subscribe(groups => (this.userGroups = groups)) + ); + } + + public async deletePoll(): Promise { + const title = this.translate.instant('Are you sure you want to delete this vote?'); + if (await this.promptService.open(title)) { + this.repo.delete(this.poll).then(() => this.onDeleted(), this.raiseError); + } + } + + public async pseudoanonymizePoll(): Promise { + const title = this.translate.instant('Are you sure you want to anonymize all votes? This cannot be undone.'); + if (await this.promptService.open(title)) { + this.repo.pseudoanonymize(this.poll).then(() => this.onPollLoaded(), this.raiseError); // votes have changed, but not the poll, so the components have to be informed about the update + } + } + + /** + * Opens dialog for editing the poll + */ + public openDialog(viewPoll: V): void { + this.pollDialog.openDialog(viewPoll); + } + + /** + * Called after the poll has been loaded. Meant to be overwritten by subclasses who need initial access to the poll + */ + protected onPollLoaded(): void {} + + protected onStateChanged(): void {} + + protected abstract hasPerms(): boolean; + + protected abstract onDeleted(): void; + + protected get canSeeVotes(): boolean { + return (this.hasPerms && this.poll.isFinished) || this.poll.isPublished; + } + + /** + * sets the votes data only if the poll wasn't pseudoanonymized + */ + protected setVotesData(data: BaseVoteData[]): void { + if (data.every(voteDate => !voteDate.user)) { + this.votesDataObservable = null; + } else { + this.votesDataObservable = from([data]); + } + } + + /** + * Is called when the underlying vote data changes. Is supposed to call setVotesData + */ + protected abstract createVotesData(): void; + + /** + * Initializes data for the shown chart. + * Could be overwritten to implement custom chart data. + */ + protected initChartData(): void { + this.chartDataSubject.next(this.pollService.generateChartData(this.poll)); + } + + /** + * Helper-function to search for this poll and display data or create a new one. + */ + private findComponentById(): void { + const params = this.route.snapshot.params; + if (params && params.id) { + this.subscriptions.push( + this.repo.getViewModelObservable(params.id).subscribe(poll => { + if (poll) { + this.poll = poll; + this.onPollLoaded(); + this.createVotesData(); + this.initChartData(); + this.optionsLoaded.resolve(); + } + }) + ); + } + } +} diff --git a/client/src/app/site/polls/components/base-poll-dialog.component.ts b/client/src/app/site/polls/components/base-poll-dialog.component.ts new file mode 100644 index 000000000..99212eebd --- /dev/null +++ b/client/src/app/site/polls/components/base-poll-dialog.component.ts @@ -0,0 +1,125 @@ +import { OnInit } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { MatSnackBar } from '@angular/material'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { VOTE_UNDOCUMENTED } from 'app/shared/models/poll/base-poll'; +import { OneOfValidator } from 'app/shared/validators/one-of-validator'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { PollFormComponent } from './poll-form/poll-form.component'; +import { ViewBasePoll } from '../models/view-base-poll'; + +/** + * A dialog for updating the values of a poll. + */ +export abstract class BasePollDialogComponent extends BaseViewComponent implements OnInit { + public publishImmediately: boolean; + + protected pollForm: PollFormComponent; + + public dialogVoteForm: FormGroup; + + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + public dialogRef: MatDialogRef> + ) { + super(title, translate, matSnackbar); + } + + public ngOnInit(): void { + if (this.dialogRef) { + // Jasmin/Karma fails here. TODO: + this.dialogRef.keydownEvents().subscribe((event: KeyboardEvent) => { + if (event.key === 'Enter' && event.shiftKey) { + this.submitPoll(); + } + + if (event.key === 'Escape') { + this.dialogRef.close(); + } + }); + } + } + + /** + * Submits the values from dialog. + */ + public submitPoll(): void { + const answer = { + ...this.pollForm.getValues(), + votes: this.getVoteData(), + publish_immediately: this.publishImmediately + }; + this.dialogRef.close(answer); + } + + /** + * Handles the state-change of the checkbox `Publish immediately`. + * + * If it is checked, at least one of the fields have to be filled. + * + * @param checked The next state. + */ + public publishStateChanged(checked: boolean): void { + if (checked) { + this.dialogVoteForm.setValidators(OneOfValidator.validation(...Object.keys(this.dialogVoteForm.controls))); + } else { + this.dialogVoteForm.setValidators(null); + } + } + + public getVoteData(): object { + if (this.isVoteDataEmpty(this.dialogVoteForm.value)) { + return undefined; + } + return this.replaceEmptyValues(this.dialogVoteForm.value); + } + + /** + * check recursively whether the given vote data object is empty, meaning all values would + * be VOTE_UNDOCUMENTED when sent + * + * @param voteData the (partial) vote data + */ + private isVoteDataEmpty(voteData: object): boolean { + return Object.values(voteData).every( + value => !value || (typeof value === 'object' && this.isVoteDataEmpty(value)) + ); + } + + /** + * iterates over the given data and returns a new object with all empty fields recursively + * replaced with VOTE_UNDOCUMENTED + * @param voteData the (partial) data + */ + private replaceEmptyValues(voteData: object, undo: boolean = false): object { + const result = {}; + for (const key of Object.keys(voteData)) { + if (typeof voteData[key] === 'object' && voteData[key]) { + result[key] = this.replaceEmptyValues(voteData[key], undo); + } else { + if (undo) { + result[key] = voteData[key] === VOTE_UNDOCUMENTED ? null : voteData[key]; + } else { + result[key] = !!voteData[key] ? voteData[key] : VOTE_UNDOCUMENTED; + } + } + } + return result; + } + + /** + * reverses the replacement of empty values by VOTE_UNDOCUMENTED; replaces each + * VOTE_UNDOCUMENTED with null + * + * @param voteData the vote data + */ + protected undoReplaceEmptyValues(voteData: object): object { + return this.replaceEmptyValues(voteData, true); + } +} diff --git a/client/src/app/site/polls/components/base-poll-vote.component.ts b/client/src/app/site/polls/components/base-poll-vote.component.ts new file mode 100644 index 000000000..7e2df88ac --- /dev/null +++ b/client/src/app/site/polls/components/base-poll-vote.component.ts @@ -0,0 +1,35 @@ +import { Input } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { OperatorService } from 'app/core/core-services/operator.service'; +import { VotingError } from 'app/core/ui-services/voting.service'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { ViewBasePoll } from '../models/view-base-poll'; + +export abstract class BasePollVoteComponent extends BaseViewComponent { + @Input() + public poll: V; + + public votingErrors = VotingError; + + protected user: ViewUser; + + public constructor( + title: Title, + translate: TranslateService, + matSnackbar: MatSnackBar, + protected operator: OperatorService + ) { + super(title, translate, matSnackbar); + + this.subscriptions.push( + this.operator.getViewUserObservable().subscribe(user => { + this.user = user; + }) + ); + } +} diff --git a/client/src/app/site/polls/components/base-poll.component.ts b/client/src/app/site/polls/components/base-poll.component.ts new file mode 100644 index 000000000..6bb93a1c9 --- /dev/null +++ b/client/src/app/site/polls/components/base-poll.component.ts @@ -0,0 +1,91 @@ +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; + +import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { ChartData } from 'app/shared/components/charts/charts.component'; +import { PollState, PollType } from 'app/shared/models/poll/base-poll'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { BasePollRepositoryService } from '../services/base-poll-repository.service'; +import { ViewBasePoll } from '../models/view-base-poll'; + +export abstract class BasePollComponent extends BaseViewComponent { + public chartDataSubject: BehaviorSubject = new BehaviorSubject([]); + + protected _poll: V; + + public pollStateActions = { + [PollState.Created]: { + icon: 'play_arrow', + css: 'start-poll-button' + }, + [PollState.Started]: { + icon: 'stop', + css: 'stop-poll-button' + }, + [PollState.Finished]: { + icon: 'public', + css: 'publish-poll-button' + } + }; + + public get hideChangeState(): boolean { + return this._poll.isPublished || (this._poll.isCreated && this._poll.type === PollType.Analog); + } + + public constructor( + titleService: Title, + matSnackBar: MatSnackBar, + protected translate: TranslateService, + public dialog: MatDialog, + protected promptService: PromptService, + protected repo: BasePollRepositoryService, + protected pollDialog: BasePollDialogService + ) { + super(titleService, translate, matSnackBar); + } + + public async changeState(key: PollState): Promise { + if (key === PollState.Created) { + const title = this.translate.instant('Are you sure you want to reset this vote?'); + const content = this.translate.instant('All votes will be lost.'); + if (await this.promptService.open(title, content)) { + this.repo.resetPoll(this._poll).catch(this.raiseError); + } + } else { + this.repo.changePollState(this._poll).catch(this.raiseError); + } + } + + public resetState(): void { + this.changeState(PollState.Created); + } + + /** + * Handler for the 'delete poll' button + */ + public async onDeletePoll(): Promise { + const title = this.translate.instant('Are you sure you want to delete this vote?'); + if (await this.promptService.open(title)) { + await this.repo.delete(this._poll).catch(this.raiseError); + } + } + + /** + * Edits the poll + */ + public openDialog(): void { + this.pollDialog.openDialog(this._poll); + } + + /** + * Forces to initialize the poll. + */ + protected initPoll(model: V): void { + this._poll = model; + } +} diff --git a/client/src/app/site/polls/components/poll-form/poll-form.component.html b/client/src/app/site/polls/components/poll-form/poll-form.component.html new file mode 100644 index 000000000..443f66cd3 --- /dev/null +++ b/client/src/app/site/polls/components/poll-form/poll-form.component.html @@ -0,0 +1,96 @@ +
    + +
    + +

    + +

    +
    +
    + + +
    + + + {{ value[0] }} + + + {{ value[1] }} + + +
    +
    + + + + + + {{ option.value | translate }} + + + This field is required. + {{ 'Not suitable for formal secret voting!' | translate }} + + + + + + + + + + + + {{ option.value | translate }} + + + This field is required. + + + + + + + + {{ option.value | translate }} + + + + + + + + + + {{ PollPropertyVerbose.global_no | translate }} + {{ + PollPropertyVerbose.global_abstain | translate + }} + +
    +
    diff --git a/client/src/app/site/polls/components/poll-form/poll-form.component.scss b/client/src/app/site/polls/components/poll-form/poll-form.component.scss new file mode 100644 index 000000000..e219f1795 --- /dev/null +++ b/client/src/app/site/polls/components/poll-form/poll-form.component.scss @@ -0,0 +1,39 @@ +.poll-preview-title { + margin: 0; +} + +.pollType { + .mat-hint { + color: red; + cursor: pointer; + } +} + +.poll-preview-meta-info { + display: flex; + justify-content: space-between; + margin: 10px 0; + + .short-description { + flex: 1; + padding: 0 5px; + display: inline-block; + span { + display: block; + } + &-label { + font-size: 75%; + } + } +} + +.poll-preview-meta-info-form { + display: flex; + align-items: center; + flex-wrap: wrap; + + & > * { + flex: 1; + margin: 0 4px; + } +} diff --git a/client/src/app/site/polls/components/poll-form/poll-form.component.spec.ts b/client/src/app/site/polls/components/poll-form/poll-form.component.spec.ts new file mode 100644 index 000000000..2131a3729 --- /dev/null +++ b/client/src/app/site/polls/components/poll-form/poll-form.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollFormComponent } from './poll-form.component'; + +describe('PollFormComponent', () => { + let component: PollFormComponent; + let fixture: ComponentFixture>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PollFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/polls/components/poll-form/poll-form.component.ts b/client/src/app/site/polls/components/poll-form/poll-form.component.ts new file mode 100644 index 000000000..1640f47af --- /dev/null +++ b/client/src/app/site/polls/components/poll-form/poll-form.component.ts @@ -0,0 +1,281 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatDialog, MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + +import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; +import { ConfigService } from 'app/core/ui-services/config.service'; +import { VotingPrivacyWarningComponent } from 'app/shared/components/voting-privacy-warning/voting-privacy-warning.component'; +import { AssignmentPollMethod, AssignmentPollPercentBase } from 'app/shared/models/assignments/assignment-poll'; +import { PercentBase } from 'app/shared/models/poll/base-poll'; +import { PollType } from 'app/shared/models/poll/base-poll'; +import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { + MajorityMethodVerbose, + PollClassType, + PollPropertyVerbose, + PollTypeVerbose, + ViewBasePoll +} from 'app/site/polls/models/view-base-poll'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { PollService } from '../../services/poll.service'; + +@Component({ + selector: 'os-poll-form', + templateUrl: './poll-form.component.html', + styleUrls: ['./poll-form.component.scss'] +}) +export class PollFormComponent extends BaseViewComponent implements OnInit { + /** + * The form-group for the meta-info. + */ + public contentForm: FormGroup; + + public PollType = PollType; + public PollPropertyVerbose = PollPropertyVerbose; + + /** + * The different methods for this poll. + */ + @Input() + public pollMethods: { [key: string]: string }; + + /** + * The different percent bases for this poll. + */ + @Input() + public percentBases: { [key: string]: string }; + + @Input() + public data: Partial; + + /** + * The different types the poll can accept. + */ + public pollTypes = PollTypeVerbose; + + /** + * The majority methods for the poll. + */ + public majorityMethods = MajorityMethodVerbose; + + /** + * the filtered `percentBases`. + */ + public validPercentBases: { [key: string]: string }; + + /** + * Reference to the observable of the groups. Used by the `search-value-component`. + */ + public groupObservable: Observable = null; + + /** + * An twodimensional array to handle constant values for this poll. + */ + public pollValues: [string, unknown][] = []; + + /** + * Model for the checkbox. + * If true, the given poll will immediately be published. + */ + public publishImmediately = true; + + public showNonNominalWarning = false; + + /** + * Constructor. Retrieves necessary metadata from the pollService, + * injects the poll itself + */ + public constructor( + title: Title, + protected translate: TranslateService, + snackbar: MatSnackBar, + private fb: FormBuilder, + private groupRepo: GroupRepositoryService, + public pollService: PollService, + private configService: ConfigService, + private dialog: MatDialog + ) { + super(title, translate, snackbar); + this.initContentForm(); + } + + /** + * OnInit. + * Sets the observable for groups. + */ + public ngOnInit(): void { + // without default group since default cant ever vote + this.groupObservable = this.groupRepo.getViewModelListObservableWithoutDefaultGroup(); + + if (this.data) { + if (this.data instanceof ViewAssignmentPoll) { + if (this.data.assignment && !this.data.votes_amount) { + this.data.votes_amount = this.data.assignment.open_posts; + } + if (!this.data.pollmethod) { + this.data.pollmethod = this.configService.instant('assignment_poll_method'); + } + } + + Object.keys(this.contentForm.controls).forEach(key => { + if (this.data[key]) { + this.contentForm.get(key).patchValue(this.data[key]); + } + }); + } + this.updatePollValues(this.contentForm.value); + this.updatePercentBases(this.contentForm.get('pollmethod').value); + + this.subscriptions.push( + // changes to whole form + this.contentForm.valueChanges.subscribe(values => { + if (values) { + this.updatePollValues(values); + } + }), + // poll method changes + this.contentForm.get('pollmethod').valueChanges.subscribe(method => { + if (method) { + this.updatePercentBases(method); + this.setVotesAmountCtrl(); + } + }), + // poll type changes + this.contentForm.get('type').valueChanges.subscribe(() => { + this.setVotesAmountCtrl(); + }) + ); + } + + /** + * updates the available percent bases according to the pollmethod + * @param method the currently chosen pollmethod + */ + private updatePercentBases(method: AssignmentPollMethod): void { + if (method) { + let forbiddenBases = []; + if (method === AssignmentPollMethod.YN) { + forbiddenBases = [PercentBase.YNA, AssignmentPollPercentBase.Votes]; + } else if (method === AssignmentPollMethod.YNA) { + forbiddenBases = [AssignmentPollPercentBase.Votes]; + } else if (method === AssignmentPollMethod.Votes) { + forbiddenBases = [PercentBase.YN, PercentBase.YNA]; + } + + const bases = {}; + for (const [key, value] of Object.entries(this.percentBases)) { + if (!forbiddenBases.includes(key)) { + bases[key] = value; + } + } + // update value in case that its no longer valid + const percentBaseControl = this.contentForm.get('onehundred_percent_base'); + percentBaseControl.setValue(this.getNormedPercentBase(percentBaseControl.value, method)); + + this.validPercentBases = bases; + } + } + + private getNormedPercentBase( + base: AssignmentPollPercentBase, + method: AssignmentPollMethod + ): AssignmentPollPercentBase { + if ( + method === AssignmentPollMethod.YN && + (base === AssignmentPollPercentBase.YNA || base === AssignmentPollPercentBase.Votes) + ) { + return AssignmentPollPercentBase.YN; + } else if (method === AssignmentPollMethod.YNA && base === AssignmentPollPercentBase.Votes) { + return AssignmentPollPercentBase.YNA; + } else if ( + method === AssignmentPollMethod.Votes && + (base === AssignmentPollPercentBase.YN || base === AssignmentPollPercentBase.YNA) + ) { + return AssignmentPollPercentBase.Votes; + } + return base; + } + + /** + * Disable votes_amount form control if the poll type is anonymous + * and the poll method is votes. + */ + private setVotesAmountCtrl(): void { + if (this.contentForm.get('type').value === PollType.Pseudoanonymous) { + this.showNonNominalWarning = true; + } else { + this.showNonNominalWarning = false; + } + } + + public getValues(): Partial { + return { ...this.data, ...this.contentForm.value }; + } + + /** + * This updates the poll-values to get correct data in the view. + * + * @param data Passing the properties of the poll. + */ + private updatePollValues(data: { [key: string]: any }): void { + if (this.data) { + this.pollValues = [ + [ + this.pollService.getVerboseNameForKey('type'), + this.pollService.getVerboseNameForValue('type', data.type) + ] + ]; + // show pollmethod only for assignment polls + if (this.data.pollClassType === PollClassType.Assignment) { + this.pollValues.push([ + this.pollService.getVerboseNameForKey('pollmethod'), + this.pollService.getVerboseNameForValue('pollmethod', data.pollmethod) + ]); + } + if (data.type !== 'analog') { + this.pollValues.push([ + this.pollService.getVerboseNameForKey('groups'), + data && data.groups_id && data.groups_id.length + ? this.groupRepo.getNameForIds(...data.groups_id) + : '---' + ]); + } + if (data.pollmethod === 'votes') { + this.pollValues.push([this.pollService.getVerboseNameForKey('votes_amount'), data.votes_amount]); + this.pollValues.push([this.pollService.getVerboseNameForKey('global_no'), data.global_no]); + this.pollValues.push([this.pollService.getVerboseNameForKey('global_abstain'), data.global_abstain]); + } + } + } + + private initContentForm(): void { + this.contentForm = this.fb.group({ + title: ['', Validators.required], + type: ['', Validators.required], + pollmethod: ['', Validators.required], + onehundred_percent_base: ['', Validators.required], + majority_method: ['', Validators.required], + votes_amount: [1, [Validators.required, Validators.min(1)]], + groups_id: [], + global_no: [false], + global_abstain: [false] + }); + } + + public openVotingWarning(): void { + this.dialog.open(VotingPrivacyWarningComponent, infoDialogSettings); + } + + /** + * compare function used with the KeyValuePipe to display the percent bases in original order + */ + public keepEntryOrder(): number { + return 0; + } +} diff --git a/client/src/app/site/polls/components/poll-list/poll-list.component.html b/client/src/app/site/polls/components/poll-list/poll-list.component.html new file mode 100644 index 000000000..ba6a5a482 --- /dev/null +++ b/client/src/app/site/polls/components/poll-list/poll-list.component.html @@ -0,0 +1,44 @@ + +
    List of votes
    +
    + + + +
    + + {{ poll.title }} +
    + + +
    + + {{ poll.getContentObject().getListTitle() }} +
    + + +
    + + {{ poll.stateVerbose | translate }} +
    + + +
    + + check_circle + + + warning + +
    +
    diff --git a/client/src/app/site/polls/components/poll-list/poll-list.component.scss b/client/src/app/site/polls/components/poll-list/poll-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/polls/components/poll-list/poll-list.component.spec.ts b/client/src/app/site/polls/components/poll-list/poll-list.component.spec.ts new file mode 100644 index 000000000..8f26b2bcc --- /dev/null +++ b/client/src/app/site/polls/components/poll-list/poll-list.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollListComponent } from './poll-list.component'; + +describe('PollListComponent', () => { + let component: PollListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [PollListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PollListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/polls/components/poll-list/poll-list.component.ts b/client/src/app/site/polls/components/poll-list/poll-list.component.ts new file mode 100644 index 000000000..2d26a79bf --- /dev/null +++ b/client/src/app/site/polls/components/poll-list/poll-list.component.ts @@ -0,0 +1,53 @@ +import { Component } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { PblColumnDefinition } from '@pebula/ngrid'; + +import { StorageService } from 'app/core/core-services/storage.service'; +import { VotingService } from 'app/core/ui-services/voting.service'; +import { BaseListViewComponent } from 'app/site/base/base-list-view'; +import { PollFilterListService } from '../../services/poll-filter-list.service'; +import { PollListObservableService } from '../../services/poll-list-observable.service'; +import { ViewBasePoll } from '../../models/view-base-poll'; + +@Component({ + selector: 'os-poll-list', + templateUrl: './poll-list.component.html', + styleUrls: ['./poll-list.component.scss'] +}) +export class PollListComponent extends BaseListViewComponent { + public tableColumnDefinition: PblColumnDefinition[] = [ + { + prop: 'title', + width: 'auto' + }, + { + prop: 'classType', + width: 'auto' + }, + { + prop: 'state', + width: 'auto' + }, + { + prop: 'votability', + width: '25px' + } + ]; + public filterProps = ['title', 'state']; + + public constructor( + public polls: PollListObservableService, + public filterService: PollFilterListService, + public votingService: VotingService, + protected storage: StorageService, + title: Title, + translate: TranslateService, + snackbar: MatSnackBar + ) { + super(title, translate, snackbar, storage); + super.setTitle('List of votes'); + } +} diff --git a/client/src/app/site/polls/components/poll-progress/poll-progress.component.html b/client/src/app/site/polls/components/poll-progress/poll-progress.component.html new file mode 100644 index 000000000..d1851adac --- /dev/null +++ b/client/src/app/site/polls/components/poll-progress/poll-progress.component.html @@ -0,0 +1,8 @@ +
    +
    + {{ poll.votescast }} / {{ max }} +
    + + Received votes + +
    diff --git a/client/src/app/site/polls/components/poll-progress/poll-progress.component.scss b/client/src/app/site/polls/components/poll-progress/poll-progress.component.scss new file mode 100644 index 000000000..3c258af6e --- /dev/null +++ b/client/src/app/site/polls/components/poll-progress/poll-progress.component.scss @@ -0,0 +1,8 @@ +.poll-progress-wrapper { + margin: 1em 0 2em 0; + + .vote-number { + text-align: center; + font-size: 150%; + } +} diff --git a/client/src/app/site/polls/components/poll-progress/poll-progress.component.spec.ts b/client/src/app/site/polls/components/poll-progress/poll-progress.component.spec.ts new file mode 100644 index 000000000..8daf57c81 --- /dev/null +++ b/client/src/app/site/polls/components/poll-progress/poll-progress.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollProgressComponent } from './poll-progress.component'; + +describe('PollProgressComponent', () => { + let component: PollProgressComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [PollProgressComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PollProgressComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/polls/components/poll-progress/poll-progress.component.ts b/client/src/app/site/polls/components/poll-progress/poll-progress.component.ts new file mode 100644 index 000000000..e5508d10e --- /dev/null +++ b/client/src/app/site/polls/components/poll-progress/poll-progress.component.ts @@ -0,0 +1,58 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { map } from 'rxjs/operators'; + +import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; + +@Component({ + selector: 'os-poll-progress', + templateUrl: './poll-progress.component.html', + styleUrls: ['./poll-progress.component.scss'] +}) +export class PollProgressComponent extends BaseViewComponent implements OnInit { + @Input() + public poll: ViewBasePoll; + + public max: number; + + public constructor( + title: Title, + protected translate: TranslateService, + snackbar: MatSnackBar, + private userRepo: UserRepositoryService + ) { + super(title, translate, snackbar); + } + + public get valueInPercent(): number { + if (this.poll) { + return (this.poll.votesvalid / this.max) * 100; + } else { + return 0; + } + } + + /** + * OnInit. + * Sets the observable for groups. + */ + public ngOnInit(): void { + if (this.poll) { + this.userRepo + .getViewModelListObservable() + .pipe( + map(users => + users.filter(user => user.is_present && this.poll.groups_id.intersect(user.groups_id).length) + ) + ) + .subscribe(users => { + this.max = users.length; + }); + } + } +} diff --git a/client/src/app/site/polls/models/has-view-polls.ts b/client/src/app/site/polls/models/has-view-polls.ts new file mode 100644 index 000000000..db7e65353 --- /dev/null +++ b/client/src/app/site/polls/models/has-view-polls.ts @@ -0,0 +1,5 @@ +import { ViewBasePoll } from './view-base-poll'; + +export interface HasViewPolls { + polls: T[]; +} diff --git a/client/src/app/site/polls/models/view-base-option.ts b/client/src/app/site/polls/models/view-base-option.ts new file mode 100644 index 000000000..60981426b --- /dev/null +++ b/client/src/app/site/polls/models/view-base-option.ts @@ -0,0 +1,15 @@ +import { BaseOption } from 'app/shared/models/poll/base-option'; +import { BaseViewModel } from '../../base/base-view-model'; +import { ViewBasePoll } from './view-base-poll'; +import { ViewBaseVote } from './view-base-vote'; + +export class ViewBaseOption = any> extends BaseViewModel { + public get option(): M { + return this._model; + } +} + +export interface ViewBaseOption = any> extends BaseOption { + votes: ViewBaseVote[]; + poll: ViewBasePoll; +} diff --git a/client/src/app/site/polls/models/view-base-poll.ts b/client/src/app/site/polls/models/view-base-poll.ts new file mode 100644 index 000000000..b8bca57c6 --- /dev/null +++ b/client/src/app/site/polls/models/view-base-poll.ts @@ -0,0 +1,120 @@ +import { BasePoll } from 'app/shared/models/poll/base-poll'; +import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; +import { BaseViewModel } from 'app/site/base/base-view-model'; +import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { ViewBaseOption } from './view-base-option'; + +export enum PollClassType { + Motion = 'motion', + Assignment = 'assignment' +} + +export const PollClassTypeVerbose = { + motion: 'Motion poll', + assignment: 'Assignment poll' +}; + +export const PollStateVerbose = { + 1: 'created', + 2: 'started', + 3: 'finished (unpublished)', + 4: 'published' +}; + +export const PollStateChangeActionVerbose = { + 1: 'Reset', + 2: 'Start voting', + 3: 'Stop voting', + 4: 'Publish' +}; + +export const PollTypeVerbose = { + analog: 'analog', + named: 'nominal', + pseudoanonymous: 'non-nominal' +}; + +export const PollPropertyVerbose = { + majority_method: 'Required majority', + onehundred_percent_base: '100% base', + type: 'Voting type', + pollmethod: 'Voting method', + state: 'State', + groups: 'Entitled to vote', + votes_amount: 'Amount of votes', + global_no: 'General No', + global_abstain: 'General Abstain' +}; + +export const MajorityMethodVerbose = { + simple: 'Simple majority', + two_thirds: 'Two-thirds majority', + three_quarters: 'Three-quarters majority', + disabled: 'Disabled' +}; + +export const PercentBaseVerbose = { + YN: 'Yes/No', + YNA: 'Yes/No/Abstain', + valid: 'Valid votes', + cast: 'Total votes cast', + disabled: 'Disabled' +}; + +export abstract class ViewBasePoll< + M extends BasePoll = any, + PM extends string = string, + PB extends string = string +> extends BaseProjectableViewModel { + public get poll(): M { + return this._model; + } + + public get pollClassTypeVerbose(): string { + return PollClassTypeVerbose[this.pollClassType]; + } + + public get parentLink(): string { + return `/${this.pollClassType}s/${this.getContentObject().id}`; + } + + public get stateVerbose(): string { + return PollStateVerbose[this.state]; + } + + public get nextStateActionVerbose(): string { + return PollStateChangeActionVerbose[this.nextState]; + } + + public get typeVerbose(): string { + return PollTypeVerbose[this.type]; + } + + public get majorityMethodVerbose(): string { + return MajorityMethodVerbose[this.majority_method]; + } + + public abstract get pollmethodVerbose(): string; + + public abstract get percentBaseVerbose(): string; + + public abstract readonly pollClassType: 'motion' | 'assignment'; + + public canBeVotedFor: () => boolean; + + public abstract getSlide(): ProjectorElementBuildDeskriptor; + + public abstract getContentObject(): BaseViewModel; +} + +export interface ViewBasePoll< + M extends BasePoll = any, + PM extends string = string, + PB extends string = string +> extends BasePoll { + voted: ViewUser[]; + groups: ViewGroup[]; + options: ViewBaseOption[]; +} diff --git a/client/src/app/site/polls/models/view-base-vote.ts b/client/src/app/site/polls/models/view-base-vote.ts new file mode 100644 index 000000000..d508a9f47 --- /dev/null +++ b/client/src/app/site/polls/models/view-base-vote.ts @@ -0,0 +1,15 @@ +import { BaseVote } from 'app/shared/models/poll/base-vote'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BaseViewModel } from '../../base/base-view-model'; +import { ViewBaseOption } from './view-base-option'; + +export class ViewBaseVote = any> extends BaseViewModel { + public get vote(): M { + return this._model; + } +} + +export interface ViewBaseVote = any> extends BaseVote { + user?: ViewUser; + option: ViewBaseOption; +} diff --git a/client/src/app/site/polls/polls-routing.module.ts b/client/src/app/site/polls/polls-routing.module.ts new file mode 100644 index 000000000..dd8d63f18 --- /dev/null +++ b/client/src/app/site/polls/polls-routing.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { PollListComponent } from './components/poll-list/poll-list.component'; + +/** + * Define the routes for the polls module + */ +const routes: Routes = [{ path: '', component: PollListComponent, pathMatch: 'full' }]; + +/** + * Define the routing component and setup the routes + */ +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class PollsRoutingModule {} diff --git a/client/src/app/site/polls/polls.module.ts b/client/src/app/site/polls/polls.module.ts new file mode 100644 index 000000000..5cb94a456 --- /dev/null +++ b/client/src/app/site/polls/polls.module.ts @@ -0,0 +1,18 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { PollListComponent } from './components/poll-list/poll-list.component'; +import { PollProgressComponent } from './components/poll-progress/poll-progress.component'; +import { PollsRoutingModule } from './polls-routing.module'; +import { SharedModule } from '../../shared/shared.module'; + +/** + * App module for the history feature. + * Declares the used components. + */ +@NgModule({ + imports: [CommonModule, PollsRoutingModule, SharedModule], + exports: [PollProgressComponent], + declarations: [PollListComponent, PollProgressComponent] +}) +export class PollsModule {} diff --git a/client/src/app/site/polls/services/base-poll-repository.service.ts b/client/src/app/site/polls/services/base-poll-repository.service.ts new file mode 100644 index 000000000..0a1086fb5 --- /dev/null +++ b/client/src/app/site/polls/services/base-poll-repository.service.ts @@ -0,0 +1,87 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.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'; +import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; +import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { RelationDefinition } from 'app/core/definitions/relations'; +import { BaseRepository, NestedModelDescriptors } from 'app/core/repositories/base-repository'; +import { VotingService } from 'app/core/ui-services/voting.service'; +import { ModelConstructor } from 'app/shared/models/base/base-model'; +import { BasePoll, PollState } from 'app/shared/models/poll/base-poll'; +import { BaseViewModel, TitleInformation } from 'app/site/base/base-view-model'; +import { ViewBasePoll } from '../models/view-base-poll'; + +export abstract class BasePollRepositoryService< + V extends ViewBasePoll & T = any, + M extends BasePoll = any, + T extends TitleInformation = any +> extends BaseRepository { + // just passing everything to superclass + public constructor( + protected DS: DataStoreService, + protected dataSend: DataSendService, + protected collectionStringMapperService: CollectionStringMapperService, + protected viewModelStoreService: ViewModelStoreService, + protected translate: TranslateService, + protected relationManager: RelationManagerService, + protected baseModelCtor: ModelConstructor, + protected relationDefinitions: RelationDefinition[] = [], + protected nestedModelDescriptors: NestedModelDescriptors = {}, + private votingService: VotingService, + protected http: HttpService + ) { + super( + DS, + dataSend, + collectionStringMapperService, + viewModelStoreService, + translate, + relationManager, + baseModelCtor, + relationDefinitions, + nestedModelDescriptors + ); + } + + /** + * overwrites the view model creation to insert the `canBeVotedFor` property + * @param model the model + */ + protected createViewModelWithTitles(model: M): V { + const viewModel = super.createViewModelWithTitles(model); + Object.defineProperty(viewModel, 'canBeVotedFor', { + get: () => this.votingService.canVote(viewModel) + }); + return viewModel; + } + + public changePollState(poll: BasePoll): Promise { + const path = this.restPath(poll); + switch (poll.state) { + case PollState.Created: + return this.http.post(`${path}/start/`); + case PollState.Started: + return this.http.post(`${path}/stop/`); + case PollState.Finished: + return this.http.post(`${path}/publish/`); + case PollState.Published: + return this.resetPoll(poll); + } + } + + public resetPoll(poll: BasePoll): Promise { + return this.http.post(`${this.restPath(poll)}/reset/`); + } + + private restPath(poll: BasePoll): string { + return `/rest/${poll.collectionString}/${poll.id}`; + } + + public pseudoanonymize(poll: BasePoll): Promise { + const path = this.restPath(poll); + return this.http.post(`${path}/pseudoanonymize/`); + } +} diff --git a/client/src/app/site/polls/services/poll-filter-list.service.spec.ts b/client/src/app/site/polls/services/poll-filter-list.service.spec.ts new file mode 100644 index 000000000..97eb5ca2a --- /dev/null +++ b/client/src/app/site/polls/services/poll-filter-list.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollFilterListService } from './poll-filter-list.service'; + +describe('PollFilterListService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: PollFilterListService = TestBed.get(PollFilterListService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/polls/services/poll-filter-list.service.ts b/client/src/app/site/polls/services/poll-filter-list.service.ts new file mode 100644 index 000000000..8059a73a0 --- /dev/null +++ b/client/src/app/site/polls/services/poll-filter-list.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service'; +import { StorageService } from 'app/core/core-services/storage.service'; +import { BaseFilterListService, OsFilter } from 'app/core/ui-services/base-filter-list.service'; +import { PollState } from 'app/shared/models/poll/base-poll'; +import { ViewBasePoll } from '../models/view-base-poll'; + +@Injectable({ + providedIn: 'root' +}) +export class PollFilterListService extends BaseFilterListService { + /** + * set the storage key name + */ + protected storageKey = 'PollList'; + + public constructor(store: StorageService, OSStatus: OpenSlidesStatusService, private translate: TranslateService) { + super(store, OSStatus); + } + + /** + * @returns the filter definition + */ + protected getFilterDefinitions(): OsFilter[] { + return [ + { + property: 'state', + label: this.translate.instant('State'), + options: [ + { condition: PollState.Created, label: this.translate.instant('created') }, + { condition: PollState.Started, label: this.translate.instant('started') }, + { condition: PollState.Finished, label: this.translate.instant('finished (unpublished)') }, + { condition: PollState.Published, label: this.translate.instant('published') } + ] + }, + { + property: 'canBeVotedFor', + label: this.translate.instant('Vote'), + options: [ + { condition: true, label: this.translate.instant('Vote currently possible') }, + { condition: false, label: this.translate.instant('Vote not possible') } + ] + }, + { + property: 'user_has_voted', + label: this.translate.instant('Vote finished'), + options: [ + { condition: true, label: this.translate.instant('Has been voted for') }, + { condition: false, label: this.translate.instant('Has not been voted for') } + ] + } + ]; + } +} diff --git a/client/src/app/site/polls/services/poll-list-observable.service.spec.ts b/client/src/app/site/polls/services/poll-list-observable.service.spec.ts new file mode 100644 index 000000000..cd6bc9c69 --- /dev/null +++ b/client/src/app/site/polls/services/poll-list-observable.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollListObservableService } from './poll-list-observable.service'; + +describe('PollListObservableService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: PollListObservableService = TestBed.get(PollListObservableService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/polls/services/poll-list-observable.service.ts b/client/src/app/site/polls/services/poll-list-observable.service.ts new file mode 100644 index 000000000..e7577e89f --- /dev/null +++ b/client/src/app/site/polls/services/poll-list-observable.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Observable } from 'rxjs'; + +import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; +import { HasViewModelListObservable } from 'app/core/definitions/has-view-model-list-observable'; +import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; +import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; +import { BaseViewModel } from 'app/site/base/base-view-model'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { PollClassType, ViewBasePoll } from '../models/view-base-poll'; + +@Injectable({ + providedIn: 'root' +}) +export class PollListObservableService implements HasViewModelListObservable { + // protected so tslint doesn't complain + protected motionPolls: ViewMotionPoll[] = []; + protected assignmentPolls: ViewAssignmentPoll[] = []; + + private readonly viewPollListSubject: BehaviorSubject = new BehaviorSubject([]); + + public constructor( + motionPollRepo: MotionPollRepositoryService, + assignmentPollRepo: AssignmentPollRepositoryService, + private mapper: CollectionStringMapperService + ) { + motionPollRepo + .getViewModelListObservable() + .subscribe(polls => this.adjustViewModelListObservable(polls, PollClassType.Motion)); + assignmentPollRepo + .getViewModelListObservable() + .subscribe(polls => this.adjustViewModelListObservable(polls, PollClassType.Assignment)); + } + + private adjustViewModelListObservable(polls: ViewBasePoll[], mode: PollClassType): void { + this[mode + 'Polls'] = polls; + + const allPolls = (this.motionPolls as ViewBasePoll[]).concat(this.assignmentPolls); + this.viewPollListSubject.next(allPolls); + } + + public getViewModelListObservable(): Observable { + return this.viewPollListSubject.asObservable(); + } + + public getObservableFromViewModel(poll: ViewBasePoll): Observable { + return this.mapper.getRepository(poll.collectionString).getViewModelObservable(poll.id); + } +} diff --git a/client/src/app/site/polls/services/poll.service.spec.ts b/client/src/app/site/polls/services/poll.service.spec.ts new file mode 100644 index 000000000..a83ee6f8e --- /dev/null +++ b/client/src/app/site/polls/services/poll.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollService } from './poll.service'; + +describe('PollService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [RouterTestingModule, E2EImportsModule] }); + }); + + it('should be created', () => { + const service: PollService = TestBed.get(PollService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/polls/services/poll.service.ts b/client/src/app/site/polls/services/poll.service.ts new file mode 100644 index 000000000..6652a9214 --- /dev/null +++ b/client/src/app/site/polls/services/poll.service.ts @@ -0,0 +1,365 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { _ } from 'app/core/translate/translation-marker'; +import { ChartData, ChartDate } from 'app/shared/components/charts/charts.component'; +import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; +import { + BasePoll, + MajorityMethod, + PercentBase, + PollColor, + PollType, + VOTE_UNDOCUMENTED +} from 'app/shared/models/poll/base-poll'; +import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe'; +import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; +import { AssignmentPollMethodVerbose } from 'app/site/assignments/models/view-assignment-poll'; +import { + MajorityMethodVerbose, + PercentBaseVerbose, + PollPropertyVerbose, + PollTypeVerbose, + ViewBasePoll +} from 'app/site/polls/models/view-base-poll'; +import { ConstantsService } from '../../../core/core-services/constants.service'; + +const PERCENT_DECIMAL_PLACES = 3; +/** + * The possible keys of a poll object that represent numbers. + * TODO Should be 'key of MotionPoll|AssinmentPoll if type of key is number' + */ +export type CalculablePollKey = + | 'votesvalid' + | 'votesinvalid' + | 'votescast' + | 'yes' + | 'no' + | 'abstain' + | 'votesno' + | 'votesabstain'; + +/** + * TODO: may be obsolete if the server switches to lower case only + * (lower case variants are already in CalculablePollKey) + */ +export type PollVoteValue = 'Yes' | 'No' | 'Abstain' | 'Votes'; + +export const VoteValuesVerbose = { + Y: 'Yes', + N: 'No', + A: 'Abstain' +}; + +/** + * Interface representing possible majority calculation methods. The implementing + * calc function should return an integer number that must be reached for the + * option to successfully fulfill the quorum, or null if disabled + */ +export interface CalculableMajorityMethod { + value: string; + display_name: string; + calc: (base: number) => number | null; +} + +/** + * Function to round up the passed value of a poll. + * + * @param value The calculated value of 100%-base. + * @param addOne Flag, if the result should be increased by 1. + * + * @returns The necessary value to get the majority. + */ +export const calcMajority = (value: number, addOne: boolean = false) => { + return Math.ceil(value) + (addOne ? 1 : 0); +}; + +/** + * List of available majority methods, used in motion and assignment polls + */ +export const PollMajorityMethod: CalculableMajorityMethod[] = [ + { + value: 'simple_majority', + display_name: 'Simple majority', + calc: base => calcMajority(base / 2, true) + }, + { + value: 'two-thirds_majority', + display_name: 'Two-thirds majority', + calc: base => calcMajority((base * 2) / 3) + }, + { + value: 'three-quarters_majority', + display_name: 'Three-quarters majority', + calc: base => calcMajority((base * 3) / 4) + }, + { + value: 'disabled', + display_name: 'Disabled', + calc: a => null + } +]; + +export interface PollData { + pollmethod: string; + type: string; + onehundred_percent_base: string; + options: { + user?: { + short_name: string; + }; + yes?: number; + no?: number; + abstain?: number; + }[]; + votesvalid: number; + votesinvalid: number; + votescast: number; +} + +interface OpenSlidesSettings { + ENABLE_ELECTRONIC_VOTING: boolean; +} + +/** + * Interface describes the possible data for the result-table. + */ +export interface PollTableData { + votingOption: string; + votingOptionSubtitle?: string; + class?: string; + value: VotingResult[]; +} + +export interface VotingResult { + vote?: + | 'yes' + | 'no' + | 'abstain' + | 'votesvalid' + | 'votesinvalid' + | 'votescast' + | 'amount_global_no' + | 'amount_global_abstain'; + amount?: number; + icon?: string; + hide?: boolean; + showPercent?: boolean; +} + +/** + * Shared service class for polls. Used by child classes {@link MotionPollService} + * and {@link AssignmentPollService} + */ +@Injectable({ + providedIn: 'root' +}) +export abstract class PollService { + /** + * The default percentage base + */ + public abstract defaultPercentBase: string; + + /** + * The default majority method + */ + public abstract defaultMajorityMethod: MajorityMethod; + + /** + * Per default entitled to vote + */ + public abstract defaultGroupIds: number[]; + + /** + * The majority method currently in use + */ + public majorityMethod: CalculableMajorityMethod; + + public isElectronicVotingEnabled: boolean; + + /** + * list of poll keys that are numbers and can be part of a quorum calculation + */ + public pollValues: CalculablePollKey[] = ['yes', 'no', 'abstain', 'votesvalid', 'votesinvalid', 'votescast']; + + public constructor( + constants: ConstantsService, + protected translate: TranslateService, + private pollKeyVerbose: PollKeyVerbosePipe, + private parsePollNumber: ParsePollNumberPipe + ) { + constants + .get('Settings') + .subscribe(settings => (this.isElectronicVotingEnabled = settings.ENABLE_ELECTRONIC_VOTING)); + } + + /** + * return the total number of votes depending on the selected percent base + */ + public abstract getPercentBase(poll: PollData): number; + + public getVoteValueInPercent(value: number, poll: PollData): string | null { + const totalByBase = this.getPercentBase(poll); + if (totalByBase && totalByBase > 0) { + const percentNumber = (value / totalByBase) * 100; + const result = percentNumber % 1 === 0 ? percentNumber : percentNumber.toFixed(PERCENT_DECIMAL_PLACES); + return `${result} %`; + } + return null; + } + + /** + * Assigns the default poll data to the object. To be extended in subclasses + * @param poll the poll/object to fill + */ + public getDefaultPollData(): Partial { + return { + onehundred_percent_base: this.defaultPercentBase, + majority_method: this.defaultMajorityMethod, + groups_id: this.defaultGroupIds, + type: PollType.Analog + }; + } + + public getVerboseNameForValue(key: string, value: string): string { + switch (key) { + case 'majority_method': + return MajorityMethodVerbose[value]; + case 'onehundred_percent_base': + return PercentBaseVerbose[value]; + case 'pollmethod': + return AssignmentPollMethodVerbose[value]; + case 'type': + return PollTypeVerbose[value]; + } + } + + public getVerboseNameForKey(key: string): string { + return PollPropertyVerbose[key]; + } + + public getVoteTableKeys(poll: PollData | ViewBasePoll): VotingResult[] { + return [ + { + vote: 'yes', + icon: 'thumb_up', + showPercent: true + }, + { + vote: 'no', + icon: 'thumb_down', + showPercent: true + }, + { + vote: 'abstain', + icon: 'trip_origin', + showPercent: this.showAbstainPercent(poll) + } + ]; + } + + private showAbstainPercent(poll: PollData | ViewBasePoll): boolean { + return ( + poll.onehundred_percent_base === PercentBase.YNA || + poll.onehundred_percent_base === PercentBase.Valid || + poll.onehundred_percent_base === PercentBase.Cast + ); + } + + public showPercentOfValidOrCast(poll: PollData | ViewBasePoll): boolean { + return poll.onehundred_percent_base === PercentBase.Valid || poll.onehundred_percent_base === PercentBase.Cast; + } + + public getSumTableKeys(poll: PollData | ViewBasePoll): VotingResult[] { + return [ + { + vote: 'votesvalid', + hide: poll.votesvalid === VOTE_UNDOCUMENTED, + showPercent: this.showPercentOfValidOrCast(poll) + }, + { + vote: 'votesinvalid', + icon: 'not_interested', + hide: poll.votesinvalid === VOTE_UNDOCUMENTED || poll.type !== PollType.Analog, + showPercent: poll.onehundred_percent_base === PercentBase.Cast + }, + { + vote: 'votescast', + hide: poll.votescast === VOTE_UNDOCUMENTED || poll.type !== PollType.Analog, + showPercent: poll.onehundred_percent_base === PercentBase.Cast + } + ]; + } + + public generateChartData(poll: PollData | ViewBasePoll): ChartData { + const fields = this.getPollDataFields(poll); + + const data: ChartData = fields.map(key => { + return { + data: this.getResultFromPoll(poll, key), + label: key.toUpperCase(), + backgroundColor: PollColor[key], + hoverBackgroundColor: PollColor[key] + } as ChartDate; + }); + + return data; + } + + private getPollDataFields(poll: PollData | ViewBasePoll): CalculablePollKey[] { + let fields: CalculablePollKey[]; + let isAssignment: boolean; + + if (poll instanceof ViewBasePoll) { + isAssignment = poll.pollClassType === 'assignment'; + } else { + isAssignment = Object.keys(poll.options[0]).includes('user'); + } + + if (isAssignment) { + if (poll.pollmethod === AssignmentPollMethod.YNA) { + fields = ['yes', 'no', 'abstain']; + } else if (poll.pollmethod === AssignmentPollMethod.YN) { + fields = ['yes', 'no']; + } else { + fields = ['yes']; + } + } else { + if (poll.onehundred_percent_base === PercentBase.YN) { + fields = ['yes', 'no']; + } else if (poll.onehundred_percent_base === PercentBase.Cast) { + fields = ['yes', 'no', 'abstain', 'votesinvalid']; + } else { + fields = ['yes', 'no', 'abstain']; + } + } + + return fields; + } + + /** + * Extracts yes-no-abstain such as valid, invalids and totals from Poll and PollData-Objects + */ + private getResultFromPoll(poll: PollData, key: CalculablePollKey): number[] { + return poll[key] ? [poll[key]] : poll.options.map(option => option[key]); + } + + public getChartLabels(poll: PollData): string[] { + const fields = this.getPollDataFields(poll); + return poll.options.map(option => { + const votingResults = fields.map(field => { + const votingKey = this.translate.instant(this.pollKeyVerbose.transform(field)); + const resultValue = this.parsePollNumber.transform(option[field]); + const resultInPercent = this.getVoteValueInPercent(option[field], poll); + return `${votingKey} ${resultValue} (${resultInPercent})`; + }); + + return `${option.user.short_name} ยท ${votingResults.join(' ยท ')}`; + }); + } + + public isVoteDocumented(vote: number): boolean { + return vote !== null && vote !== undefined && vote !== VOTE_UNDOCUMENTED; + } +} diff --git a/client/src/app/site/projector/projector.config.ts b/client/src/app/site/projector/projector.config.ts index c0b8012cd..8f5110e34 100644 --- a/client/src/app/site/projector/projector.config.ts +++ b/client/src/app/site/projector/projector.config.ts @@ -16,25 +16,21 @@ export const ProjectorAppConfig: AppConfig = { name: 'projector', models: [ { - collectionString: 'core/projector', model: Projector, viewModel: ViewProjector, repository: ProjectorRepositoryService }, { - collectionString: 'core/projection-default', model: ProjectionDefault, viewModel: ViewProjectionDefault, repository: ProjectionDefaultRepositoryService }, { - collectionString: 'core/countdown', model: Countdown, viewModel: ViewCountdown, repository: CountdownRepositoryService }, { - collectionString: 'core/projector-message', model: ProjectorMessage, viewModel: ViewProjectorMessage, repository: ProjectorMessageRepositoryService diff --git a/client/src/app/site/site-routing.module.ts b/client/src/app/site/site-routing.module.ts index bda0023d9..9b33d7d2e 100644 --- a/client/src/app/site/site-routing.module.ts +++ b/client/src/app/site/site-routing.module.ts @@ -68,6 +68,11 @@ const routes: Routes = [ path: 'projectors', loadChildren: () => import('./projector/projector.module').then(m => m.ProjectorModule), data: { basePerm: 'core.can_see_projector' } + }, + { + path: 'polls', + loadChildren: () => import('./polls/polls.module').then(m => m.PollsModule), + data: { basePerm: ['motions.can_see', 'assignments.can_see'] } // one of them is sufficient } ], canActivateChild: [AuthGuard] diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html index 188dd375c..040e39857 100644 --- a/client/src/app/site/site.component.html +++ b/client/src/app/site/site.component.html @@ -1,9 +1,4 @@ -
    cloud_offOffline mode
    -
    - You are using the history mode of OpenSlides. Changes will not be saved. - ({{ getHistoryTimestamp() }}) - Exit -
    + @@ -88,13 +88,14 @@
    - + + +
    diff --git a/client/src/app/site/topics/topics.config.ts b/client/src/app/site/topics/topics.config.ts index 957bf0d13..07d39b1b4 100644 --- a/client/src/app/site/topics/topics.config.ts +++ b/client/src/app/site/topics/topics.config.ts @@ -7,7 +7,6 @@ export const TopicsAppConfig: AppConfig = { name: 'topics', models: [ { - collectionString: 'topics/topic', model: Topic, viewModel: ViewTopic, searchOrder: 1, 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 2d4642f5b..e1591b231 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 @@ -75,22 +75,12 @@ - + - +
    @@ -142,13 +132,14 @@
    - + + +
    @@ -184,23 +175,14 @@
    - +
    - + Only for internal notes.
    @@ -245,7 +227,9 @@ {{ user.short_name }} check_box - account_balance + account_balance block diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.ts b/client/src/app/site/users/components/user-detail/user-detail.component.ts index 66b023974..94a5ca3be 100644 --- a/client/src/app/site/users/components/user-detail/user-detail.component.ts +++ b/client/src/app/site/users/components/user-detail/user-detail.component.ts @@ -110,9 +110,7 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit { this.constantsService.get('UserBackends').subscribe(backends => (this.userBackends = backends)); - this.groupRepo - .getViewModelListObservable() - .subscribe(groups => this.groups.next(groups.filter(group => group.id !== 1))); + this.groupRepo.getViewModelListObservableWithoutDefaultGroup().subscribe(this.groups); } /** diff --git a/client/src/app/site/users/components/user-list/user-list.component.html b/client/src/app/site/users/components/user-list/user-list.component.html index 08fce0fca..da414f2d0 100644 --- a/client/src/app/site/users/components/user-list/user-list.component.html +++ b/client/src/app/site/users/components/user-list/user-list.component.html @@ -15,7 +15,7 @@ implements UserTitl // Will be set by the repository public getFullName: () => string; public getShortName: () => string; + public getLevelAndNumber: () => string; /** * Formats the category for search diff --git a/client/src/app/site/users/users.config.ts b/client/src/app/site/users/users.config.ts index c3b24868d..3f7ce1ab0 100644 --- a/client/src/app/site/users/users.config.ts +++ b/client/src/app/site/users/users.config.ts @@ -13,15 +13,13 @@ export const UsersAppConfig: AppConfig = { name: 'users', models: [ { - collectionString: 'users/user', model: User, viewModel: ViewUser, searchOrder: 4, repository: UserRepositoryService }, - { collectionString: 'users/group', model: Group, viewModel: ViewGroup, repository: GroupRepositoryService }, + { model: Group, viewModel: ViewGroup, repository: GroupRepositoryService }, { - collectionString: 'users/personal-note', model: PersonalNote, viewModel: ViewPersonalNote, repository: PersonalNoteRepositoryService diff --git a/client/src/app/slides/all-slide-configurations.ts b/client/src/app/slides/all-slide-configurations.ts index a81627a6d..34ebe55df 100644 --- a/client/src/app/slides/all-slide-configurations.ts +++ b/client/src/app/slides/all-slide-configurations.ts @@ -25,6 +25,11 @@ export const allSlidesDynamicConfiguration: (SlideDynamicConfiguration & Slide)[ scaleable: true, scrollable: true }, + { + slide: 'motions/motion-poll', + scaleable: true, + scrollable: true + }, { slide: 'users/user', scaleable: true, @@ -83,7 +88,7 @@ export const allSlidesDynamicConfiguration: (SlideDynamicConfiguration & Slide)[ scrollable: true }, { - slide: 'assignments/poll', + slide: 'assignments/assignment-poll', scaleable: true, scrollable: true }, diff --git a/client/src/app/slides/all-slides.ts b/client/src/app/slides/all-slides.ts index 409f9f231..e0529236e 100644 --- a/client/src/app/slides/all-slides.ts +++ b/client/src/app/slides/all-slides.ts @@ -41,6 +41,14 @@ export const allSlides: SlideManifest[] = [ elementIdentifiers: ['name', 'id'], canBeMappedToModel: true }, + { + slide: 'motions/motion-poll', + path: 'motions/motion-poll', + loadChildren: () => import('./motions/motion-poll/motion-poll-slide.module').then(m => m.MotionPollSlideModule), + verboseName: 'Motion Poll', + elementIdentifiers: ['name', 'id'], + canBeMappedToModel: true + }, { slide: 'users/user', path: 'users/user', @@ -126,12 +134,13 @@ export const allSlides: SlideManifest[] = [ canBeMappedToModel: true }, { - slide: 'assignments/poll', - path: 'assignments/poll', - loadChildren: () => import('./assignments/poll/poll-slide.module').then(m => m.PollSlideModule), - verboseName: 'Poll', - elementIdentifiers: ['name', 'assignment_id', 'poll_id'], - canBeMappedToModel: false + slide: 'assignments/assignment-poll', + path: 'assignments/assignment-poll', + loadChildren: () => + import('./assignments/assignment-poll/assignment-poll-slide.module').then(m => m.AssignmentPollSlideModule), + verboseName: 'Assignment Poll', + elementIdentifiers: ['name', 'id'], + canBeMappedToModel: true }, { slide: 'mediafiles/mediafile', diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts new file mode 100644 index 000000000..4e821c495 --- /dev/null +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts @@ -0,0 +1,34 @@ +import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; +import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll'; +import { AssignmentTitleInformation } from 'app/site/assignments/models/view-assignment'; +import { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data'; + +export interface AssignmentPollSlideData extends BasePollSlideData { + assignment: AssignmentTitleInformation; + poll: { + title: string; + type: PollType; + pollmethod: AssignmentPollMethod; + votes_amount: number; + description: string; + state: PollState; + onehundred_percent_base: PercentBase; + majority_method: MajorityMethod; + + options: { + user: { + short_name: string; + }; + yes?: number; + no?: number; + abstain?: number; + }[]; + + // optional for published polls: + amount_global_no?: number; + amount_global_abstain?: number; + votesvalid: number; + votesinvalid: number; + votescast: number; + }; +} diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.html b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.html new file mode 100644 index 000000000..656890ef0 --- /dev/null +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.html @@ -0,0 +1,15 @@ + +
    +

    {{ data.data.assignment.title }}

    +

    {{ data.data.poll.title }}

    +
    +
    + +
    +
    diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.scss b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.scss new file mode 100644 index 000000000..94c5fff87 --- /dev/null +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.scss @@ -0,0 +1,11 @@ +.assignment-title { + margin: 0 0 10px; +} + +.slidetitle { + margin-bottom: 15px; +} + +.charts-wrapper { + position: relative; +} diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.spec.ts b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.spec.ts new file mode 100644 index 000000000..2c8b67d07 --- /dev/null +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AssignmentPollSlideComponent } from './assignment-poll-slide.component'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('AssignmentPollSlideComponent', () => { + let component: AssignmentPollSlideComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [AssignmentPollSlideComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AssignmentPollSlideComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.ts b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.ts new file mode 100644 index 000000000..39a1ccc24 --- /dev/null +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; + +import { PollState } from 'app/shared/models/poll/base-poll'; +import { BasePollSlideComponent } from 'app/slides/polls/base-poll-slide.component'; +import { AssignmentPollSlideData } from './assignment-poll-slide-data'; + +@Component({ + selector: 'os-assignment-poll-slide', + templateUrl: './assignment-poll-slide.component.html', + styleUrls: ['./assignment-poll-slide.component.scss'] +}) +export class AssignmentPollSlideComponent extends BasePollSlideComponent { + public PollState = PollState; + + public options = { maintainAspectRatio: false, responsive: true, legend: { position: 'right' } }; +} diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.module.spec.ts b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.module.spec.ts new file mode 100644 index 000000000..a6c06a558 --- /dev/null +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.module.spec.ts @@ -0,0 +1,13 @@ +import { AssignmentPollSlideModule } from './assignment-poll-slide.module'; + +describe('AssignmentPollSlideModule', () => { + let assignmentPollSlideModule: AssignmentPollSlideModule; + + beforeEach(() => { + assignmentPollSlideModule = new AssignmentPollSlideModule(); + }); + + it('should create an instance', () => { + expect(assignmentPollSlideModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.module.ts b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.module.ts new file mode 100644 index 000000000..a31657771 --- /dev/null +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; + +import { makeSlideModule } from 'app/slides/base-slide-module'; +import { AssignmentPollSlideComponent } from './assignment-poll-slide.component'; + +@NgModule(makeSlideModule(AssignmentPollSlideComponent)) +export class AssignmentPollSlideModule {} diff --git a/client/src/app/slides/assignments/assignment/assignment-slide-data.ts b/client/src/app/slides/assignments/assignment/assignment-slide-data.ts index 4b9f9a2b2..4663ec637 100644 --- a/client/src/app/slides/assignments/assignment/assignment-slide-data.ts +++ b/client/src/app/slides/assignments/assignment/assignment-slide-data.ts @@ -5,6 +5,6 @@ export interface AssignmentSlideData { open_posts: number; assignment_related_users: { user: string; - elected: boolean; }[]; + number_poll_candidates: boolean; } diff --git a/client/src/app/slides/assignments/assignment/assignment-slide.component.html b/client/src/app/slides/assignments/assignment/assignment-slide.component.html index 368ac2366..63af8cf92 100644 --- a/client/src/app/slides/assignments/assignment/assignment-slide.component.html +++ b/client/src/app/slides/assignments/assignment/assignment-slide.component.html @@ -7,10 +7,16 @@

    Candidates

    -
      -
    • - {{ candidate.user }} - star -
    • -
    + +
      +
    1. + {{ candidate.user }} +
    2. +
    +
      +
    • + {{ candidate.user }} +
    • +
    +
    diff --git a/client/src/app/slides/assignments/poll/poll-slide-data.ts b/client/src/app/slides/assignments/poll/poll-slide-data.ts deleted file mode 100644 index 5a4b69462..000000000 --- a/client/src/app/slides/assignments/poll/poll-slide-data.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { PollVoteValue } from 'app/core/ui-services/poll.service'; -import { AssignmentPercentBase, AssignmentPollMethod } from 'app/site/assignments/services/assignment-poll.service'; - -export interface PollSlideOption { - user: string; - is_elected: boolean; - votes: { - weight: string; - value: PollVoteValue; - }[]; -} - -export interface PollSlideData { - title: string; - assignments_poll_100_percent_base: AssignmentPercentBase; - poll: { - published: boolean; - description?: string; - has_votes?: boolean; - pollmethod?: AssignmentPollMethod; - votesno?: string; - votesabstain?: string; - votesvalid?: string; - votesinvalid?: string; - votescast?: string; - options?: PollSlideOption[]; - }; -} diff --git a/client/src/app/slides/assignments/poll/poll-slide.component.html b/client/src/app/slides/assignments/poll/poll-slide.component.html deleted file mode 100644 index 17c44405b..000000000 --- a/client/src/app/slides/assignments/poll/poll-slide.component.html +++ /dev/null @@ -1,43 +0,0 @@ -
    -
    -

    {{ data.data.title }}

    -

    Election result

    -
    - -
    -
    Waiting for results...
    -
    -
    -
    -
    -

    Candidates

    -
    -
    -

    Votes

    -
    -
    -
    -
    -
    - {{ option.user }} - star -
    -
    -
    - {{ getLabel(vote.value) }}: - {{ getVotePercent(vote.value, option) }} -
    -
    -
    -
    -
    -
    - {{ getLabel(value) }} -
    -
    - {{ getPollPercent(value) }} -
    -
    -
    -
    -
    diff --git a/client/src/app/slides/assignments/poll/poll-slide.component.scss b/client/src/app/slides/assignments/poll/poll-slide.component.scss deleted file mode 100644 index c22687f65..000000000 --- a/client/src/app/slides/assignments/poll/poll-slide.component.scss +++ /dev/null @@ -1,31 +0,0 @@ -.row { - border-top: 1px solid #ddd; - display: table; - width: 100%; - - .heading { - text-transform: uppercase; - - h3 { - font-weight: normal; - } - } - - .option-name { - display: table-cell; - padding: 5px; - vertical-align: middle; - width: 70%; - } - - .option-percents { - display: table-cell; - padding: 5px; - width: 30%; - } - - .grey { - background-color: #ddd !important; - color: rgba(0, 0, 0, 0.87) !important; - } -} diff --git a/client/src/app/slides/assignments/poll/poll-slide.component.ts b/client/src/app/slides/assignments/poll/poll-slide.component.ts deleted file mode 100644 index 4282af97b..000000000 --- a/client/src/app/slides/assignments/poll/poll-slide.component.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Component, Input } from '@angular/core'; - -import { TranslateService } from '@ngx-translate/core'; - -import { SlideData } from 'app/core/core-services/projector-data.service'; -import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service'; -import { - AssignmentPollService, - CalculationData, - SummaryPollKey -} from 'app/site/assignments/services/assignment-poll.service'; -import { BaseSlideComponent } from 'app/slides/base-slide-component'; -import { PollSlideData, PollSlideOption } from './poll-slide-data'; - -@Component({ - selector: 'os-poll-slide', - templateUrl: './poll-slide.component.html', - styleUrls: ['./poll-slide.component.scss'] -}) -export class PollSlideComponent extends BaseSlideComponent { - private _data: SlideData; - - private calculationData: CalculationData; - - public get pollValues(): SummaryPollKey[] { - if (!this.data) { - return []; - } - const values: SummaryPollKey[] = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast']; - return values.filter(val => this.data.data.poll[val] !== null); - } - - @Input() - public set data(data: SlideData) { - this._data = data; - this.calculationData = { - pollMethod: data.data.poll.pollmethod, - votesno: parseFloat(data.data.poll.votesno), - votesabstain: parseFloat(data.data.poll.votesabstain), - votescast: parseFloat(data.data.poll.votescast), - votesvalid: parseFloat(data.data.poll.votesvalid), - votesinvalid: parseFloat(data.data.poll.votesinvalid), - pollOptions: data.data.poll.options.map(opt => { - return { - votes: opt.votes.map(vote => { - return { - weight: parseFloat(vote.weight), - value: vote.value - }; - }) - }; - }), - percentBase: data.data.assignments_poll_100_percent_base - }; - } - - public get data(): SlideData { - return this._data; - } - - public constructor(private pollService: AssignmentPollService, private translate: TranslateService) { - super(); - } - - /** - * get a vote's numerical or special label, including percent values if these are to - * be displayed - * - * @param key - * @param option - */ - public getVotePercent(key: PollVoteValue, option: PollSlideOption): string { - const calcOption = { - votes: option.votes.map(vote => { - return { weight: parseFloat(vote.weight), value: vote.value }; - }) - }; - const percent = this.pollService.getPercent(this.calculationData, calcOption, key); - const number = this.translate.instant( - this.pollService.getSpecialLabel(parseFloat(option.votes.find(v => v.value === key).weight)) - ); - return percent === null ? number : `${number} (${percent}%)`; - } - - public getPollPercent(key: CalculablePollKey): string { - const percent = this.pollService.getValuePercent(this.calculationData, key); - const number = this.translate.instant(this.pollService.getSpecialLabel(this.calculationData[key])); - return percent === null ? number : `${number} (${percent}%)`; - } - - /** - * @returns a translated label for a key - */ - public getLabel(key: CalculablePollKey): string { - return this.translate.instant(this.pollService.getLabel(key)); - } -} diff --git a/client/src/app/slides/assignments/poll/poll-slide.module.spec.ts b/client/src/app/slides/assignments/poll/poll-slide.module.spec.ts deleted file mode 100644 index da322455d..000000000 --- a/client/src/app/slides/assignments/poll/poll-slide.module.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { PollSlideModule } from './poll-slide.module'; - -describe('PollSlideModule', () => { - let pollSlideModule: PollSlideModule; - - beforeEach(() => { - pollSlideModule = new PollSlideModule(); - }); - - it('should create an instance', () => { - expect(pollSlideModule).toBeTruthy(); - }); -}); diff --git a/client/src/app/slides/assignments/poll/poll-slide.module.ts b/client/src/app/slides/assignments/poll/poll-slide.module.ts deleted file mode 100644 index 0d538cde8..000000000 --- a/client/src/app/slides/assignments/poll/poll-slide.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NgModule } from '@angular/core'; - -import { makeSlideModule } from 'app/slides/base-slide-module'; -import { PollSlideComponent } from './poll-slide.component'; - -@NgModule(makeSlideModule(PollSlideComponent)) -export class PollSlideModule {} diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts b/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts new file mode 100644 index 000000000..1ca3e479f --- /dev/null +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts @@ -0,0 +1,27 @@ +import { MotionPollMethod } from 'app/shared/models/motions/motion-poll'; +import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll'; +import { MotionTitleInformation } from 'app/site/motions/models/view-motion'; +import { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data'; + +export interface MotionPollSlideData extends BasePollSlideData { + motion: MotionTitleInformation; + poll: { + title: string; + type: PollType; + pollmethod: MotionPollMethod; + state: PollState; + onehundred_percent_base: PercentBase; + majority_method: MajorityMethod; + + options: { + yes?: number; + no?: number; + abstain?: number; + }[]; + + // optional for published polls: + votesvalid: number; + votesinvalid: number; + votescast: number; + }; +} diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.html b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.html new file mode 100644 index 000000000..f0f692b1c --- /dev/null +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.html @@ -0,0 +1,12 @@ + +
    +

    + {{ data.data.motion.identifier }}: + {{ data.data.motion.title }} +

    +

    {{ data.data.poll.title }}

    +
    +
    + +
    +
    diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.scss b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.scss new file mode 100644 index 000000000..5f08bc87a --- /dev/null +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.scss @@ -0,0 +1,5 @@ +@import '~assets/styles/poll-colors.scss'; + +.motion-title { + margin: 0 0 10px; +} diff --git a/client/src/app/slides/assignments/poll/poll-slide.component.spec.ts b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.spec.ts similarity index 58% rename from client/src/app/slides/assignments/poll/poll-slide.component.spec.ts rename to client/src/app/slides/motions/motion-poll/motion-poll-slide.component.spec.ts index 3d56db30e..f21eea502 100644 --- a/client/src/app/slides/assignments/poll/poll-slide.component.spec.ts +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.spec.ts @@ -1,21 +1,21 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { E2EImportsModule } from '../../../../e2e-imports.module'; -import { PollSlideComponent } from './poll-slide.component'; +import { MotionPollSlideComponent } from './motion-poll-slide.component'; -describe('PollSlideComponent', () => { - let component: PollSlideComponent; - let fixture: ComponentFixture; +describe('MotionPollSlideComponent', () => { + let component: MotionPollSlideComponent; + let fixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [E2EImportsModule], - declarations: [PollSlideComponent] + declarations: [MotionPollSlideComponent] }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(PollSlideComponent); + fixture = TestBed.createComponent(MotionPollSlideComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.ts b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.ts new file mode 100644 index 000000000..88388ec57 --- /dev/null +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.ts @@ -0,0 +1,42 @@ +import { Component } from '@angular/core'; + +import { PollState } from 'app/shared/models/poll/base-poll'; +import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; +import { PollData, PollService, PollTableData } from 'app/site/polls/services/poll.service'; +import { BasePollSlideComponent } from 'app/slides/polls/base-poll-slide.component'; +import { MotionPollSlideData } from './motion-poll-slide-data'; + +@Component({ + selector: 'os-motion-poll-slide', + templateUrl: './motion-poll-slide.component.html', + styleUrls: ['./motion-poll-slide.component.scss'] +}) +export class MotionPollSlideComponent extends BasePollSlideComponent { + public PollState = PollState; + + public pollData: PollData; + public voteYes: number; + public voteNo: number; + public voteAbstain: number; + + public constructor(pollService: PollService, private motionPollService: MotionPollService) { + super(pollService); + this.chartDataSubject.subscribe(() => { + if (this.data && this.data.data) { + this.pollData = this.data.data.poll as PollData; + const result = this.pollData.options[0]; + this.voteYes = result.yes; + this.voteNo = result.no; + this.voteAbstain = result.abstain; + } + }); + } + + public showChart(): boolean { + return this.motionPollService.showChart(this.pollData); + } + + public getTableData(): PollTableData[] { + return this.motionPollService.generateTableData(this.pollData); + } +} diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide.module.spec.ts b/client/src/app/slides/motions/motion-poll/motion-poll-slide.module.spec.ts new file mode 100644 index 000000000..b030f82b3 --- /dev/null +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide.module.spec.ts @@ -0,0 +1,13 @@ +import { MotionPollSlideModule } from './motion-poll-slide.module'; + +describe('MotionPollSlideModule', () => { + let motionPollSlideModule: MotionPollSlideModule; + + beforeEach(() => { + motionPollSlideModule = new MotionPollSlideModule(); + }); + + it('should create an instance', () => { + expect(motionPollSlideModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide.module.ts b/client/src/app/slides/motions/motion-poll/motion-poll-slide.module.ts new file mode 100644 index 000000000..cba9881d9 --- /dev/null +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; + +import { makeSlideModule } from 'app/slides/base-slide-module'; +import { MotionPollSlideComponent } from './motion-poll-slide.component'; + +@NgModule(makeSlideModule(MotionPollSlideComponent)) +export class MotionPollSlideModule {} diff --git a/client/src/app/slides/polls/base-poll-slide-data.ts b/client/src/app/slides/polls/base-poll-slide-data.ts new file mode 100644 index 000000000..1790d84bd --- /dev/null +++ b/client/src/app/slides/polls/base-poll-slide-data.ts @@ -0,0 +1,22 @@ +import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll'; + +export interface BasePollSlideData { + poll: { + title: string; + type: PollType; + state: PollState; + onehundred_percent_base: PercentBase; + majority_method: MajorityMethod; + pollmethod: string; + + options: { + yes?: number; + no?: number; + abstain?: number; + }[]; + + votesvalid: number; + votesinvalid: number; + votescast: number; + }; +} diff --git a/client/src/app/slides/polls/base-poll-slide.component.ts b/client/src/app/slides/polls/base-poll-slide.component.ts new file mode 100644 index 000000000..4612b327b --- /dev/null +++ b/client/src/app/slides/polls/base-poll-slide.component.ts @@ -0,0 +1,36 @@ +import { forwardRef, Inject, Input } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; + +import { SlideData } from 'app/core/core-services/projector-data.service'; +import { ChartData } from 'app/shared/components/charts/charts.component'; +import { PollState } from 'app/shared/models/poll/base-poll'; +import { PollService } from 'app/site/polls/services/poll.service'; +import { BasePollSlideData } from './base-poll-slide-data'; +import { BaseSlideComponent } from '../base-slide-component'; + +export class BasePollSlideComponent extends BaseSlideComponent { + public chartDataSubject: BehaviorSubject = new BehaviorSubject([]); + + @Input() + public set data(value: SlideData) { + this._data = value; + if (value.data.poll.state === PollState.Published) { + const chartData = this.pollService.generateChartData(value.data.poll); + this.chartDataSubject.next(chartData); + } + } + + public get data(): SlideData { + return this._data; + } + + private _data: SlideData; + + public constructor( + @Inject(forwardRef(() => PollService)) + public pollService: PollService + ) { + super(); + } +} diff --git a/client/src/assets/i18n/de.json b/client/src/assets/i18n/de.json index 71031451e..2c6cca603 100644 --- a/client/src/assets/i18n/de.json +++ b/client/src/assets/i18n/de.json @@ -1 +1 @@ -{"%num% emails were send sucessfully.":"%num% E-Mails wurden erfolgreich versandt.","OpenSlides is a free web based presentation and assembly system for visualizing and controlling agenda, motions and elections of an assembly.":"OpenSlides ist ein freies, webbasiertes Prรคsentations- und Versammlungssystem zur Darstellung und Steuerung von Tagesordnung, Antrรคgen und Wahlen einer Versammlung.","":"","A name is required":"Ein Name ist erforderlich","A new update is available!":"Ein neues Update ist verfรผgbar!","A password is required":"Ein Passwort ist erforderlich","A server error occured. Please contact your system administrator.":"Ein Serverfehler ist aufgetreten. Bitte kontaktieren Sie den Administrator.","A title is required":"Ein Titel ist erforderlich","About me":"รœber mich","Abstain":"Enthaltung","Accept":"Annehmen","Acceptance":"Annahme","Access data (PDF)":"Zugangsdaten (PDF)","Access groups":"Zugriffsgruppen","Access-data":"Zugangsdaten","Activate amendments":"ร„nderungsantrรคge aktivieren","Activate statute amendments":"Satzungsรคnderungsantrรคge aktivieren","Active":"Aktiv","Active filters":"Aktive Filter","Add":"Hinzufรผgen","Add countdown":"Countdown hinzufรผgen","Add me":"Fรผge mich hinzu","Add message":"Mitteilung hinzufรผgen","Add new custom translation":"Neue benutzerdefinierte รœbersetzung hinzufรผgen","Add to agenda":"Zur Tagesordnung hinzufรผgen","Add to queue":"Zur Warteschlange hinzufรผgen","Add/remove groups ...":"Gruppen hinzufรผgen/entfernen ...","Add/remove submitters":"Antragsteller hinzufรผgen/entfernen","Add/remove tags":"Schlagwรถrter hinzufรผgen/entfernen","Additional columns after the required ones may be present and won't affect the import.":"Zusรคtzliche Spalten nach den erforderlichen Spalten kรถnnen vorhanden sein und haben keinen Einfluss auf den Import.","Adjourn":"Vertagen","Adjournment":"Vertagung","Admin":"Admin","After verifiy the preview click on 'import' please (see top right).":"Nach Prรผfung der Vorschau klicken Sie bitte auf 'Importieren' (oben rechts).","Agenda":"Tagesordnung","Agenda visibility":"Sichtbarkeit in der Tagesordnung","All casted ballots":"Alle abgegebenen Stimmzettel","All topics will be deleted and won't be accessible afterwards.":"Alle Themen werden gelรถscht und sind danach nicht mehr zugรคnglich.","All valid ballots":"Alle gรผltigen Stimmzettel","All your changes are saved immediately.":"Alle ร„nderungen werden sofort gespeichert.","Allow access for anonymous guest users":"Erlaube Zugriff fรผr anonyme Gast-Nutzer","Allow amendments of amendments":"ร„nderungsantrรคge zu ร„nderungsantrรคgen erlauben","Allow blank in identifier":"Leerzeichen im Bezeichner erlauben","Allow create poll":"Abstimmung mรถglich","Allow submitter edit":"Antragsteller/in darf bearbeiten","Allow support":"Unterstรผtzung mรถglich","Allowed access groups for this directory":"Zulรคssige Zugriffsgruppen fรผr dieses Verzeichnis","Always":"Immer","Always Yes-No-Abstain per candidate":"Ja/Nein/Enthaltung pro Kandidat/in","Always Yes/No per candidate":"Ja/Nein pro Kandidat/in","Always one option per candidate":"Eine Stimme pro Kandidat/in","Amendment":"ร„nderungsantrag","Amendment list (PDF)":"ร„nderungsantragsliste (PDF)","Amendment text":"ร„nderungsantragstext","Amendment to":"ร„nderungsantrag zu","Amendments":"ร„nderungsantrรคge","Amendments can change multiple paragraphs":"ร„nderungsantrรคge kรถnnen mehrere Absรคtze รคndern","Amendments to":"ร„nderungsantrรคge zu","An email with a password reset link was send!":"Es wurde eine E-Mail mit Link zum Passwort-Zurรผcksetzen gesendet.","An unknown error occurred.":"Ein unbekannter Fehler ist aufgetreten.","Apply":"รœbernehmen","Arabic":"Arabisch","Are you sure you want to copy the final version to the print template?":"Soll die Beschlussfassung weiter bearbeitet und eine Beschluss-Druckvorlage erstellt werden?","Are you sure you want to delete all selected elections?":"Sollen alle ausgewรคhlten Wahlen wirklich gelรถscht werden?","Are you sure you want to delete all selected files and folders?":"Sollen alle ausgewรคhlten Dateien und Verzeichnisse wirklich gelรถscht werden?","Are you sure you want to delete all selected motions?":"Sollen alle ausgewรคhlten Antrรคge wirklich gelรถscht werden?","Are you sure you want to delete all selected participants?":"Sollen alle ausgewรคhlten Teilnehmende wirklich gelรถscht werden?","Are you sure you want to delete all speakers from this list of speakers?":"Sollen wirklich alle Redner/innen von dieser Liste entfernt werden?","Are you sure you want to delete the print template?":"Soll die Beschluss-Druckvorlage wirklich gelรถscht werden?","Are you sure you want to delete this ballot?":"Soll dieser Wahlgang wirklich gelรถscht werden?","Are you sure you want to delete this category and all subcategories?":"Soll dieses Sachgebiet und deren Untersachgebiete wirklich gelรถscht werden?","Are you sure you want to delete this change recommendation?":"Soll diese ร„nderungsempfehlung wirklich gelรถscht werden?","Are you sure you want to delete this comment field?":"Soll dieses Kommentarfeld wirklich gelรถscht werden?","Are you sure you want to delete this election?":"Soll diese Wahl wirklich gelรถscht werden?","Are you sure you want to delete this entry?":"Soll dieser Eintrag wirklich gelรถscht werden?","Are you sure you want to delete this file?":"Soll diese Datei wirklich gelรถscht werden?","Are you sure you want to delete this group?":"Soll diese Gruppe wirklich gelรถscht werden?","Are you sure you want to delete this motion block?":"Soll dieser Antragsblock wirklich gelรถscht werden?","Are you sure you want to delete this motion?":"Soll dieser Antrag wirklich gelรถscht werden?","Are you sure you want to delete this participant?":"Soll diese/r Teilnehmende wirklich gelรถscht werden?","Are you sure you want to delete this projector?":"Soll dieser Projektor wirklich gelรถscht werden?","Are you sure you want to delete this speaker from this list of speakers?":"Soll diese/r Redner/in von der Redeliste wirklich entfernt werden?","Are you sure you want to delete this statute paragraph?":"Soll dieser Satzungsabschnitt wirklich gelรถscht werden?","Are you sure you want to delete this tag?":"Soll dieses Schlagwort wirklich gelรถscht werden?","Are you sure you want to delete this topic?":"Soll dieses Thema wirklich gelรถscht werden?","Are you sure you want to delete this workflow?":"Soll dieser Arbeitsablauf wirklich gelรถscht werden?","Are you sure you want to discard this amendment?":"Soll dieser ร„nderungsantrag wirklich verworfen werden?","Are you sure you want to generate new passwords for all selected participants?":"Sollen wirklich neue Passwรถrter fรผr alle ausgewรคhlte Teilnehmende generiert werden?","Are you sure you want to number all agenda items?":"Sollen alle Tagesordnungspunkte wirklich neu nummeriert werden?","Are you sure you want to override the state of all motions of this motion block?":"Soll der Status von allen Antrรคgen aus diesem Antragsblock wirklich รผberschrieben werden?","Are you sure you want to remove all selected items from the agenda?":"Sollen alle ausgewรคhlten Eintrรคge wirklich aus der Tagesordnung entfernt werden?","Are you sure you want to remove this entry from the agenda?":"Soll dieser Eintrag wirklich aus der Tagesordnung entfernt werden?","Are you sure you want to remove this motion from motion block?":"Soll dieser Antrag wirklich aus dem Antragsblock entfernt werden?","Are you sure you want to renumber all motions of this category?":"Sollen alle Antrรคge dieses Sachgebiets wirklich neu nummeriert werden?","Are you sure you want to reset all options to factory defaults? All changes of this settings group will be lost!":"Wollen Sie wirklich alle Werte auf Werkseinstellungen zurรผcksetzen? Alle ร„nderungen auf dieser Einstellungsseite gehen verloren!","Are you sure you want to reset all options to factory defaults? Changes of all settings group will be lost!":"Wollen Sie wirklich alle Werte auf Werkseinstellungen zurรผcksetzen? ร„nderungen in allen Einstellungsrubriken gehen verloren!","Are you sure you want to reset all passwords to the default ones?":"Sollen wirklich alle Passwรถrter auf die initialen Passwรถrter zurรผckgesetzt werden?","Are you sure you want to send an invitation email to the user?":"Soll wirklich eine E-Mail den diesen Nutzer gesendet werden?","Are you sure you want to send emails to all selected participants?":"Sollen E-Mails wirklich an alle ausgewรคhlten Teilnehmende gesendet werden?","As of":"Stand","As recommendation":"wie Empfehlung","Ask, default no":"Nachfragen, voreingestellt nein","Ask, default yes":"Nachfragen, voreingestellt ja","Attachments":"Anhรคnge","Automatic assign of method":"Automatische Zuordnung der Methode","Back":"Zurรผck","Back to login":"Zurรผck zur Anmeldung","Ballot":"Wahlgang","Ballot and ballot papers":"Wahlgang und Stimmzettel","Base folder":"Basisverzeichnis","Begin of event":"Beginn der Veranstaltung","Begin speech":"Rede beginnen","Blank between prefix and number, e.g. 'A 001'.":"Leerzeichen zwischen Prรคfix und Nummer, z. B. 'A 001'.","CSV import":"CSV-Import","Call list":"Aufrufliste","Called":"Aufgerufen wird","Called with":"Mit aufgerufen werden","Can change its own password":"Darf eigenes Passwort รคndern","Can create amendments":"Darf ร„nderungsantrรคge stellen","Can create motions":"Darf Antrรคge erstellen","Can manage agenda":"Darf die Tagesordung verwalten","Can manage comments":"Darf Kommentare verwalten","Can manage configuration":"Darf die Konfiguration verwalten","Can manage elections":"Darf Wahlen verwalten","Can manage files":"Darf Dateien verwalten","Can manage list of speakers":"Darf Redelisten verwalten","Can manage logos and fonts":"Darf Logos und Schriften verwalten","Can manage motion metadata":"Darf Antragsmetadaten verwalten","Can manage motions":"Darf Antrรคge verwalten","Can manage tags":"Darf Schlagwรถrter verwalten","Can manage the projector":"Darf den Projektor steuern","Can manage users":"Darf Benutzer verwalten","Can nominate another participant":"Darf andere Teilnehmende fรผr Wahlen vorschlagen","Can nominate oneself":"Darf selbst fรผr Wahlen kandidieren","Can put oneself on the list of speakers":"Darf sich selbst auf die Redeliste setzen","Can see agenda":"Darf die Tagesordnung sehen","Can see comments":"Darf Kommentare sehen","Can see elections":"Darf Wahlen sehen","Can see extra data of users (e.g. present and comment)":"Darf die zusรคtzlichen Daten der Benutzer sehen (z. B. anwesend und Kommentar)","Can see hidden files":"Darf versteckte Dateien sehen","Can see history":"Darf die Chronik sehen","Can see internal items and time scheduling of agenda":"Darf interne Eintrรคge und Zeitplan der Tagesordnung sehen","Can see list of speakers":"Darf Redelisten sehen","Can see motions":"Darf Antrรคge sehen","Can see motions in internal state":"Darf Antrรคge im internen Status sehen","Can see names of users":"Darf die Namen der Benutzer sehen","Can see the front page":"Darf die Startseite sehen","Can see the list of files":"Darf die Dateiliste sehen","Can see the projector":"Darf den Projektor sehen","Can support motions":"Darf Antrรคge unterstรผtzen","Can upload files":"Darf Dateien hochladen","Cancel":"Abbrechen","Cancel edit":"Bearbeitung abbrechen","Candidates":"Kandidaten/innen","Cannot create PDF files on this browser.":"In diesem Browser kรถnnen keine PDF-Dateien erstellt werden.","Categories":"Sachgebiete","Category":"Sachgebiet","Center":"Mittig","Change paragraph":"Absatz รคndern","Change password":"Passwort รคndern","Change password for":"Passwort รคndern fรผr","Change presence":"Anwesenheit รคndern","Change recommendation":"ร„nderungsempfehlung","Change recommendations":"ร„nderungsempfehlungen","Changed by":"Bearbeitet von","Changed title":"Geรคnderter Titel","Changed version":"Geรคnderte Fassung","Changed version in line":"Geรคnderte Fassung in Zeile","Changes":"ร„nderungen","Check for updates":"Auf Updates prรผfen","Check in or check out participants based on their participant numbers:":"An- oder Abmeldung von Teilnehmenden basierend auf ihren Teilnehmernummern:","Choose 0 to disable the supporting system.":"Zum Deaktivieren des Unterstรผtzersystems '0' eingeben.","Chyron":"Bauchbinde","Clear all":"Alle lรถschen","Clear all filters":"Alle Filter lรถschen","Clear list":"Liste leeren","Clear motion block":"Antragsblock lรถschen","Clear tags":"Schlagwรถrter lรถschen","Close":"SchlieรŸen","Close list of speakers":"Redeliste schlieรŸen","Closed items":"Erledigte Eintrรคge","Collapse all":"Alle zusammenklappen","Column separator":"Spaltentrenner","Comma separated names will be read as 'Surname, given name(s)'.":"Kommaseparierte Namen werden als 'Nachname, Vorname(n)' interpretiert.","Comment":"Kommentar","Comment fields":"Kommentarfelder","Comments":"Kommentare","Committee":"Gremium","Committees":"Gremien","Complex Workflow":"Komplexer Arbeitsablauf","Confirm new password":"Neues Passwort bestรคtigen","Content":"Inhalt","Copy and paste your participant names in this textbox.":"Kopieren Sie die Name Ihrer Teilnehmer/innen in diese Textbox.","Count active users":"Aktive Nutzer zรคhlen","Countdown":"Countdown","Countdown and traffic light":"Countdown und Ampel","Countdown description":"Countdown-Beschreibung","Countdown time":"Countdown-Zeit","Countdown title":"Countdown-Titel","Countdowns":"Countdowns","Couple countdown with the list of speakers":"Countdown mit der Redeliste verkoppeln","Create":"Erstellen","Create final print template":"Beschluss-Druckvorlage erstellen","Creating PDF file ...":"PDF-Datei wird erstellt ...","Creation date":"Erstellungsdatum","Current browser language":"Aktuelle Browsersprache","Current date":"Aktuelles Datum","Current list of speakers":"Aktuelle Redeliste","Custom aspect ratio":"Eigenes Seitenverhรคltnis","Custom number of ballot papers":"Benutzerdefinierte Anzahl von Stimmzetteln","Custom translations":"Benutzerdefinierte รœbersetzungen","Dear {name},\n\nthis is your OpenSlides login for the event {event_name}:\n\n {url}\n username: {username}\n password: {password}\n\nThis email was generated automatically.":"Hallo {name},\n\ndies ist Ihr OpenSlides-Zugang fรผr die Veranstaltung {event_name}:\n\n {url}\n Benutzername: {username}\n Passwort: {password}\n\nDiese E-Mail wurde automatisch erstellt.","Decision":"Entscheidung","Default":"Standard","Default comment on the ballot paper":"Voreingestellter Hinweis auf Stimmzettel","Default encoding for all csv exports":"Voreingestelltes Encoding fรผr alle CSV-Exporte","Default group":"Vorgegebene Gruppen","Default line numbering":"Voreingestellte Zeilennummerierung","Default method to check whether a candidate has reached the required majority.":"Voreingestellte Methode zur รœberprรผfung ob ein Kandidat die nรถtige Mehrheit erreicht hat.","Default method to check whether a motion has reached the required majority.":"Voreingestellte Methode zur รœberprรผfung ob ein Antrag die nรถtige Mehrheit erreicht hat.","Default projector":"Standardprojektor","Default text version for change recommendations":"Voreingestellte Fassung fรผr ร„nderungsempfehlungen","Default visibility for new agenda items (except topics)":"Voreingestellte Sichtbarkeit fรผr neue Tagesordnungspunkte (auรŸer Themen)","Delegates":"Delegierte","Delete":"Lรถschen","Delete final print template":"Beschluss-Druckvorlage lรถschen","Delete message":"Mitteilung lรถschen","Delete projector":"Projektor lรถschen","Delete recommendation":"Empfehlung lรถschen","Delete whole history":"Chronik lรถschen","Deletion":"Streichung","Description":"Beschreibung","Deselect all":"Alle abwรคhlen","Designates whether this user is in the room.":"Bestimmt, ob dieser Benutzer vor Ort ist.","Designates whether this user should be treated as a committee.":"Legt fest, ob dieser Benutzer als Gremium behandelt werden soll.","Designates whether this user should be treated as active. Unselect this instead of deleting the account.":"Bestimmt, ob dieser Benutzer als aktiv behandelt werden soll. Sie kรถnnen ihn deaktivieren anstatt ihn zu lรถschen.","Didn't get an email":"Bekam keine E-Mail","Diff version":"ร„nderungsdarstellung","Disabled":"Deaktiviert","Disabled (no percents)":"Deaktiviert (keine Prozente)","Display type":"Anzeigeformat","Divergent:":"abweichend:","Do not concern":"Nicht befassen","Do not decide":"Nicht entscheiden","Do not forget to save your changes!":"Vergessen Sie nicht Ihre ร„nderungen zu speichern!","Do not set identifier":"Bezeichner nicht setzen","Do you really want to exit this page?":"Wollen Sie diese Seite wirklich verlassen?","Do you really want to go ahead?":"Wollen Sie die Aktion wirklich fortsetzen?","Do you really want to save your changes?":"Wollen Sie wirklich Ihre ร„nderungen speichern?","Does not have notes":"Hat keine Notizen","Done":"erledigt","Download CSV example file":"CSV-Beispiel-Datei herunterladen","Drop files into this area OR click here to select files":"Dateien auf diesen Bereich ziehen ODER hier klicken um Dateien auszuwรคhlen","Duration":"Dauer","Edit":"Bearbeiten","Edit comment field":"Kommentarfeld bearbeiten","Edit details":"Details bearbeiten","Edit details for":"Details bearbeiten fรผr","Edit projector":"Projektor bearbeiten","Edit statute paragraph":"Satzungsabschnitt bearbeiten","Edit tag":"Schlagwort bearbeiten","Edit the whole motion text":"Vollstรคndigen Antragstext bearbeiten","Edit topic":"Thema bearbeiten","Elected":"Gewรคhlt","Election":"Wahl","Election documents":"Wahlunterlagen","Election method":"Wahlmethode","Election result":"Wahlergebnis","Elections":"Wahlen","Element":"Element","Email":"E-Mail","Email body":"Nachrichtentext","Email sent":"E-Mail gesendet","Email subject":"Betreff","Empty text field":"Leeres Textfeld","Enable numbering for agenda items":"Nummerierung von Tagesordnungspunkten aktivieren","Enable participant presence view":"Ansicht zur Teilnehmeranwesenheit aktivieren","Enable/disable account ...":"Account ein-/ausschalten ...","Encoding of the file":"Encoding der Datei","End speech":"Rede beenden","Enforce page breaks":"Seitenumbrรผche erzwingen","Enter duration in seconds. Choose 0 to disable warning color.":"Geben Sie die Dauer in Sekunden an. Zum Deaktivieren der Warn-Farbe 0 auswรคhlen.","Enter number of the next shown speakers. Choose -1 to show all next speakers.":"Geben Sie die Anzahl der nรคchsten anzuzeigenden Redner/innen ein. Wรคhlen Sie -1 um alle anzuzeigen.","Enter participant number":"Teilnehmernummer eingeben","Enter votes":"Stimmen eingeben","Enter your email to send the password reset link":"Geben Sie Ihre E-Mail-Adresse ein um eine Link zum Zurรผcksetzen des Passworts zu erhalten.","Error":"Fehler","Error during PDF creation of election:":"Fehler bei der PDF-Erstellung der Wahl:","Error during PDF creation of motion:":"Fehler bei der PDF-Erstellung in Antrag","Error: The new passwords do not match.":"Fehler: Die neuen Passwรถrter stimmen nicht รผberein.","Estimated end":"Voraussichtliches Ende","Event":"Veranstaltung","Event date":"Veranstaltungszeitraum","Event location":"Veranstaltungsort","Event name":"Veranstaltungsname","Event organizer":"Veranstalter","Exit":"Beenden","Expand all":"Alle ausklappen","Export":"Exportieren","Export ...":"Exportieren ...","Export as CSV":"Exportieren als CSV","Export as PDF":"Als PDF exportieren","Export comment":"Kommentar exportieren","Export motions":"Antrรคge exportieren","Export personal note only":"Nur persรถnliche Notiz exportieren","Export selected elections":"Ausgewรคhlte Wahlen exportieren","Export selected motions":"Ausgewรคhlte Antrรคge exportieren","Extension":"Erweiterung","Favorites":"Favoriten","File information":"Dateiinformationen","File name":"Dateiname","Files":"Dateien","Filter":"Filter","Final print template":"Beschluss-Druckvorlage","Final version":"Beschlussfassung","Finished":"Abgeschlossen","First state":"Erster Status","Follow recommendation":"Empfehlung folgen","Follow recommendations for all motions":"Empfehlungen fรผr alle Antrรคge folgen","Following users are currently editing this motion:":"Folgende Nutzer bearbeiten aktuell diesen Antrag:","For Yes/No/Abstain per candidate and Yes/No per candidate the 100-%-base depends on the election method: If there is only one option per candidate, the sum of all votes of all candidates is 100 %. Otherwise for each candidate the sum of all votes is 100 %.":"Fรผr Ja/Nein/Enthaltung pro Kandidat und Ja/Nein pro Kandidat hรคngt die 100%-Basis von der Wahlmethode ab: Wenn es nur eine Option pro Kandidat gibt, ist 100% die Summe aller Stimmen von allen Kandidaten. Andernfalls ist 100% die Summe aller Stimmen pro Kandidat.","Forgot Password?":"Passwort vergessen?","Format":"Format","Front page text":"Text der Startseite","Front page title":"Titel der Startseite","Fullscreen":"Vollbild","Gender":"Geschlecht","General":"Allgemein","Generate new passwords":"Neue Passwรถrter generieren","Generate password":"Passwort generieren","Given name":"Vorname","Go to line":"Springe zur Zeile","Got an email":"Bekam eine E-Mail","Groups":"Gruppen","Groups with read permissions":"Gruppen mit Leseberechtigungen","Groups with write permissions":"Gruppen mit Schreibberechtigungen","Guest":"Gast","Has amendments":"Hat ร„nderungsantrรคge","Has no speakers":"Keine Wortmeldungen vorhanden","Has notes":"Hat Notizen","Has speakers":"Wortmeldungen vorhanden","Help text for access data and welcome PDF":"Hilfetext fรผr das Zugangsdaten- und Willkommens-PDF","Hidden item":"Versteckter Eintrag","Hide internal items when projecting subitems":"Interne Eintrรคge ausblenden bei der Projektion von Untereintrรคgen","Hide more text":"weniger anzeigen","Hide motion text on projector":"Antragstext auf dem Projektor ausblenden","Hide password":"Passwort verstecken","Hide reason on projector":"Begrรผndung auf dem Projektor ausblenden","Hide recommendation on projector":"Empfehlung auf dem Projektor ausblenden","Hide referring motions":"Verweisende Antrรคge ausblenden","Hide the amount of speakers in subtitle of list of speakers slide":"Anzahl der Redner/innen im Untertitel der Redelistenprojektion ausblenden","Hint for ballot paper":"Hinweis auf dem Stimmzettel","History":"Chronik","Home":"Startseite","How to create new amendments":"Erstellung von ร„nderungsantrรคgen","Identifier":"Bezeichner","Identifier, reason, submitter, category, origin and motion block are optional and may be empty.":"Bezeichner, Begrรผndung, Antragsteller/in, Sachgebiet, Herkunft und Antragsblock sind optional und dรผrfen leer sein.","Import":"Importieren","Import motions":"Antrรคge importieren","Import participants":"Teilnehmende importieren","Import statute":"Satzung importieren","Import topics":"Themen importieren","In motion list, motion detail and PDF.":"In Antragsliste, Detailansicht und PDF.","Inactive":"Inaktiv","Initial password":"Initiales Passwort","Initiate update check for all clients":"Update-Prรผfung fรผr alle Clients starten","Input format: DD.MM.YYYY HH:MM":"Eingabeformat: TT.MM.JJJJ HH:MM","Insert after":"Danach einfรผgen","Insert before":"Davor einfรผgen","Insert behind":"Dahinter einfรผgen","Insert participants here":"Teilnehmende hier importieren","Insert topics here":"Themen hier importieren","Insertion":"Ergรคnzung","Installed plugins":"Installierte Plugins","Internal":"Intern","Internal item":"Interner Eintrag","Invalid input.":"Ungรผltige Eingabe.","Invalid line number":"Ungรผltige Zeilennummer","Invalid votes":"Ungรผltige Stimmen","Is SAML user":"Ist SAML-Nutzer","Is a committee":"Ist ein Gremium","Is active":"Ist aktiv","Is already projected":"ist bereits projiziert","Is amendment":"Ist ein ร„nderungsantrag","Is committee":"Ist Gremium","Is favorite":"Ist Favorit","Is no amendment and has no amendments":"Ist kein ร„nderungsantrag und hat keine ร„nderungsantrรคge","Is not a committee":"Ist kein Gremium","Is not active":"Ist nicht aktiv","Is not favorite":"Ist kein Favorit","Is not present":"Ist nicht anwesend","Is present":"Ist anwesend","Item number":"Tagesordnungspunkt-Nummer","Keep each item in a single line.":"Verwenden Sie eine Zeile pro Eintrag.","Keep each person in a single line.":"Verwenden Sie eine Zeile pro Person.","Label color":"Beschriftungsfarbe","Last email send":"Letzte gesendet E-Mail","Last email sent":"Letzte gesendete E-Mail","Last modified":"Zuletzt geรคndert","Last speakers":"Letzte Redner/innen","Left":"Links","Legal notice":"Impressum","License":"Lizenz","Line":"Zeile","Line length":"Zeilenlรคnge","Line numbering":"Zeilennummerierung","List of participants":"Teilnehmendenliste","List of participants (PDF)":"Teilnehmendenliste (PDF)","List of speakers":"Redeliste","List of speakers overlay":"Redelisten-Einblendung","List view":"Listenansicht","Loading data. Please wait ...":"Daten werden geladen. Bitte warten ...","Login":"Anmelden","Login as guest":"Als Gast anmelden","Logout":"Abmelden","Main motion and line number":"Hauptantrag und Zeilennummer","Mark as elected":"Als gewรคhlt markieren","Mark speaker":"Redner/in markieren","Media file":"Mediendatei","Message":"Mitteilung","Messages":"Mitteilungen","Meta information":"Metainformationen","Motion":"Antrag","Motion block":"Antragsblock","Motion blocks":"Antragsblรถcke","Motion change recommendation created":"ร„nderungsempfehlung erstellt","Motion change recommendation deleted":"ร„nderungsempfehlung gelรถscht","Motion change recommendation updated":"ร„nderungsempfehlung aktualisiert","Motion created":"Antrag erstellt","Motion deleted":"Antrag gelรถscht","Motion has been imported":"Antrag wurde importiert","Motion preamble":"Antragseinleitung","Motion text":"Antragstext","Motion updated":"Antrag aktualisiert","Motion will be imported":"Antrag wird importiert","Motion(s) will be imported.":"Antrรคge werden importieren.","Motions":"Antrรคge","Motions are in process. Please wait ...":"Antrรคge werden bearbeitet. Bitte warten ...","Motions have been imported.":"Antrรคge wurden importiert.","Move":"Verschieben","Move in call list":"In Aufrufliste verschieben","Move into directory":"In Verzeichnis verschieben","Move selected items ...":"Ausgewรคhlte Eintrรคge verschieben ...","Move to agenda item":"Zu TOP verschieben","Multiselect":"Mehrfachauswahl","Name":"Name","Name of recommender":"Name des Empfehlungsgebers","Name of recommender for statute amendments":"Name des Empfehlungsgebers fรผr Satzungsรคnderungsantrรคge","Needs review":"Benรถtigt Review","Never":"Nie","New Projector":"Neuer Projektor","New amendment":"Neuer ร„nderungsantrag","New ballot":"Neuer Wahlgang","New category":"Neues Sachgebiet","New change recommendation":"Neue ร„nderungsempfehlung","New comment field":"Neues Kommentarfeld","New directory":"Neues Verzeichnis","New election":"Neue Wahl","New file name":"Neuer Dateiname","New group name":"Neuer Gruppenname","New motion":"Neuer Antrag","New motion block":"Neuer Antragsblock","New participant":"Neue/r Teilnehmer/in","New password":"Neues Passwort","New state":"Neuer Status","New statute paragraph":"Neuer Satzungsabschnitt","New tag":"Neues Schlagwort","New topic":"Neues Thema","New vote":"Neue Abstimmung","New workflow":"Neuer Arbeitsablauf","Next":"Weiter","Next states":"Nรคchste Zustรคnde","No":"Nein","No category":"Kein Sachgebiet","No category set":"kein Sachgebiet gesetzt","No change recommendations yet":"Bisher keine ร„nderungsempfehlungen","No changes at the text.":"Keine ร„nderung am Text.","No comment":"Kein Kommentar","No concernment":"Nichtbefassung","No data":"Keine Daten","No decision":"Keine Entscheidung","No email sent":"Keine E-Mail gesendet","No emails were send.":"Es wurden keine E-Mails versandt.","No encryption":"Keine Verschlรผsselung","No groups selected":"Keine Gruppen ausgewรคhlt","No information available":"Keine Informationen verfรผgbar","No items selected":"Keine Eintrรคge ausgewรคhlte","No motion block set":"kein Antragsblock gesetzt","No personal note":"Keine persรถnliche Notiz","No recommendation":"keine Empfehlung gesetzt","No search result found":"Keine Suchergebnisse gefunden","No statute paragraphs":"Keine Satzungsabschnitte vorhanden","No tags":"Keine Schlagwรถrter","No users with email {0} found.":"Es wurde kein Nutzer mit E-Mail-Adresse {0} gefunden.","No.":"Nr.","None":"aus","Note, that the default password will be changed to the new generated one.":"Beachten Sie, dass das Passwort auf das neu generierte Passwort geรคndert wird.","Note: Your own password was not changed. Please use the password change dialog instead.":"Hinweis: Ihr eigenes Passwort wurde nicht geรคndert. Nutzen Sie stattdessen die Passwort-ร„ndern-Funktion.","Notes":"Notizen","Number motions":"Antrรคge nummerieren","Number of (minimum) required supporters for a motion":"Mindestanzahl erforderlicher Unterstรผtzer/innen fรผr einen Antrag","Number of all delegates":"Anzahl aller Delegierten","Number of all participants":"Anzahl aller Teilnehmenden","Number of ballot papers (selection)":"Anzahl der Stimmzettel (Vorauswahl)","Number of candidates":"Kandidatenanzahl","Number of last speakers to be shown on the projector":"Anzahl der dargestellten letzten Redner/innen auf dem Projektor","Number of minimal digits for identifier":"Minimale Anzahl an Ziffern im Bezeichner","Number of persons to be elected":"Anzahl der zu wรคhlenden Personen","Number of the next speakers to be shown on the projector":"Anzahl der dargestellten nรคchsten Render/innen auf dem Projektor","Number set":"Nummer gesetzt","Numbered per category":"pro Sachgebiet nummerieren","Numbering":"Nummerierung","Numbering prefix for agenda items":"Prรคfix fรผr Nummerierung von Tagesordnungspunkten","Numeral system for agenda items":"Nummerierungssystem fรผr Tagesordnungspunkte","OK":"OK","Offline mode":"Offline-Modus","Old password":"Altes Passwort","One email was send sucessfully.":"Eine E-Mail wurde erfolgreich versandt.","One of given name, surname and username has to be filled in. All other fields are optional and may be empty.":"Mindestens Vor- oder Nachname muss angegeben werden. Alle รผbrigen Felder sind optional und dรผrfen leer sein.","One vote per candidate":"Eine Stimme pro Kandidat/in","Only countdown":"Nur Countdown","Only for internal notes.":"Nur fรผr interne Notizen.","Only main agenda items":"Nur Haupt-Tagesordnungspunkte","Only present participants can be added to the list of speakers":"Nur anwesende Teilnehmende kรถnnen zur Redeliste hinzugefรผgt werden","Only traffic light":"Nur Ampel","Open":"ร–ffnen","Open items":"Offene Eintrรคge","Open list of speakers":"Redeliste รถffnen","Open projection dialog":"Projektionsdialog รถffnen","Open requests to speak":"Offene Wortmeldungen","OpenSlides Theme":"OpenSlides-Design-Theme","OpenSlides access data":"OpenSlides-Zugangsdaten","OpenSlides is temporarily reset to following timestamp":"OpenSlides ist vorรผbergehend auf folgenden Zeitpunkt zurรผckgesetzt:","Origin":"Herkunft","Original":"Original","Original version":"Originalfassung","Outside":"auรŸerhalb","Overlay":"Einblendung","PDF":"PDF","PDF ballot paper logo":"PDF-Stimmzettel-Logo","PDF export":"PDF-Export","PDF footer logo (left)":"PDF-Logo FuรŸzeile (links)","PDF footer logo (right)":"PDF-Logo FuรŸzeile (rechts)","PDF header logo (left)":"PDF-Logo Kopfzeile (links)","PDF header logo (right)":"PDF-Logo Kopfzeile (rechts)","PDF options":"PDF-Optionen","Page":"Seite","Page number alignment in PDF":"Seitenzahl-Ausrichtung im PDF","Page numbers":"Seitenzahlen","Paragraph-based, Diff-enabled":"Absatzbasiert mit ร„nderungsdarstellung","Parallel upload":"Parallel hochladen","Parent agenda item":"Elternelement in der Tagesordnung","Parent directory":"Elternverzeichnis","Participant":"Teilnehmer/in","Participant cannot be found":"Teilnehmende wurde nicht gefunden","Participant has been imported":"Teilnehmer/in wurde importiert","Participant number":"Teilnehmernummer","Participant number is not unique":"Teilnehmernummer ist nicht eindeutig","Participant will be imported":"Teilnehmer/in wird importiert","Participant(s) will be imported.":"Teilnehmende werden importiert.","Participants":"Teilnehmende","Participants have been imported.":"Teilnehmende wurden importiert.","Password":"Passwort","Paste/write your topics in this textbox.":"Kopieren oder schreiben Sie die Titel Ihrer Themenpunkte in diese Textbox.","Permission":"Zulassung","Permissions":"Berechtigungen","Permit":"Zulassen","Personal note":"Persรถnliche Notiz","Personal notes":"Persรถnliche Notizen","Phase":"Phase","Please enter a name for the new directory:":"Bitte geben Sie einen Namen fรผr das neue Verzeichnis ein:","Please enter a name for the new workflow:":"Bitte geben Sie einen Namen fรผr den neuen Arbeitsablauf ein:","Please enter a valid email address":"Bitte geben Sie einen neuen Namen ein fรผr","Please enter a valid email address!":"Bitte geben Sie eine gรผltige E-Mail-Adresse ein!","Please enter your new password":"Bitte geben Sie Ihr neues Passwort ein","Please fill in all required values":"Bitte fรผllen Sie alle erforderlichen Felder aus.","Please fill in the values for each candidate":"Bitte fรผllen Sie die Werte aller Kandidat/innen aus.","Please select the directory:":"Bitte wรคhlen Sie das Verzeichnis aus:","Preamble text for PDF document (all elections)":"Einleitungstext fรผr PDF-Dokument (alle Wahlen) ","Preamble text for PDF documents of motions":"Einleitungstext fรผr PDF-Dokumente von Antrรคgen","Predefined seconds of new countdowns":"Vorgegebene Sekunden fรผr neue Countdowns","Prefix":"Prรคfix","Prefix for the identifier for amendments":"Prรคfix fรผr den Bezeichner von ร„nderungsantrรคgen","Presence":"Anwesenheit","Present":"Anwesend","Presentation and assembly system":"Prรคsentations- und Versammlungssystem","Preview":"Vorschau","Previous":"Zurรผck","Previous slides":"Letzte Folien","Print ballot paper":"Stimmzettel drucken","Print ballot papers":"Stimmzettel drucken","Privacy Policy":"Datenschutzerklรคrung","Privacy policy":"Datenschutzerklรคrung","Project":"Projizieren","Project selection?":"Auswahl projizieren?","Projection defaults":"Projektionsvorgaben","Projector":"Projektor","Projector header image":"Projektor-Kopfgrafik","Projector language":"Projektorsprache","Projector logo":"Projektor-Logo","Projectors":"Projektoren","Public":"ร–ffentlich","Public item":"ร–ffentlicher Eintrag","Publish":"Verรถffentlichen","Put all candidates on the list of speakers":"Alle Kandidaten auf die Redeliste setzen","Queue":"Warteliste","Quorum":"Quorum","Re-add last speaker":"Letzte/n Redner/in zurรผckholen","Reason":"Begrรผndung","Reason required for creating new motion":"Begrรผndung erforderlich zur Erstellung neuer Antrรคge","Recommendation":"Empfehlung","Recommendation label":"Empfehlung","Recommendation set to {arg1}":"Empfehlung gesetzt auf {arg1}","Refer to committee":"In Ausschuss verweisen","Referral to committee":"Verweisung in Ausschuss","Referring motions":"Auf diesen Antrag verweisende Antrรคge","Refresh":"Aktualisieren","Reject":"Ablehnen","Reject (not authorized)":"Verwerfen (nicht zulรคssig)","Rejected":"Abgelehnt","Rejection":"Ablehnung","Rejection (not authorized)":"Verwerfung (nicht berechtigt)","Remove":"Entfernen","Remove all speakers":"Alle Redner/innen entfernen","Remove all supporters of a motion if a submitter edits his motion in early state":"Entferne alle Unterstรผtzer/innen, wenn ein Antragsteller/in den Antrag im Anfangsstadium bearbeitet","Remove candidate":"Kandidate/in entfernen","Remove from agenda":"Aus Tagesordnung entfernen","Remove from motion block":"Vom Antragsblock entfernen ","Remove me":"Entferne mich","Replacement":"Ersetzung","Reply address":"Antwortadresse","Required":"Erforderlich","Required comma or semicolon separated values with these column header names in the first row:":"Erforderliche Komma- oder Semikolon-separierte Werte mit diesen Spaltennamen in der ersten Zeile:","Required majority":"Erforderliche Mehrheit","Reset":"Zurรผcksetzen","Reset cache":"Cache leeren","Reset password":"Passwort zurรผcksetzen","Reset passwords to the default ones":"Passwรถrter zurรผcksetzen auf Initiales","Reset recommendation":"Empfehlung zurรผcksetzen","Reset state":"Status zurรผcksetzen","Reset to factory defaults":"Auf Werkseinstellung zurรผcksetzen","Resolution and size":"Auflรถsung und GrรถรŸe","Restrictions":"Zugriffsbeschrรคnkung","Right":"Rechts","Roman":"Rรถmisch","Save":"Speichern","Save all changes":"Alle ร„nderungen speichern","Scan this QR code to connect to WLAN.":"QR-Code scannen um sich mit dem WLAN zu verbinden.","Scan this QR code to open URL.":"QR-Code scannen um die URL zu รถffnen.","Scroll down":"Nach unten scrollen","Scroll down (big step)":"Nach unten scrollen (in groรŸen Schritten)","Scroll up":"Nach oben scrollen","Scroll up (big step)":"Nach oben scrollen (in groรŸen Schritten)","Search":"Suche","Search player":"Spieler suchen","Searching for candidates":"Auf Kandidatensuche","Select a new candidate":"Kandidat/in auswรคhlen ...","Select all":"Alle auswรคhlen","Select file":"Datei auswรคhlen","Select or search new speaker ...":"Redner/in auswรคhlen oder suchen ...","Select or search new submitter ...":"Antragsteller/in auswรคhlen oder suchen ...","Select paragraphs":"Absรคtze auswรคhlen","Send invitation email":"Einladungs-E-Mail senden","Sender name":"Absendername","Sending an invitation email":"Einladungs-E-Mail senden","Separator used for all csv exports and examples":"Feldtrenner fรผr alle CSV-Exporte und -Beispiele","Sequential number":"Laufende Nummer","Serially numbered":"fortlaufend nummerieren","Set as favorite":"Als Favorit markieren","Set as not favorite":"Favorit lรถschen","Set as parent":"Als Eltern setzen","Set as reference projector":"Als Referenzprojektor setzen","Set category":"Sachgebiet setzen","Set committee ...":"Gremium setzen ...","Set favorite":"Favorit markieren","Set hidden":"Versteckt setzen","Set internal":"Intern setzen","Set it manually":"manuell setzen","Set motion block":"Antragsblock setzen","Set presence ...":"Anwesenheit setzen ...","Set public":"ร–ffentlich setzen","Set recommendation":"Empfehlung setzen","Set status":"Status setzen","Sets this projector as the reference for the current list of speakers":"Diesen Projektor als Referenz fรผr die aktuelle Redeliste setzen.","Settings":"Einstellungen","Short description of event":"Kurzbeschreibung der Veranstaltung","Show all":"Alle anzeigen","Show amendment in parent motion":"ร„nderungsantrag im Hauptantrag anzeigen","Show amendments together with motions":"ร„nderungsantrรคge zusรคtzlich in der Hauptantragsรผbersicht anzeigen","Show checkbox to record decision":"Ankreuzfelder zum Dokumentieren der Entscheidung anzeigen","Show clock":"Uhr anzeigen","Show correct entries":"Korrekte Eintrรคge anzeigen","Show correct entries only":"Nur korrekte Eintrรคge anzeigen","Show entire motion text":"Vollstรคndigen Antragstext anzeigen","Show errors only":"Nur fehlerhafte Eintrรคge anzeigen","Show full text":"mehr anzeigen","Show header and footer":"Kopf- und FuรŸzeile anzeigen","Show logo":"Logo anzeigen","Show meta information box below the title on projector":"Meta-Informations-Box auf dem Projektor unterhalb des Antragstitels anzeigen","Show orange countdown in the last x seconds of speaking time":"Countdown in den letzten x Sekunden der Redezeit orange darstellen","Show password":"Passwort anzeigen","Show preview":"Vorschau anzeigen","Show profile":"Profil anzeigen","Show recommendation extension field":"Ergรคnzungsfeld fรผr Empfehlung anzeigen","Show state extension field":"Ergรคnzungsfeld fรผr Status anzeigen","Show submitters and recommendation/state in table of contents":"Antragsteller/in und Beschlussempfehlung/Beschluss im PDF-Inhaltsverzeichnis anzeigen","Show subtitles in the agenda":"Untertitel in der Tagesordnungsรผbersicht anzeigen","Show the sequential number for a motion":"Laufende Nummer von Antrรคgen anzeigen","Show this text on the login page":"Diesen Text auf der Login-Seite anzeigen","Show title":"Veranstaltungstitel anzeigen","Simple Workflow":"Einfacher Arbeitsablauf","Simple majority":"Einfache Mehrheit","Slide":"Folie","Sort":"Sortieren","Sort agenda":"Tagesordnung sortieren","Sort by identifier":"Nach Bezeichner sortieren","Sort categories":"Sachgebiete sortieren","Sort comments":"Kommentare sortieren","Sort list of speakers":"Redeliste sortieren","Sort motions":"Antrรคge sortieren","Sort motions by":"Antrรคge sortieren nach","Sort name of participants by":"Namen der Teilnehmenden sortieren nach","Speakers":"Redner/innen","Special values":"Spezielle Werte","Staff":"Mitarbeitende","Standard font size in PDF":"Standard-SchriftgrรถรŸe im PDF","Standard page size in PDF":"Standard-PDF-Papierformat","Start time":"Startzeit","State":"Status","State set to {arg1}":"Status gesetzt auf {arg1}","Statute":"Satzung","Statute amendment":"Satzungsรคnderungsantrag","Statute amendment for":"Satzungsรคnderungsantrag zu","Statute paragraph":"Satzungsabschnitt","Statute paragraph has been imported":"Satzungsabschnitt wurde importiert","Statute paragraph will be imported":"Satzungsabschnitt wird importiert","Statute paragraphs":"Satzung","Stop counting":"Zรคhlen stoppen","Stop submitting new motions by non-staff users":"Einreichen von neuen Antrรคgen stoppen fรผr Nutzer ohne Verwaltungsrechte","Structure level":"Gliederungsebene","Subcategory":"Untersachgebiet","Submitters":"Antragsteller/in","Submitters changed":"Antragsteller/in geรคndert","Summary of changes":"Zusammenfassung der ร„nderungen","Summary of changes:":"Zusammenfassung der ร„nderungen:","Support":"Unterstรผtzen","Supporters":"Unterstรผtzer/innen","Supporters changed":"Unterstรผtzer/innen geรคndert","Surname":"Nachname","System":"System","System URL":"System-URL","Table of contents":"Inhaltsverzeichnis","Tag":"Schlagwort","Tags":"Schlagwรถrter","Text":"Text","Text import":"Textimport","Text separator":"Texttrenner","The 100 % base of a voting result consists of":"Die 100%-Basis eines Abstimmungsergebnisses besteht aus","The 100-%-base of an election result consists of":"Die 100%-Basis eines Wahlergebnisses besteht aus","The assembly may decide:":"Die Versammlung mรถge beschlieรŸen:","The event manager hasn't set up a legal notice yet.":"Der Veranstalter hat noch kein Impressum hinterlegt.","The event manager hasn't set up a privacy policy yet.":"Der Veranstalter hat noch keine Datenschutzerklรคrung hinterlegt.","The file has too few columns to be parsed properly.":"Die Datei enthรคlt zu wenige Spalten, um richtig verwendet zu werden.","The file seems to have additional columns. They will be ignored.":"Die Datei scheint zusรคtzliche Spalten zu haben. Diese werden ignoriert.","The file seems to have some ommitted columns. They will be considered empty.":"Die Datei scheint einige ausgelassene Spalten zu haben. Sie werden als leer betrachtet.","The link is broken. Please contact your system administrator.":"Der Link ist defekt. Bitte kontaktieren Sie den zustรคndigen Administrator.","The list of speakers is closed.":"Die Redeliste ist geschlossen.","The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40":"Die maximale Zeichenanzahl pro Zeile. Relevant, wenn die Zeilennummerierung eingeschaltet ist. Minimum: 40.","The number has to be greater than 0.":"Die Anzahl muss grรถรŸer als 0 sein.","The reason field may not be blank.":"Die Begrรผndung darf nicht leer sein.","The requested method is not allowed. Please contact your system administrator.":"Die gewรผnschte Methode ist nicht erlaubt. Bitte kontaktieren Sie den zustรคndigen Administrator.","The sender address is defined in the OpenSlides server settings and should modified by administrator only.":"Die Absenderadresse ist in den OpenSlides-Servereinstellungen definiert und kann nur vom Administrator geรคndert werden.","The server could not be reached.":"Der Server konnte nicht erreicht werden.","The server didn't respond.":"Der Server antwortet nicht.","The text field may not be blank.":"Der Antragstext darf nicht leer sein.","The title is required":"Ein Titel ist erforderlich","The title of the motion is always applied.":"Der Antragstitel wird immer รผbernommen.","The user %user% has no email, so the invitation email could not be send.":"%user% besitzt keine E-Mail-Adresse; eine E-Mail konnte daher nicht gesendet werden.","The users %user% have no email, so the invitation emails could not be send.":"%user% besitzen keine E-Mail-Adressen; E-Mails konnte daher nicht gesendet werden.","There are no items left to chose from":"Es sind keine Eintrรคge ausgewรคhlt.","There is an error with this amendment. Please edit it manually.":"In diesem ร„nderungsantrag ist ein Fehler. Bitte bearbeiten Sie den Text manuell.","This change collides with another one.":"Diese ร„nderung kollidiert mit einer anderen.","This element does not exist at this time.":"Dieses Element existiert nicht zu diesem Zeitpunkt.","This field is required.":"Dieses Feld ist erforderlich.","This prefix will be set if you run the automatic agenda numbering.":"Dieses Prรคfix wird gesetzt, wenn die automatische Nummerierung der Tagesordnung durchgefรผhrt wird.","This will add or remove the following groups for all selected participants:":"Folgende Gruppen werden fรผr die ausgewรคhlten Teilnehmenden hinzugefรผgt oder entfernt:","This will add or remove the following submitters for all selected motions:":"Folgende Antragsteller werden fรผr die ausgewรคhlten Antrรคge hinzugefรผgt oder entfernt:","This will add or remove the following tags for all selected motions:":"Folgende Schlagwรถrter werden fรผr die ausgewรคhlten Antrรคge hinzugefรผgt oder entfernt:","This will move all selected motions as childs to:":"Alle ausgewรคhlten Antrรคge werden unterhalb des folgenden Tagesordnungspunktes verschoben:","This will move all selected motions under or after the following motion in the call list:":"Alle ausgewรคhlten Antrรคge unter oder nach dem folgenden Antrag in der Aufrufliste verschieben:","This will reset all made changes and sort the call list.":"Alle noch nicht gespeicherten ร„nderungen werden zurรผckgesetzt und die Aufrufliste wird neu sortiert.","This will send an update notification to all active clients":"Es wird eine Update-Benachrichtigung an alle aktiven Clients gesendet.","This will set the favorite status for all selected motions:":"Favoriten-Markierung fรผr alle ausgewรคhlten Antrรคge setzen:","This will set the following category for all selected motions:":"Folgendes Sachgebiet wird fรผr alle ausgewรคhlten Antrรคge gesetzt:","This will set the following motion block for all selected motions:":"Folgender Antragsblock wird fรผr alle ausgewรคhlten Antrรคgen gesetzt:","This will set the following recommendation for all selected motions:":"Folgende Empfehlung wird fรผr alle ausgewรคhlten Antrรคge gesetzt:","This will set the following state for all selected motions:":"Folgender Status wird fรผr alle ausgewรคhlten Antrรคge gesetzt:","Three-quarters majority":"Dreiviertelmehrheit","Tile view":"Kachelansicht","Timestamp":"Zeitstempel","Title":"Titel","Title for PDF document (all elections)":"Titel fรผr PDF-Dokument (alle Wahlen)","Title for PDF documents of motions":"Titel fรผr PDF-Dokumente von Antrรคgen","Title for access data and welcome PDF":"Titel fรผr das Zugangsdaten- und BegrรผรŸungs-PDF","Title is required. All other fields are optional and may be empty.":"Titel ist erforderlich. Alle รผbrigen Felder sind optional und dรผrfen leer sein.","Topic":"Thema","Topic has been imported":"Thema wurde importiert","Topic will be imported":"Thema wird importiert","Topics":"Themen","Topics have been imported.":"Themen wurden importiert.","Topics(s) will be imported.":"Themen werden importiert.","Total votes cast":"Abgegebene Stimmen","Touch the book icon to enter text":"Tippen Sie auf das Buch-Icon, um den Text zu bearbeiten.","Translation":"รœbersetzung","Two-thirds majority":"Zweidrittelmehrheit","Type":"Typ","Undone":"unerledigt","Unpublish":"Nicht verรถffentlichen","Unsupport":"Unterstรผtzung zurรผckziehen","Upload":"Hochladen","Upload files":"Dateien hochladen","Upload to:":"Hochladen in:","Use admin and admin for your first login.
    Please change your password to hide this message!":"Verwenden Sie admin und admin fรผr die erste Anmeldung.
    Bitte รคndern Sie Ihr Passwort, um diese Nachricht auszublenden!","Use the following custom number":"Verwende die folgende benutzerdefinierte Anzahl","Use these placeholders: {name}, {event_name}, {url}, {username}, {password}. The url referrs to the system url.":"Verwendbare Platzhalter: {name}, {event_name}, {url}, {username}, {password}. Die URL bezieht sich auf die System-URL.","Used for QRCode in PDF of access data.":"Wird fรผr QR-Code im Zugangsdaten-PDF verwendet.","Used for WLAN QRCode in PDF of access data.":"Wird fรผr WLAN-QR-Code im Zugangsdaten-PDF verwendet.","Username":"Benutzername","Username or password is not correct.":"Benutzername oder Passwort war nicht korrekt.","Uses leading zeros to sort motions correctly by identifier.":"Es werden fรผhrende Nullen verwendet, um die Bezeichner korrekt zu sortieren.","Valid votes":"Gรผltige Stimmen","View":"Anzeigen","Visibility":"Sichtbarkeit","Vote":"Abstimmung","Vote created":"Abstimmung erstellt","Vote deleted":"Abstimmung gelรถscht","Vote updated":"Abstimmung aktualisiert","Votes":"Stimmen","Voting":"Im Wahlvorgang","Voting and ballot papers":"Abstimmung und Stimmzettel","Voting result":"Abstimmungsergebnis","WEP":"WEP","WLAN access data":"WLAN-Zugangsdaten","WLAN encryption":"WLAN-Verschlรผsselung","WLAN name (SSID)":"WLAN-Name (SSID)","WLAN password":"WLAN-Passwort","WPA/WPA2":"WPA/WPA2","Waiting for results":"Auf Ergebnisse wartend ...","Web interface header logo":"Web-Interface-Kopfzeilen-Logo","Welcome to OpenSlides":"Willkommen bei OpenSlides","Which version?":"Welche Fassung?","Will be displayed as label before selected recommendation in statute amendments.":"Wird als Beschriftung vor der Beschlussempfehlung in Satzungsรคnderungsantrรคgen angezeigt.","Will be displayed as label before selected recommendation. Use an empty value to disable the recommendation system.":"Wird als Beschriftung vor der Beschlussempfehlung angezeigt. Leere Eingabe deaktiviert das Empfehlungssystem.","Withdraw":"Zurรผckziehen","Workflow":"Arbeitsablauf","Workflow of new motions":"Arbeitsablauf fรผr neue Antrรคge","Workflow of new statute amendments":"Arbeitsablauf fรผr neue Satzungsรคnderungsantrรคge","Workflows":"Arbeitsablรคufe","Yes":"Ja","Yes/No":"Ja/Nein","Yes/No per candidate":"Ja/Nein pro Kandidat","Yes/No/Abstain":"Ja/Nein/Enthaltung","Yes/No/Abstain per candidate":"Ja/Nein/Enthaltung pro Kandidat","You are not supposed to be here...":"Sie sollten nicht hier sein ...","You are using the history mode of OpenSlides. Changes will not be saved.":"Der Chronik-Modus ist aktiv. ร„nderungen werden nicht gespeichert.","You can use {event_name} and {username} as placeholder.":"Sie kรถnnen {event_name} und {username} als Platzhalter verwenden.","You cannot change the recommendation of motions in different workflows!":"Das ร„ndern der Empfehlung von Antrรคgen in verschiedenen Arbeitsablรคufen ist nicht mรถglich.","You cannot change the state of motions in different workflows!":"Das ร„ndern des Status von Antrรคgen in verschiedenen Arbeitsablรคufen ist nicht mรถglich.","You do not have the required permission to see that page!":"Sie haben leider keine Berechtigung diese Seite zu sehen.","You have to fill this field.":"Sie mรผssen diese Feld ausfรผllen.","You made changes.":"Sie haben ร„nderungen vorgenommen.","You override the personally set password!":"Sie รผberschreiben hiermit das persรถnlich gesetzte Passwort!","Your password was resetted successfully!":"Ihr Passwort wurde erfolgreich zurรผckgesetzt!","Zoom in":"VergrรถรŸern","Zoom out":"Verkleinern","[Begin speech] starts the countdown, [End speech] stops the countdown.":"[Rede beginnen] startet den Countdown, [Rede beenden] stoppt den Countdown.","[Place for your welcome and help text.]":"[Platz fรผr Ihren BegrรผรŸungs- und Hilfetext.]","[Space for your welcome text.]":"[Platz fรผr Ihren BegrรผรŸungstext.]","absent":"abwesend","accepted":"angenommen","active":"aktiv","active users":"aktive Nutzer","add group(s)":"Gruppe(n) hinzufรผgen","adjourned":"vertagt","and":"und","ballot-paper":"stimmzettel","by":"von","committee":"Gremium","connections":"Verbindungen","contribution":"Wortmeldung","custom":"benutzerdefiniert","disabled":"deaktiviert","diverse":"divers","emails":"E-Mails","entries will be ommitted.":"Eintrรคge werden ausgelassen. ","errors":"Fehler","example":"Beispiel","female":"weiblich","fullscreen":"Vollbild","has saved his work on this motion.":"hat die Arbeit an diesem Antrag gespeichert.","hidden":"versteckt","in progress":"in Bearbeitung","inactive":"inaktiv","inline":"innerhalb","internal":"intern","is elected":"gewรคhlt","is now":"ist jetzt","items":"Eintrรคge","items per page":"Eintrรคge pro Seite","items selected":"Eintrรคge ausgewรคhlt","majority":"Mehrheit","male":"mรคnnlich","motions":"Antrรคge","motions-example":"Antrรคge-Beispiel","move ...":"verschieben ...","needs review":"benรถtigt รœberprรผfung","no committee":"kein Gremium","none":"aus","not concerned":"nicht befasst","not decided":"nicht entschieden","not reached":"nicht erreicht","not reached.":"nicht erreicht.","of":"von","outside":"auรŸerhalb","participants-example":"Teilnehmende-Beispiel","permitted":"zugelassen","present":"anwesend","public":"รถffentlich","published":"verรถffentlicht","reached":"erreicht","reached.":"erreicht.","refered to committee":"in Ausschuss verwiesen","rejected":"abgelehnt","rejected (not authorized)":"verworfen (nicht zulรคssig)","remove group(s)":"Gruppe(n) entfernen","result":"Ergebnis","results":"Ergebnisse","selected":"ausgewรคhlt","statute paragraphs have been imported.":"Satzungsabschnitte wurden importiert.","statute paragraphs(s) will be imported.":"Satzungsabschnitte werden importiert.","submitted":"eingereicht","supporters":"Unterstรผtzer/innen","to":"bis","undocumented":"nicht erfasst","unpublished":"unverรถffentlicht","with filter":"mit Filter","with indexedDB":"mit indexedDB","with local storage":"mit Local Storage","withdrawed":"zurรผckgezogen"} \ No newline at end of file +{"%num% emails were send sucessfully.":"%num% E-Mails wurden erfolgreich versandt.","100% base":"100-%-Basis","OpenSlides is a free web based presentation and assembly system for visualizing and controlling agenda, motions and elections of an assembly.":"OpenSlides ist ein freies, webbasiertes Prรคsentations- und Versammlungssystem zur Darstellung und Steuerung von Tagesordnung, Antrรคgen und Wahlen einer Versammlung.","":"","A name is required":"Ein Name ist erforderlich","A new update is available!":"Ein neues Update ist verfรผgbar!","A password is required":"Ein Passwort ist erforderlich","A server error occured. Please contact your system administrator.":"Ein Serverfehler ist aufgetreten. Bitte kontaktieren Sie den Administrator.","A title is required":"Ein Titel ist erforderlich","About me":"รœber mich","Abstain":"Enthaltung","Accept":"Annehmen","Acceptance":"Annahme","Access data (PDF)":"Zugangsdaten (PDF)","Access groups":"Zugriffsgruppen","Access-data":"Zugangsdaten","Activate amendments":"ร„nderungsantrรคge aktivieren","Activate statute amendments":"Satzungsรคnderungsantrรคge aktivieren","Active":"Aktiv","Active filters":"Aktive Filter","Add":"Hinzufรผgen","Add countdown":"Countdown hinzufรผgen","Add me":"Fรผge mich hinzu","Add message":"Mitteilung hinzufรผgen","Add new custom translation":"Neue benutzerdefinierte รœbersetzung hinzufรผgen","Add to agenda":"Zur Tagesordnung hinzufรผgen","Add to queue":"Zur Warteschlange hinzufรผgen","Add/remove groups ...":"Gruppen hinzufรผgen/entfernen ...","Add/remove submitters":"Antragsteller hinzufรผgen/entfernen","Add/remove tags":"Schlagwรถrter hinzufรผgen/entfernen","Additional columns after the required ones may be present and won't affect the import.":"Zusรคtzliche Spalten nach den erforderlichen Spalten kรถnnen vorhanden sein und haben keinen Einfluss auf den Import.","Adjourn":"Vertagen","Adjournment":"Vertagung","Admin":"Admin","After verifiy the preview click on 'import' please (see top right).":"Nach Prรผfung der Vorschau klicken Sie bitte auf 'Importieren' (oben rechts).","Agenda":"Tagesordnung","Agenda visibility":"Sichtbarkeit in der Tagesordnung","All casted ballots":"Alle abgegebenen Stimmzettel","All topics will be deleted and won't be accessible afterwards.":"Alle Themen werden gelรถscht und sind danach nicht mehr zugรคnglich.","All valid ballots":"Alle gรผltigen Stimmzettel","All votes will be lost.":"Alle Stimmen gehen verloren.","All your changes are saved immediately.":"Alle ร„nderungen werden sofort gespeichert.","Allow access for anonymous guest users":"Erlaube Zugriff fรผr anonyme Gast-Nutzer","Allow amendments of amendments":"ร„nderungsantrรคge zu ร„nderungsantrรคgen erlauben","Allow blank in identifier":"Leerzeichen im Bezeichner erlauben","Allow create poll":"Abstimmung mรถglich","Allow submitter edit":"Antragsteller/in darf bearbeiten","Allow support":"Unterstรผtzung mรถglich","Allowed access groups for this directory":"Zulรคssige Zugriffsgruppen fรผr dieses Verzeichnis","Always":"Immer","Amendment":"ร„nderungsantrag","Amendment list (PDF)":"ร„nderungsantragsliste (PDF)","Amendment text":"ร„nderungsantragstext","Amendment to":"ร„nderungsantrag zu","Amendments":"ร„nderungsantrรคge","Amendments can change multiple paragraphs":"ร„nderungsantrรคge kรถnnen mehrere Absรคtze รคndern","Amendments to":"ร„nderungsantrรคge zu","Amount of votes":"Anzahl der Stimmen","An email with a password reset link was send!":"Es wurde eine E-Mail mit Link zum Passwort-Zurรผcksetzen gesendet.","An unknown error occurred.":"Ein unbekannter Fehler ist aufgetreten.","Anonymize votes":"Stimmen anonymisieren","Anonymous":"Anonym","Apply":"รœbernehmen","Arabic":"Arabisch","Are you sure you want to anonymize all votes? This cannot be undone.":"Sollen alle Stimmen wirklich anonymisiert werden? Dies kann nicht rรผckgรคngig gemacht werden.","Are you sure you want to delete all selected elections?":"Sollen alle ausgewรคhlten Wahlen wirklich gelรถscht werden?","Are you sure you want to delete all selected files and folders?":"Sollen alle ausgewรคhlten Dateien und Verzeichnisse wirklich gelรถscht werden?","Are you sure you want to delete all selected motions?":"Sollen alle ausgewรคhlten Antrรคge wirklich gelรถscht werden?","Are you sure you want to delete all selected participants?":"Sollen alle ausgewรคhlten Teilnehmende wirklich gelรถscht werden?","Are you sure you want to delete all speakers from this list of speakers?":"Sollen wirklich alle Redner/innen von dieser Liste entfernt werden?","Are you sure you want to delete the print template?":"Soll die Beschluss-Druckvorlage wirklich gelรถscht werden?","Are you sure you want to delete this category and all subcategories?":"Soll dieses Sachgebiet und deren Untersachgebiete wirklich gelรถscht werden?","Are you sure you want to delete this change recommendation?":"Soll diese ร„nderungsempfehlung wirklich gelรถscht werden?","Are you sure you want to delete this comment field?":"Soll dieses Kommentarfeld wirklich gelรถscht werden?","Are you sure you want to delete this election?":"Soll diese Wahl wirklich gelรถscht werden?","Are you sure you want to delete this entry?":"Soll dieser Eintrag wirklich gelรถscht werden?","Are you sure you want to delete this file?":"Soll diese Datei wirklich gelรถscht werden?","Are you sure you want to delete this group?":"Soll diese Gruppe wirklich gelรถscht werden?","Are you sure you want to delete this motion block?":"Soll dieser Antragsblock wirklich gelรถscht werden?","Are you sure you want to delete this motion?":"Soll dieser Antrag wirklich gelรถscht werden?","Are you sure you want to delete this participant?":"Soll diese/r Teilnehmende wirklich gelรถscht werden?","Are you sure you want to delete this projector?":"Soll dieser Projektor wirklich gelรถscht werden?","Are you sure you want to delete this speaker from this list of speakers?":"Soll diese/r Redner/in von der Redeliste wirklich entfernt werden?","Are you sure you want to delete this statute paragraph?":"Soll dieser Satzungsabschnitt wirklich gelรถscht werden?","Are you sure you want to delete this tag?":"Soll dieses Schlagwort wirklich gelรถscht werden?","Are you sure you want to delete this topic?":"Soll dieses Thema wirklich gelรถscht werden?","Are you sure you want to delete this vote?":"Soll diese Abstimmung wirklich gelรถscht werden?","Are you sure you want to delete this workflow?":"Soll dieser Arbeitsablauf wirklich gelรถscht werden?","Are you sure you want to discard this amendment?":"Soll dieser ร„nderungsantrag wirklich verworfen werden?","Are you sure you want to generate new passwords for all selected participants?":"Sollen wirklich neue Passwรถrter fรผr alle ausgewรคhlte Teilnehmende generiert werden?","Are you sure you want to number all agenda items?":"Sollen alle Tagesordnungspunkte wirklich neu nummeriert werden?","Are you sure you want to override the state of all motions of this motion block?":"Soll der Status von allen Antrรคgen aus diesem Antragsblock wirklich รผberschrieben werden?","Are you sure you want to remove all selected items from the agenda?":"Sollen alle ausgewรคhlten Eintrรคge wirklich aus der Tagesordnung entfernt werden?","Are you sure you want to remove this entry from the agenda?":"Soll dieser Eintrag wirklich aus der Tagesordnung entfernt werden?","Are you sure you want to remove this motion from motion block?":"Soll dieser Antrag wirklich aus dem Antragsblock entfernt werden?","Are you sure you want to renumber all motions of this category?":"Sollen alle Antrรคge dieses Sachgebiets wirklich neu nummeriert werden?","Are you sure you want to reset all options to factory defaults? All changes of this settings group will be lost!":"Wollen Sie wirklich alle Werte auf Werkseinstellungen zurรผcksetzen? Alle ร„nderungen auf dieser Einstellungsseite gehen verloren!","Are you sure you want to reset all options to factory defaults? Changes of all settings group will be lost!":"Wollen Sie wirklich alle Werte auf Werkseinstellungen zurรผcksetzen? ร„nderungen in allen Einstellungsrubriken gehen verloren!","Are you sure you want to reset all passwords to the default ones?":"Sollen wirklich alle Passwรถrter auf die initialen Passwรถrter zurรผckgesetzt werden?","Are you sure you want to reset this vote?":"Soll diese Abstimmung wirklich zurรผckgesetzt werden?","Are you sure you want to send an invitation email to the user?":"Soll wirklich eine E-Mail den diesen Nutzer gesendet werden?","Are you sure you want to send emails to all selected participants?":"Sollen E-Mails wirklich an alle ausgewรคhlten Teilnehmende gesendet werden?","As of":"Stand","As recommendation":"wie Empfehlung","Ask, default no":"Nachfragen, voreingestellt nein","Ask, default yes":"Nachfragen, voreingestellt ja","Attachments":"Anhรคnge","Available votes":"Verfรผgbare Stimmen","Back":"Zurรผck","Back to login":"Zurรผck zur Anmeldung","Ballot":"Wahlgang","Ballot opened":"Wahlgang erรถffnet","Ballot papers":"Stimmzettel","Base folder":"Basisverzeichnis","Begin of event":"Beginn der Veranstaltung","Begin speech":"Rede beginnen","Blank between prefix and number, e.g. 'A 001'.":"Leerzeichen zwischen Prรคfix und Nummer, z. B. 'A 001'.","CSV import":"CSV-Import","Call list":"Aufrufliste","Called":"Aufgerufen wird","Called with":"Mit aufgerufen werden","Can change its own password":"Darf eigenes Passwort รคndern","Can create amendments":"Darf ร„nderungsantrรคge stellen","Can create motions":"Darf Antrรคge erstellen","Can manage agenda":"Darf die Tagesordung verwalten","Can manage comments":"Darf Kommentare verwalten","Can manage configuration":"Darf die Konfiguration verwalten","Can manage elections":"Darf Wahlen verwalten","Can manage files":"Darf Dateien verwalten","Can manage list of speakers":"Darf Redelisten verwalten","Can manage logos and fonts":"Darf Logos und Schriften verwalten","Can manage motion metadata":"Darf Antragsmetadaten verwalten","Can manage motions":"Darf Antrรคge verwalten","Can manage tags":"Darf Schlagwรถrter verwalten","Can manage the projector":"Darf den Projektor steuern","Can manage users":"Darf Benutzer verwalten","Can nominate another participant":"Darf andere Teilnehmende fรผr Wahlen vorschlagen","Can nominate oneself":"Darf selbst fรผr Wahlen kandidieren","Can put oneself on the list of speakers":"Darf sich selbst auf die Redeliste setzen","Can see agenda":"Darf die Tagesordnung sehen","Can see comments":"Darf Kommentare sehen","Can see elections":"Darf Wahlen sehen","Can see extra data of users (e.g. present and comment)":"Darf die zusรคtzlichen Daten der Benutzer sehen (z. B. anwesend und Kommentar)","Can see hidden files":"Darf versteckte Dateien sehen","Can see history":"Darf die Chronik sehen","Can see internal items and time scheduling of agenda":"Darf interne Eintrรคge und Zeitplan der Tagesordnung sehen","Can see list of speakers":"Darf Redelisten sehen","Can see motions":"Darf Antrรคge sehen","Can see motions in internal state":"Darf Antrรคge im internen Status sehen","Can see names of users":"Darf die Namen der Benutzer sehen","Can see the front page":"Darf die Startseite sehen","Can see the list of files":"Darf die Dateiliste sehen","Can see the projector":"Darf den Projektor sehen","Can support motions":"Darf Antrรคge unterstรผtzen","Can upload files":"Darf Dateien hochladen","Cancel":"Abbrechen","Cancel edit":"Bearbeitung abbrechen","Candidates":"Kandidaten/innen","Cannot create PDF files on this browser.":"In diesem Browser kรถnnen keine PDF-Dateien erstellt werden.","Categories":"Sachgebiete","Category":"Sachgebiet","Center":"Mittig","Change paragraph":"Absatz รคndern","Change password":"Passwort รคndern","Change password for":"Passwort รคndern fรผr","Change presence":"Anwesenheit รคndern","Change recommendation":"ร„nderungsempfehlung","Change recommendations":"ร„nderungsempfehlungen","Changed by":"Bearbeitet von","Changed title":"Geรคnderter Titel","Changed version":"Geรคnderte Fassung","Changed version in line":"Geรคnderte Fassung in Zeile","Changes":"ร„nderungen","Check for updates":"Auf Updates prรผfen","Check in or check out participants based on their participant numbers:":"An- oder Abmeldung von Teilnehmenden basierend auf ihren Teilnehmernummern:","Choose 0 to disable the supporting system.":"Zum Deaktivieren des Unterstรผtzersystems '0' eingeben.","Chyron":"Bauchbinde","Clear all":"Alle lรถschen","Clear all filters":"Alle Filter lรถschen","Clear list":"Liste leeren","Clear motion block":"Antragsblock lรถschen","Clear tags":"Schlagwรถrter lรถschen","Click here to vote!":"Zur Stimmabgabe hier klicken!","Close":"SchlieรŸen","Close list of speakers":"Redeliste schlieรŸen","Closed items":"Erledigte Eintrรคge","Collapse all":"Alle zusammenklappen","Column separator":"Spaltentrenner","Comma separated names will be read as 'Surname, given name(s)'.":"Kommaseparierte Namen werden als 'Nachname, Vorname(n)' interpretiert.","Comment":"Kommentar","Comment fields":"Kommentarfelder","Comments":"Kommentare","Committee":"Gremium","Committees":"Gremien","Complex Workflow":"Komplexer Arbeitsablauf","Confirm new password":"Neues Passwort bestรคtigen","Content":"Inhalt","Copy and paste your participant names in this textbox.":"Kopieren Sie die Name Ihrer Teilnehmer/innen in diese Textbox.","Count active users":"Aktive Nutzer zรคhlen","Countdown":"Countdown","Countdown and traffic light":"Countdown und Ampel","Countdown description":"Countdown-Beschreibung","Countdown time":"Countdown-Zeit","Countdown title":"Countdown-Titel","Countdowns":"Countdowns","Counting of votes is in progress ...":"Die Auszรคhlung der Stimmen lรคuft ...","Couple countdown with the list of speakers":"Countdown mit der Redeliste verkoppeln","Create":"Erstellen","Create final print template":"Beschluss-Druckvorlage erstellen","Creating PDF file ...":"PDF-Datei wird erstellt ...","Creation date":"Erstellungsdatum","Current browser language":"Aktuelle Browsersprache","Current date":"Aktuelles Datum","Current list of speakers":"Aktuelle Redeliste","Custom aspect ratio":"Eigenes Seitenverhรคltnis","Custom number of ballot papers":"Benutzerdefinierte Anzahl von Stimmzetteln","Custom translations":"Benutzerdefinierte รœbersetzungen","Dear {name},\n\nthis is your OpenSlides login for the event {event_name}:\n\n {url}\n username: {username}\n password: {password}\n\nThis email was generated automatically.":"Hallo {name},\n\ndies ist Ihr OpenSlides-Zugang fรผr die Veranstaltung {event_name}:\n\n {url}\n Benutzername: {username}\n Passwort: {password}\n\nDiese E-Mail wurde automatisch erstellt.","Decision":"Entscheidung","Default":"Standard","Default 100 % base of a voting result":"Voreingestellte 100-%-Basis eines Abstimmungsergebnisses","Default 100 % base of an election result":"Voreingestellte 100-%-Basis eines Wahlergebnisses","Default election method":"Voreingestellte Wahlmethode","Default encoding for all csv exports":"Voreingestelltes Encoding fรผr alle CSV-Exporte","Default group":"Vorgegebene Gruppen","Default groups with voting rights":"Voreingestellte Gruppen mit Stimmrecht","Default line numbering":"Voreingestellte Zeilennummerierung","Default method to check whether a candidate has reached the required majority.":"Voreingestellte Methode zur รœberprรผfung ob ein Kandidat die nรถtige Mehrheit erreicht hat.","Default method to check whether a motion has reached the required majority.":"Voreingestellte Methode zur รœberprรผfung ob ein Antrag die nรถtige Mehrheit erreicht hat.","Default projector":"Standardprojektor","Default text version for change recommendations":"Voreingestellte Fassung fรผr ร„nderungsempfehlungen","Default visibility for new agenda items (except topics)":"Voreingestellte Sichtbarkeit fรผr neue Tagesordnungspunkte (auรŸer Themen)","Delegates":"Delegierte","Delete":"Lรถschen","Delete final print template":"Beschluss-Druckvorlage lรถschen","Delete message":"Mitteilung lรถschen","Delete projector":"Projektor lรถschen","Delete recommendation":"Empfehlung lรถschen","Delete whole history":"Chronik lรถschen","Deletion":"Streichung","Description":"Beschreibung","Deselect all":"Alle abwรคhlen","Designates whether this user is in the room.":"Bestimmt, ob dieser Benutzer vor Ort ist.","Designates whether this user should be treated as a committee.":"Legt fest, ob dieser Benutzer als Gremium behandelt werden soll.","Designates whether this user should be treated as active. Unselect this instead of deleting the account.":"Bestimmt, ob dieser Benutzer als aktiv behandelt werden soll. Sie kรถnnen ihn deaktivieren anstatt ihn zu lรถschen.","Didn't get an email":"Bekam keine E-Mail","Diff version":"ร„nderungsdarstellung","Disabled":"Deaktiviert","Disabled (no percents)":"Deaktiviert (keine Prozente)","Display type":"Anzeigeformat","Divergent:":"abweichend:","Do not concern":"Nicht befassen","Do not decide":"Nicht entscheiden","Do not forget to save your changes!":"Vergessen Sie nicht Ihre ร„nderungen zu speichern!","Do not set identifier":"Bezeichner nicht setzen","Do you really want to exit this page?":"Wollen Sie diese Seite wirklich verlassen?","Do you really want to go ahead?":"Wollen Sie die Aktion wirklich fortsetzen?","Do you really want to save your changes?":"Wollen Sie wirklich Ihre ร„nderungen speichern?","Does not have notes":"Hat keine Notizen","Done":"erledigt","Download CSV example file":"CSV-Beispiel-Datei herunterladen","Drop files into this area OR click here to select files":"Dateien auf diesen Bereich ziehen ODER hier klicken um Dateien auszuwรคhlen","Duration":"Dauer","During voting, OpenSlides does not store the individual user ID of the voter. This in no way means that a\n non-nominal vote is completely anonymous and secure. You cannot track the decisions of your voters after the\n data has been submitted. The validity of the data cannot always be guaranteed, especially if you use OpenSlides\n in a distributed online setup. You are responsible for your own actions.":"During voting, OpenSlides does not store the individual user ID of the voter. This in no way means that a\n non-nominal vote is completely anonymous and secure. You cannot track the decisions of your voters after the\n data has been submitted. The validity of the data cannot always be guaranteed, especially if you use OpenSlides\n in a distributed online setup. You are responsible for your own actions.","Edit":"Bearbeiten","Edit comment field":"Kommentarfeld bearbeiten","Edit details":"Details bearbeiten","Edit details for":"Details bearbeiten fรผr","Edit projector":"Projektor bearbeiten","Edit statute paragraph":"Satzungsabschnitt bearbeiten","Edit tag":"Schlagwort bearbeiten","Edit the whole motion text":"Vollstรคndigen Antragstext bearbeiten","Edit to enter votes.":"Bearbeiten, um Stimmen einzugeben.","Edit topic":"Thema bearbeiten","Election":"Wahl","Election documents":"Wahlunterlagen","Elections":"Wahlen","Element":"Element","Email":"E-Mail","Email body":"Nachrichtentext","Email sent":"E-Mail gesendet","Email subject":"Betreff","Empty text field":"Leeres Textfeld","Enable numbering for agenda items":"Nummerierung von Tagesordnungspunkten aktivieren","Enable participant presence view":"Ansicht zur Teilnehmeranwesenheit aktivieren","Enable/disable account ...":"Account ein-/ausschalten ...","Encoding of the file":"Encoding der Datei","End speech":"Rede beenden","Enforce page breaks":"Seitenumbrรผche erzwingen","Enter duration in seconds. Choose 0 to disable warning color.":"Geben Sie die Dauer in Sekunden an. Zum Deaktivieren der Warn-Farbe 0 auswรคhlen.","Enter number of the next shown speakers. Choose -1 to show all next speakers.":"Geben Sie die Anzahl der nรคchsten anzuzeigenden Redner/innen ein. Wรคhlen Sie -1 um alle anzuzeigen.","Enter participant number":"Teilnehmernummer eingeben","Enter your email to send the password reset link":"Geben Sie Ihre E-Mail-Adresse ein um eine Link zum Zurรผcksetzen des Passworts zu erhalten.","Entitled to vote":"Stimmberechtigte","Error":"Fehler","Error during PDF creation of election:":"Fehler bei der PDF-Erstellung der Wahl:","Error during PDF creation of motion:":"Fehler bei der PDF-Erstellung in Antrag","Error in form field.":"Fehler im Formularfeld.","Error: The new passwords do not match.":"Fehler: Die neuen Passwรถrter stimmen nicht รผberein.","Estimated end":"Voraussichtliches Ende","Event":"Veranstaltung","Event date":"Veranstaltungszeitraum","Event location":"Veranstaltungsort","Event name":"Veranstaltungsname","Event organizer":"Veranstalter","Exit":"Beenden","Expand all":"Alle ausklappen","Export":"Exportieren","Export ...":"Exportieren ...","Export as CSV":"Exportieren als CSV","Export as PDF":"Als PDF exportieren","Export comment":"Kommentar exportieren","Export motions":"Antrรคge exportieren","Export personal note only":"Nur persรถnliche Notiz exportieren","Export selected elections":"Ausgewรคhlte Wahlen exportieren","Export selected motions":"Ausgewรคhlte Antrรคge exportieren","Extension":"Erweiterung","Favorites":"Favoriten","File information":"Dateiinformationen","File name":"Dateiname","Files":"Dateien","Filter":"Filter","Final print template":"Beschluss-Druckvorlage","Final version":"Beschlussfassung","Finished":"Abgeschlossen","First state":"Erster Status","Follow recommendation":"Empfehlung folgen","Follow recommendations for all motions":"Empfehlungen fรผr alle Antrรคge folgen","Following users are currently editing this motion:":"Folgende Nutzer bearbeiten aktuell diesen Antrag:","Forgot Password?":"Passwort vergessen?","Format":"Format","Front page text":"Text der Startseite","Front page title":"Titel der Startseite","Fullscreen":"Vollbild","Gender":"Geschlecht","General":"Allgemein","General Abstain":"Generelle Enthaltung","General No":"Generelles Nein","Generate new passwords":"Neue Passwรถrter generieren","Generate password":"Passwort generieren","Given name":"Vorname","Go to line":"Springe zur Zeile","Got an email":"Bekam eine E-Mail","Groups":"Gruppen","Groups with read permissions":"Gruppen mit Leseberechtigungen","Groups with write permissions":"Gruppen mit Schreibberechtigungen","Guest":"Gast","Has amendments":"Hat ร„nderungsantrรคge","Has been voted for":"Has been voted for","Has no speakers":"Keine Wortmeldungen vorhanden","Has not been voted for":"Has not been voted for","Has notes":"Hat Notizen","Has speakers":"Wortmeldungen vorhanden","Help text for access data and welcome PDF":"Hilfetext fรผr das Zugangsdaten- und Willkommens-PDF","Hidden item":"Versteckter Eintrag","Hide internal items when projecting subitems":"Interne Eintrรคge ausblenden bei der Projektion von Untereintrรคgen","Hide more text":"weniger anzeigen","Hide motion text on projector":"Antragstext auf dem Projektor ausblenden","Hide password":"Passwort verstecken","Hide reason on projector":"Begrรผndung auf dem Projektor ausblenden","Hide recommendation on projector":"Empfehlung auf dem Projektor ausblenden","Hide referring motions":"Verweisende Antrรคge ausblenden","Hide the amount of speakers in subtitle of list of speakers slide":"Anzahl der Redner/innen im Untertitel der Redelistenprojektion ausblenden","Hint on voting":"Hinweis zur Stimmabgabe","History":"Chronik","Home":"Startseite","How to create new amendments":"Erstellung von ร„nderungsantrรคgen","I know the risk":"Ich kenne das Risiko","Identifier":"Bezeichner","Identifier, reason, submitter, category, origin and motion block are optional and may be empty.":"Bezeichner, Begrรผndung, Antragsteller/in, Sachgebiet, Herkunft und Antragsblock sind optional und dรผrfen leer sein.","Import":"Importieren","Import motions":"Antrรคge importieren","Import participants":"Teilnehmende importieren","Import statute":"Satzung importieren","Import topics":"Themen importieren","In motion list, motion detail and PDF.":"In Antragsliste, Detailansicht und PDF.","In the election process":"Im Wahlvorgang","Inactive":"Inaktiv","Initial password":"Initiales Passwort","Initiate update check for all clients":"Update-Prรผfung fรผr alle Clients starten","Input format: DD.MM.YYYY HH:MM":"Eingabeformat: TT.MM.JJJJ HH:MM","Insert after":"Danach einfรผgen","Insert before":"Davor einfรผgen","Insert behind":"Dahinter einfรผgen","Insert participants here":"Teilnehmende hier importieren","Insert topics here":"Themen hier importieren","Insertion":"Ergรคnzung","Installed plugins":"Installierte Plugins","Internal":"Intern","Internal item":"Interner Eintrag","Invalid input.":"Ungรผltige Eingabe.","Invalid line number":"Ungรผltige Zeilennummer","Invalid votes":"Ungรผltige Stimmen","Is SAML user":"Ist SAML-Nutzer","Is a committee":"Ist ein Gremium","Is active":"Ist aktiv","Is already projected":"ist bereits projiziert","Is amendment":"Ist ein ร„nderungsantrag","Is committee":"Ist Gremium","Is favorite":"Ist Favorit","Is no amendment and has no amendments":"Ist kein ร„nderungsantrag und hat keine ร„nderungsantrรคge","Is not a committee":"Ist kein Gremium","Is not active":"Ist nicht aktiv","Is not favorite":"Ist kein Favorit","Is not present":"Ist nicht anwesend","Is present":"Ist anwesend","Item number":"Tagesordnungspunkt-Nummer","Keep each item in a single line.":"Verwenden Sie eine Zeile pro Eintrag.","Keep each person in a single line.":"Verwenden Sie eine Zeile pro Person.","Label color":"Beschriftungsfarbe","Last email send":"Letzte gesendet E-Mail","Last email sent":"Letzte gesendete E-Mail","Last modified":"Zuletzt geรคndert","Last speakers":"Letzte Redner/innen","Left":"Links","Legal notice":"Impressum","License":"Lizenz","Line":"Zeile","Line length":"Zeilenlรคnge","Line numbering":"Zeilennummerierung","List of participants":"Teilnehmendenliste","List of participants (PDF)":"Teilnehmendenliste (PDF)","List of speakers":"Redeliste","List of speakers overlay":"Redelisten-Einblendung","List of votes":"Stimmabgaben","List view":"Listenansicht","Loading data. Please wait ...":"Daten werden geladen. Bitte warten ...","Login":"Anmelden","Login as guest":"Als Gast anmelden","Logout":"Abmelden","Main motion and line number":"Hauptantrag und Zeilennummer","Mark as personal favorite":"Als persรถnlichen Favorit markieren","Mark speaker":"Redner/in markieren","Media file":"Mediendatei","Message":"Mitteilung","Messages":"Mitteilungen","Meta information":"Metainformationen","More":"Mehr","Motion":"Antrag","Motion block":"Antragsblock","Motion blocks":"Antragsblรถcke","Motion change recommendation created":"ร„nderungsempfehlung erstellt","Motion change recommendation deleted":"ร„nderungsempfehlung gelรถscht","Motion change recommendation updated":"ร„nderungsempfehlung aktualisiert","Motion created":"Antrag erstellt","Motion deleted":"Antrag gelรถscht","Motion has been imported":"Antrag wurde importiert","Motion preamble":"Antragseinleitung","Motion text":"Antragstext","Motion updated":"Antrag aktualisiert","Motion will be imported":"Antrag wird importiert","Motion(s) will be imported.":"Antrรคge werden importieren.","Motions":"Antrรคge","Motions are in process. Please wait ...":"Antrรคge werden bearbeitet. Bitte warten ...","Motions have been imported.":"Antrรคge wurden importiert.","Move":"Verschieben","Move in call list":"In Aufrufliste verschieben","Move into directory":"In Verzeichnis verschieben","Move selected items ...":"Ausgewรคhlte Eintrรคge verschieben ...","Move to agenda item":"Zu TOP verschieben","Multiselect":"Mehrfachauswahl","Name":"Name","Name of recommender":"Name des Empfehlungsgebers","Name of recommender for statute amendments":"Name des Empfehlungsgebers fรผr Satzungsรคnderungsantrรคge","Needs review":"Benรถtigt Review","Never":"Nie","New Projector":"Neuer Projektor","New amendment":"Neuer ร„nderungsantrag","New ballot":"Neuer Wahlgang","New category":"Neues Sachgebiet","New change recommendation":"Neue ร„nderungsempfehlung","New comment field":"Neues Kommentarfeld","New directory":"Neues Verzeichnis","New election":"Neue Wahl","New file name":"Neuer Dateiname","New group name":"Neuer Gruppenname","New motion":"Neuer Antrag","New motion block":"Neuer Antragsblock","New participant":"Neue/r Teilnehmer/in","New password":"Neues Passwort","New state":"Neuer Status","New statute paragraph":"Neuer Satzungsabschnitt","New tag":"Neues Schlagwort","New topic":"Neues Thema","New vote":"Neue Abstimmung","New workflow":"Neuer Arbeitsablauf","Next":"Weiter","Next states":"Nรคchste Zustรคnde","No":"Nein","No category":"Kein Sachgebiet","No category set":"kein Sachgebiet gesetzt","No change recommendations yet":"Bisher keine ร„nderungsempfehlungen","No changes at the text.":"Keine ร„nderung am Text.","No comment":"Kein Kommentar","No concernment":"Nichtbefassung","No data":"Keine Daten","No decision":"Keine Entscheidung","No email sent":"Keine E-Mail gesendet","No emails were send.":"Es wurden keine E-Mails versandt.","No encryption":"Keine Verschlรผsselung","No groups selected":"Keine Gruppen ausgewรคhlt","No information available":"Keine Informationen verfรผgbar","No items selected":"Keine Eintrรคge ausgewรคhlte","No motion block set":"kein Antragsblock gesetzt","No personal note":"Keine persรถnliche Notiz","No recommendation":"keine Empfehlung gesetzt","No results yet.":"Noch keine Ergebnisse.","No search result found":"Keine Suchergebnisse gefunden","No statute paragraphs":"Keine Satzungsabschnitte vorhanden","No tags":"Keine Schlagwรถrter","No users with email {0} found.":"Es wurde kein Nutzer mit E-Mail-Adresse {0} gefunden.","No.":"Nr.","None":"aus","Not suitable for formal secret voting!":"Nicht geeignet fรผr fรถrmliche, geheime Stimmabgaben!","Note, that the default password will be changed to the new generated one.":"Beachten Sie, dass das Passwort auf das neu generierte Passwort geรคndert wird.","Note: Your own password was not changed. Please use the password change dialog instead.":"Hinweis: Ihr eigenes Passwort wurde nicht geรคndert. Nutzen Sie stattdessen die Passwort-ร„ndern-Funktion.","Notes":"Notizen","Number candidates":"Kandidat/innen nummerieren","Number motions":"Antrรคge nummerieren","Number of (minimum) required supporters for a motion":"Mindestanzahl erforderlicher Unterstรผtzer/innen fรผr einen Antrag","Number of all delegates":"Anzahl aller Delegierten","Number of all participants":"Anzahl aller Teilnehmenden","Number of ballot papers":"Stimmzettelanzahl","Number of ballot papers (selection)":"Anzahl der Stimmzettel (Vorauswahl)","Number of candidates":"Kandidatenanzahl","Number of last speakers to be shown on the projector":"Anzahl der dargestellten letzten Redner/innen auf dem Projektor","Number of minimal digits for identifier":"Minimale Anzahl an Ziffern im Bezeichner","Number of persons to be elected":"Anzahl der zu wรคhlenden Personen","Number of the next speakers to be shown on the projector":"Anzahl der dargestellten nรคchsten Render/innen auf dem Projektor","Number set":"Nummer gesetzt","Numbered per category":"pro Sachgebiet nummerieren","Numbering":"Nummerierung","Numbering prefix for agenda items":"Prรคfix fรผr Nummerierung von Tagesordnungspunkten","Numeral system for agenda items":"Nummerierungssystem fรผr Tagesordnungspunkte","OK":"OK","Offline mode":"Offline-Modus","Old password":"Altes Passwort","One email was send sucessfully.":"Eine E-Mail wurde erfolgreich versandt.","One of given name, surname and username has to be filled in. All other fields are optional and may be empty.":"Mindestens Vor- oder Nachname muss angegeben werden. Alle รผbrigen Felder sind optional und dรผrfen leer sein.","Online voting is impossible to secure":"Online voting is impossible to secure","Only countdown":"Nur Countdown","Only for internal notes.":"Nur fรผr interne Notizen.","Only main agenda items":"Nur Haupt-Tagesordnungspunkte","Only present participants can be added to the list of speakers":"Nur anwesende Teilnehmende kรถnnen zur Redeliste hinzugefรผgt werden","Only traffic light":"Nur Ampel","Open":"ร–ffnen","Open items":"Offene Eintrรคge","Open list of speakers":"Redeliste รถffnen","Open projection dialog":"Projektionsdialog รถffnen","Open requests to speak":"Offene Wortmeldungen","OpenSlides Theme":"OpenSlides-Design-Theme","OpenSlides access data":"OpenSlides-Zugangsdaten","OpenSlides is temporarily reset to following timestamp":"OpenSlides ist vorรผbergehend auf folgenden Zeitpunkt zurรผckgesetzt:","Origin":"Herkunft","Original":"Original","Original version":"Originalfassung","Outside":"auรŸerhalb","Overlay":"Einblendung","PDF":"PDF","PDF ballot paper logo":"PDF-Stimmzettel-Logo","PDF export":"PDF-Export","PDF footer logo (left)":"PDF-Logo FuรŸzeile (links)","PDF footer logo (right)":"PDF-Logo FuรŸzeile (rechts)","PDF header logo (left)":"PDF-Logo Kopfzeile (links)","PDF header logo (right)":"PDF-Logo Kopfzeile (rechts)","PDF options":"PDF-Optionen","Page":"Seite","Page number alignment in PDF":"Seitenzahl-Ausrichtung im PDF","Page numbers":"Seitenzahlen","Paragraph-based, Diff-enabled":"Absatzbasiert mit ร„nderungsdarstellung","Parallel upload":"Parallel hochladen","Parent agenda item":"Elternelement in der Tagesordnung","Parent directory":"Elternverzeichnis","Participant":"Teilnehmer/in","Participant cannot be found":"Teilnehmende wurde nicht gefunden","Participant has been imported":"Teilnehmer/in wurde importiert","Participant number":"Teilnehmernummer","Participant number is not unique":"Teilnehmernummer ist nicht eindeutig","Participant will be imported":"Teilnehmer/in wird importiert","Participant(s) will be imported.":"Teilnehmende werden importiert.","Participants":"Teilnehmende","Participants have been imported.":"Teilnehmende wurden importiert.","Password":"Passwort","Paste/write your topics in this textbox.":"Kopieren oder schreiben Sie die Titel Ihrer Themenpunkte in diese Textbox.","Permission":"Zulassung","Permissions":"Berechtigungen","Permit":"Zulassen","Personal note":"Persรถnliche Notiz","Personal notes":"Persรถnliche Notizen","Phase":"Phase","Please enter a name for the new directory:":"Bitte geben Sie einen Namen fรผr das neue Verzeichnis ein:","Please enter a name for the new workflow:":"Bitte geben Sie einen Namen fรผr den neuen Arbeitsablauf ein:","Please enter a valid email address":"Bitte geben Sie einen neuen Namen ein fรผr","Please enter a valid email address!":"Bitte geben Sie eine gรผltige E-Mail-Adresse ein!","Please enter your new password":"Bitte geben Sie Ihr neues Passwort ein","Please select the directory:":"Bitte wรคhlen Sie das Verzeichnis aus:","Preamble text for PDF document (all elections)":"Einleitungstext fรผr PDF-Dokument (alle Wahlen) ","Preamble text for PDF documents of motions":"Einleitungstext fรผr PDF-Dokumente von Antrรคgen","Predefined seconds of new countdowns":"Vorgegebene Sekunden fรผr neue Countdowns","Prefix":"Prรคfix","Prefix for the identifier for amendments":"Prรคfix fรผr den Bezeichner von ร„nderungsantrรคgen","Presence":"Anwesenheit","Present":"Anwesend","Presentation and assembly system":"Prรคsentations- und Versammlungssystem","Preview":"Vorschau","Previous":"Zurรผck","Previous slides":"Letzte Folien","Privacy Policy":"Datenschutzerklรคrung","Privacy policy":"Datenschutzerklรคrung","Project":"Projizieren","Project selection?":"Auswahl projizieren?","Projection defaults":"Projektionsvorgaben","Projector":"Projektor","Projector header image":"Projektor-Kopfgrafik","Projector language":"Projektorsprache","Projector logo":"Projektor-Logo","Projectors":"Projektoren","Public":"ร–ffentlich","Public item":"ร–ffentlicher Eintrag","Publish":"Verรถffentlichen","Publish immediately":"Direkt verรถffentlichen","Put all candidates on the list of speakers":"Alle Kandidaten auf die Redeliste setzen","Queue":"Warteliste","Re-add last speaker":"Letzte/n Redner/in zurรผckholen","Reason":"Begrรผndung","Reason required for creating new motion":"Begrรผndung erforderlich zur Erstellung neuer Antrรคge","Received votes":"Empfangene Stimmen","Recommendation":"Empfehlung","Recommendation label":"Empfehlung","Recommendation set to {arg1}":"Empfehlung gesetzt auf {arg1}","Refer to committee":"In Ausschuss verweisen","Referral to committee":"Verweisung in Ausschuss","Referring motions":"Auf diesen Antrag verweisende Antrรคge","Refresh":"Aktualisieren","Reject":"Ablehnen","Reject (not authorized)":"Verwerfen (nicht zulรคssig)","Rejected":"Abgelehnt","Rejection":"Ablehnung","Rejection (not authorized)":"Verwerfung (nicht berechtigt)","Remove":"Entfernen","Remove all speakers":"Alle Redner/innen entfernen","Remove all supporters of a motion if a submitter edits his motion in early state":"Entferne alle Unterstรผtzer/innen, wenn ein Antragsteller/in den Antrag im Anfangsstadium bearbeitet","Remove candidate":"Kandidate/in entfernen","Remove from agenda":"Aus Tagesordnung entfernen","Remove from motion block":"Vom Antragsblock entfernen ","Remove me":"Entferne mich","Replacement":"Ersetzung","Reply address":"Antwortadresse","Required":"Erforderlich","Required comma or semicolon separated values with these column header names in the first row:":"Erforderliche Komma- oder Semikolon-separierte Werte mit diesen Spaltennamen in der ersten Zeile:","Required majority":"Erforderliche Mehrheit","Reset":"Zurรผcksetzen","Reset cache":"Cache leeren","Reset password":"Passwort zurรผcksetzen","Reset passwords to the default ones":"Passwรถrter zurรผcksetzen auf Initiales","Reset recommendation":"Empfehlung zurรผcksetzen","Reset state":"Status zurรผcksetzen","Reset to factory defaults":"Auf Werkseinstellung zurรผcksetzen","Resolution and size":"Auflรถsung und GrรถรŸe","Restrictions":"Zugriffsbeschrรคnkung","Right":"Rechts","Roman":"Rรถmisch","Save":"Speichern","Save all changes":"Alle ร„nderungen speichern","Scan this QR code to connect to WLAN.":"QR-Code scannen um sich mit dem WLAN zu verbinden.","Scan this QR code to open URL.":"QR-Code scannen um die URL zu รถffnen.","Scroll down":"Nach unten scrollen","Scroll down (big step)":"Nach unten scrollen (in groรŸen Schritten)","Scroll up":"Nach oben scrollen","Scroll up (big step)":"Nach oben scrollen (in groรŸen Schritten)","Search":"Suche","Search player":"Spieler suchen","Searching for candidates":"Auf Kandidatensuche","Select a new candidate":"Kandidat/in auswรคhlen ...","Select all":"Alle auswรคhlen","Select file":"Datei auswรคhlen","Select or search new speaker ...":"Redner/in auswรคhlen oder suchen ...","Select or search new submitter ...":"Antragsteller/in auswรคhlen oder suchen ...","Select paragraphs":"Absรคtze auswรคhlen","Send invitation email":"Einladungs-E-Mail senden","Sender name":"Absendername","Sending an invitation email":"Einladungs-E-Mail senden","Separator used for all csv exports and examples":"Feldtrenner fรผr alle CSV-Exporte und -Beispiele","Sequential number":"Laufende Nummer","Serially numbered":"fortlaufend nummerieren","Set as favorite":"Als Favorit markieren","Set as not favorite":"Favorit lรถschen","Set as parent":"Als Eltern setzen","Set as reference projector":"Als Referenzprojektor setzen","Set category":"Sachgebiet setzen","Set committee ...":"Gremium setzen ...","Set favorite":"Favorit markieren","Set hidden":"Versteckt setzen","Set internal":"Intern setzen","Set it manually":"manuell setzen","Set motion block":"Antragsblock setzen","Set presence ...":"Anwesenheit setzen ...","Set public":"ร–ffentlich setzen","Set recommendation":"Empfehlung setzen","Set status":"Status setzen","Sets this projector as the reference for the current list of speakers":"Diesen Projektor als Referenz fรผr die aktuelle Redeliste setzen.","Settings":"Einstellungen","Short description of event":"Kurzbeschreibung der Veranstaltung","Show all":"Alle anzeigen","Show amendment in parent motion":"ร„nderungsantrag im Hauptantrag anzeigen","Show amendments together with motions":"ร„nderungsantrรคge zusรคtzlich in der Hauptantragsรผbersicht anzeigen","Show checkbox to record decision":"Ankreuzfelder zum Dokumentieren der Entscheidung anzeigen","Show clock":"Uhr anzeigen","Show correct entries":"Korrekte Eintrรคge anzeigen","Show correct entries only":"Nur korrekte Eintrรคge anzeigen","Show entire motion text":"Vollstรคndigen Antragstext anzeigen","Show errors only":"Nur fehlerhafte Eintrรคge anzeigen","Show full text":"mehr anzeigen","Show header and footer":"Kopf- und FuรŸzeile anzeigen","Show logo":"Logo anzeigen","Show meta information box below the title on projector":"Meta-Informations-Box auf dem Projektor unterhalb des Antragstitels anzeigen","Show orange countdown in the last x seconds of speaking time":"Countdown in den letzten x Sekunden der Redezeit orange darstellen","Show password":"Passwort anzeigen","Show preview":"Vorschau anzeigen","Show profile":"Profil anzeigen","Show recommendation extension field":"Ergรคnzungsfeld fรผr Empfehlung anzeigen","Show state extension field":"Ergรคnzungsfeld fรผr Status anzeigen","Show submitters and recommendation/state in table of contents":"Antragsteller/in und Beschlussempfehlung/Beschluss im PDF-Inhaltsverzeichnis anzeigen","Show subtitles in the agenda":"Untertitel in der Tagesordnungsรผbersicht anzeigen","Show the sequential number for a motion":"Laufende Nummer von Antrรคgen anzeigen","Show this text on the login page":"Diesen Text auf der Login-Seite anzeigen","Show title":"Veranstaltungstitel anzeigen","Simple Workflow":"Einfacher Arbeitsablauf","Simple majority":"Einfache Mehrheit","Single votes":"Einzelstimmen","Slide":"Folie","Sort":"Sortieren","Sort agenda":"Tagesordnung sortieren","Sort by identifier":"Nach Bezeichner sortieren","Sort categories":"Sachgebiete sortieren","Sort comments":"Kommentare sortieren","Sort election results by amount of votes":"Wahlergebnisse nach Stimmanzahl sortieren","Sort list of speakers":"Redeliste sortieren","Sort motions":"Antrรคge sortieren","Sort motions by":"Antrรคge sortieren nach","Sort name of participants by":"Namen der Teilnehmenden sortieren nach","Speakers":"Redner/innen","Staff":"Mitarbeitende","Standard font size in PDF":"Standard-SchriftgrรถรŸe im PDF","Standard page size in PDF":"Standard-PDF-Papierformat","Start time":"Startzeit","Start voting":"Stimmabgabe starten","State":"Status","State set to {arg1}":"Status gesetzt auf {arg1}","Statute":"Satzung","Statute amendment":"Satzungsรคnderungsantrag","Statute amendment for":"Satzungsรคnderungsantrag zu","Statute paragraph":"Satzungsabschnitt","Statute paragraph has been imported":"Satzungsabschnitt wurde importiert","Statute paragraph will be imported":"Satzungsabschnitt wird importiert","Statute paragraphs":"Satzung","Stop counting":"Zรคhlen stoppen","Stop submitting new motions by non-staff users":"Einreichen von neuen Antrรคgen stoppen fรผr Nutzer ohne Verwaltungsrechte","Stop voting":"Stimmabgabe beenden","Structure level":"Gliederungsebene","Subcategory":"Untersachgebiet","Submit selection now?":"Auswahl jetzt senden?","Submit vote now":"Stimme(n) jetzt senden","Submitters":"Antragsteller/in","Submitters changed":"Antragsteller/in geรคndert","Sum of votes including general No/Abstain":"Summe der Stimmen einschlieรŸlich generelles Nein/Enthaltung","Summary of changes":"Zusammenfassung der ร„nderungen","Summary of changes:":"Zusammenfassung der ร„nderungen:","Support":"Unterstรผtzen","Supporters":"Unterstรผtzer/innen","Supporters changed":"Unterstรผtzer/innen geรคndert","Surname":"Nachname","System":"System","System URL":"System-URL","Table of contents":"Inhaltsverzeichnis","Tag":"Schlagwort","Tags":"Schlagwรถrter","Text":"Text","Text import":"Textimport","Text separator":"Texttrenner","The assembly may decide:":"Die Versammlung mรถge beschlieรŸen:","The event manager hasn't set up a legal notice yet.":"Der Veranstalter hat noch kein Impressum hinterlegt.","The event manager hasn't set up a privacy policy yet.":"Der Veranstalter hat noch keine Datenschutzerklรคrung hinterlegt.","The file has too few columns to be parsed properly.":"Die Datei enthรคlt zu wenige Spalten, um richtig verwendet zu werden.","The file seems to have additional columns. They will be ignored.":"Die Datei scheint zusรคtzliche Spalten zu haben. Diese werden ignoriert.","The file seems to have some ommitted columns. They will be considered empty.":"Die Datei scheint einige ausgelassene Spalten zu haben. Sie werden als leer betrachtet.","The individual votes were anonymized.":"Die individuellen Stimmen wurden anonymisiert.","The link is broken. Please contact your system administrator.":"Der Link ist defekt. Bitte kontaktieren Sie den zustรคndigen Administrator.","The list of speakers is closed.":"Die Redeliste ist geschlossen.","The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40":"Die maximale Zeichenanzahl pro Zeile. Relevant, wenn die Zeilennummerierung eingeschaltet ist. Minimum: 40.","The number has to be greater than 0.":"Die Anzahl muss grรถรŸer als 0 sein.","The reason field may not be blank.":"Die Begrรผndung darf nicht leer sein.","The requested method is not allowed. Please contact your system administrator.":"Die gewรผnschte Methode ist nicht erlaubt. Bitte kontaktieren Sie den zustรคndigen Administrator.","The sender address is defined in the OpenSlides server settings and should modified by administrator only.":"Die Absenderadresse ist in den OpenSlides-Servereinstellungen definiert und kann nur vom Administrator geรคndert werden.","The server could not be reached.":"Der Server konnte nicht erreicht werden.","The server didn't respond.":"Der Server antwortet nicht.","The text field may not be blank.":"Der Antragstext darf nicht leer sein.","The title is required":"Ein Titel ist erforderlich","The title of the motion is always applied.":"Der Antragstitel wird immer รผbernommen.","The user %user% has no email, so the invitation email could not be send.":"%user% besitzt keine E-Mail-Adresse; eine E-Mail konnte daher nicht gesendet werden.","The users %user% have no email, so the invitation emails could not be send.":"%user% besitzen keine E-Mail-Adressen; E-Mails konnte daher nicht gesendet werden.","There are no items left to chose from":"Es sind keine Eintrรคge ausgewรคhlt.","There is an error with this amendment. Please edit it manually.":"In diesem ร„nderungsantrag ist ein Fehler. Bitte bearbeiten Sie den Text manuell.","This change collides with another one.":"Diese ร„nderung kollidiert mit einer anderen.","This element does not exist at this time.":"Dieses Element existiert nicht zu diesem Zeitpunkt.","This field is required.":"Dieses Feld ist erforderlich.","This prefix will be set if you run the automatic agenda numbering.":"Dieses Prรคfix wird gesetzt, wenn die automatische Nummerierung der Tagesordnung durchgefรผhrt wird.","This will add or remove the following groups for all selected participants:":"Folgende Gruppen werden fรผr die ausgewรคhlten Teilnehmenden hinzugefรผgt oder entfernt:","This will add or remove the following submitters for all selected motions:":"Folgende Antragsteller werden fรผr die ausgewรคhlten Antrรคge hinzugefรผgt oder entfernt:","This will add or remove the following tags for all selected motions:":"Folgende Schlagwรถrter werden fรผr die ausgewรคhlten Antrรคge hinzugefรผgt oder entfernt:","This will move all selected motions as childs to:":"Alle ausgewรคhlten Antrรคge werden unterhalb des folgenden Tagesordnungspunktes verschoben:","This will move all selected motions under or after the following motion in the call list:":"Alle ausgewรคhlten Antrรคge unter oder nach dem folgenden Antrag in der Aufrufliste verschieben:","This will reset all made changes and sort the call list.":"Alle noch nicht gespeicherten ร„nderungen werden zurรผckgesetzt und die Aufrufliste wird neu sortiert.","This will send an update notification to all active clients":"Es wird eine Update-Benachrichtigung an alle aktiven Clients gesendet.","This will set the favorite status for all selected motions:":"Favoriten-Markierung fรผr alle ausgewรคhlten Antrรคge setzen:","This will set the following category for all selected motions:":"Folgendes Sachgebiet wird fรผr alle ausgewรคhlten Antrรคge gesetzt:","This will set the following motion block for all selected motions:":"Folgender Antragsblock wird fรผr alle ausgewรคhlten Antrรคgen gesetzt:","This will set the following recommendation for all selected motions:":"Folgende Empfehlung wird fรผr alle ausgewรคhlten Antrรคge gesetzt:","This will set the following state for all selected motions:":"Folgender Status wird fรผr alle ausgewรคhlten Antrรคge gesetzt:","Three-quarters majority":"Dreiviertelmehrheit","Tile view":"Kachelansicht","Timestamp":"Zeitstempel","Title":"Titel","Title for PDF document (all elections)":"Titel fรผr PDF-Dokument (alle Wahlen)","Title for PDF documents of motions":"Titel fรผr PDF-Dokumente von Antrรคgen","Title for access data and welcome PDF":"Titel fรผr das Zugangsdaten- und BegrรผรŸungs-PDF","Title is required. All other fields are optional and may be empty.":"Titel ist erforderlich. Alle รผbrigen Felder sind optional und dรผrfen leer sein.","Topic":"Thema","Topic has been imported":"Thema wurde importiert","Topic will be imported":"Thema wird importiert","Topics":"Themen","Topics have been imported.":"Themen wurden importiert.","Topics(s) will be imported.":"Themen werden importiert.","Total votes cast":"Abgegebene Stimmen","Touch the book icon to enter text":"Tippen Sie auf das Buch-Icon, um den Text zu bearbeiten.","Translation":"รœbersetzung","Two-thirds majority":"Zweidrittelmehrheit","Type":"Typ","Undone":"unerledigt","Unknown user":"Unbekannter Nutzer","Unsupport":"Unterstรผtzung zurรผckziehen","Upload":"Hochladen","Upload files":"Dateien hochladen","Upload to:":"Hochladen in:","Use admin and admin for your first login.
    Please change your password to hide this message!":"Verwenden Sie admin und admin fรผr die erste Anmeldung.
    Bitte รคndern Sie Ihr Passwort, um diese Nachricht auszublenden!","Use the following custom number":"Verwende die folgende benutzerdefinierte Anzahl","Use these placeholders: {name}, {event_name}, {url}, {username}, {password}. The url referrs to the system url.":"Verwendbare Platzhalter: {name}, {event_name}, {url}, {username}, {password}. Die URL bezieht sich auf die System-URL.","Used for QRCode in PDF of access data.":"Wird fรผr QR-Code im Zugangsdaten-PDF verwendet.","Used for WLAN QRCode in PDF of access data.":"Wird fรผr WLAN-QR-Code im Zugangsdaten-PDF verwendet.","Username":"Benutzername","Username or password is not correct.":"Benutzername oder Passwort war nicht korrekt.","Uses leading zeros to sort motions correctly by identifier.":"Es werden fรผhrende Nullen verwendet, um die Bezeichner korrekt zu sortieren.","Valid votes":"Gรผltige Stimmen","View":"Anzeigen","Visibility":"Sichtbarkeit","Vote":"Abstimmung","Vote created":"Abstimmung erstellt","Vote currently possible":"Stimmabgabe aktuell mรถglich","Vote deleted":"Abstimmung gelรถscht","Vote finished":"Stimmabgabe abgeschlossen","Vote not possible":"Stimmabgabe nicht mรถglich","Vote updated":"Abstimmung aktualisiert","Votes":"Stimmen","Voting":"Im Wahlvorgang","Voting and ballot papers":"Abstimmung und Stimmzettel","Voting is currently in progress.":"Stimmabgabe lรคuft aktuell ","Voting method":"Wahlmethode","Voting opened":"Abstimmung erรถffnet","Voting result":"Abstimmungsergebnis","Voting successful.":"Stimmabgabe erfolgreich.","Voting type":"Art der Stimmabgabe","WEP":"WEP","WLAN access data":"WLAN-Zugangsdaten","WLAN encryption":"WLAN-Verschlรผsselung","WLAN name (SSID)":"WLAN-Name (SSID)","WLAN password":"WLAN-Passwort","WPA/WPA2":"WPA/WPA2","Web interface header logo":"Web-Interface-Kopfzeilen-Logo","Welcome to OpenSlides":"Willkommen bei OpenSlides","Which version?":"Welche Fassung?","Will be displayed as label before selected recommendation in statute amendments.":"Wird als Beschriftung vor der Beschlussempfehlung in Satzungsรคnderungsantrรคgen angezeigt.","Will be displayed as label before selected recommendation. Use an empty value to disable the recommendation system.":"Wird als Beschriftung vor der Beschlussempfehlung angezeigt. Leere Eingabe deaktiviert das Empfehlungssystem.","Withdraw":"Zurรผckziehen","Workflow":"Arbeitsablauf","Workflow of new motions":"Arbeitsablauf fรผr neue Antrรคge","Workflow of new statute amendments":"Arbeitsablauf fรผr neue Satzungsรคnderungsantrรคge","Workflows":"Arbeitsablรคufe","Yes":"Ja","Yes per candidate":"Ja pro Kandidat","Yes/No":"Ja/Nein","Yes/No per candidate":"Ja/Nein pro Kandidat","Yes/No/Abstain":"Ja/Nein/Enthaltung","Yes/No/Abstain per candidate":"Ja/Nein/Enthaltung pro Kandidat","You are not supposed to be here...":"Sie sollten nicht hier sein ...","You are using the history mode of OpenSlides. Changes will not be saved.":"Der Chronik-Modus ist aktiv. ร„nderungen werden nicht gespeichert.","You can use {event_name} and {username} as placeholder.":"Sie kรถnnen {event_name} und {username} als Platzhalter verwenden.","You cannot change the recommendation of motions in different workflows!":"Das ร„ndern der Empfehlung von Antrรคgen in verschiedenen Arbeitsablรคufen ist nicht mรถglich.","You cannot change the state of motions in different workflows!":"Das ร„ndern des Status von Antrรคgen in verschiedenen Arbeitsablรคufen ist nicht mรถglich.","You do not have the required permission to see that page!":"Sie haben leider keine Berechtigung diese Seite zu sehen.","You have already voted.":"Sie haben bereits Ihre Stimme abgegeben.","You have to fill this field.":"Sie mรผssen diese Feld ausfรผllen.","You made changes.":"Sie haben ร„nderungen vorgenommen.","You override the personally set password!":"Sie รผberschreiben hiermit das persรถnlich gesetzte Passwort!","You reached the maximum amount of votes. Deselect somebody first.":"Sie haben die maximale Anzahl von Stimmen erreicht. Deselektieren Sie zuerst eine Auswahl.","Your decision cannot be changed afterwards.":"Ihre Stimmabgabe kann anschlieรŸend nicht mehr geรคndert werden.","Your password was resetted successfully!":"Ihr Passwort wurde erfolgreich zurรผckgesetzt!","Zoom in":"VergrรถรŸern","Zoom out":"Verkleinern","[Begin speech] starts the countdown, [End speech] stops the countdown.":"[Rede beginnen] startet den Countdown, [Rede beenden] stoppt den Countdown.","[Place for your welcome and help text.]":"[Platz fรผr Ihren BegrรผรŸungs- und Hilfetext.]","[Space for your welcome text.]":"[Platz fรผr Ihren BegrรผรŸungstext.]","absent":"abwesend","accepted":"angenommen","active":"aktiv","active users":"aktive Nutzer","add group(s)":"Gruppe(n) hinzufรผgen","adjourned":"vertagt","analog":"analog","and":"und","ballot-paper":"stimmzettel","by":"von","committee":"Gremium","connections":"Verbindungen","contribution":"Wortmeldung","created":"erstellt","custom":"benutzerdefiniert","disabled":"deaktiviert","diverse":"divers","emails":"E-Mails","entries will be ommitted.":"Eintrรคge werden ausgelassen. ","errors":"Fehler","example":"Beispiel","female":"weiblich","finished (unpublished)":"abgeschlossen (unverรถffentlicht)","fullscreen":"Vollbild","has saved his work on this motion.":"hat die Arbeit an diesem Antrag gespeichert.","hidden":"versteckt","in progress":"in Bearbeitung","inactive":"inaktiv","inline":"innerhalb","internal":"intern","is now":"ist jetzt","items":"Eintrรคge","items per page":"Eintrรคge pro Seite","items selected":"Eintrรคge ausgewรคhlt","majority":"Mehrheit","male":"mรคnnlich","motions":"Antrรคge","motions-example":"Antrรคge-Beispiel","move ...":"verschieben ...","needs review":"benรถtigt รœberprรผfung","no committee":"kein Gremium","nominal":"namentlich","non-nominal":"nicht-namentlich","none":"aus","not concerned":"nicht befasst","not decided":"nicht entschieden","of":"von","open votes":"offene Stimmabgaben","outside":"auรŸerhalb","participants-example":"Teilnehmende-Beispiel","permitted":"zugelassen","present":"anwesend","public":"รถffentlich","published":"verรถffentlicht","refered to committee":"in Ausschuss verwiesen","rejected":"abgelehnt","rejected (not authorized)":"verworfen (nicht zulรคssig)","remove group(s)":"Gruppe(n) entfernen","result":"Ergebnis","results":"Ergebnisse","selected":"ausgewรคhlt","started":"gestartet","statute paragraphs have been imported.":"Satzungsabschnitte wurden importiert.","statute paragraphs(s) will be imported.":"Satzungsabschnitte werden importiert.","submitted":"eingereicht","supporters":"Unterstรผtzer/innen","to":"bis","undocumented":"nicht erfasst","with filter":"mit Filter","with indexedDB":"mit indexedDB","with local storage":"mit Local Storage","withdrawed":"zurรผckgezogen"} \ No newline at end of file diff --git a/client/src/assets/i18n/de.po b/client/src/assets/i18n/de.po index 96394d068..5fe434385 100644 --- a/client/src/assets/i18n/de.po +++ b/client/src/assets/i18n/de.po @@ -14,6 +14,9 @@ msgstr "" msgid "%num% emails were send sucessfully." msgstr "%num% E-Mails wurden erfolgreich versandt." +msgid "100% base" +msgstr "100-%-Basis" + msgid "" "OpenSlides is a free web based " "presentation and assembly system for visualizing and controlling agenda, " @@ -140,6 +143,9 @@ msgstr "Alle Themen werden gelรถscht und sind danach nicht mehr zugรคnglich." msgid "All valid ballots" msgstr "Alle gรผltigen Stimmzettel" +msgid "All votes will be lost." +msgstr "Alle Stimmen gehen verloren." + msgid "All your changes are saved immediately." msgstr "Alle ร„nderungen werden sofort gespeichert." @@ -167,15 +173,6 @@ msgstr "Zulรคssige Zugriffsgruppen fรผr dieses Verzeichnis" msgid "Always" msgstr "Immer" -msgid "Always Yes-No-Abstain per candidate" -msgstr "Ja/Nein/Enthaltung pro Kandidat/in" - -msgid "Always Yes/No per candidate" -msgstr "Ja/Nein pro Kandidat/in" - -msgid "Always one option per candidate" -msgstr "Eine Stimme pro Kandidat/in" - msgid "Amendment" msgstr "ร„nderungsantrag" @@ -197,22 +194,31 @@ msgstr "ร„nderungsantrรคge kรถnnen mehrere Absรคtze รคndern" msgid "Amendments to" msgstr "ร„nderungsantrรคge zu" +msgid "Amount of votes" +msgstr "Anzahl der Stimmen" + msgid "An email with a password reset link was send!" msgstr "Es wurde eine E-Mail mit Link zum Passwort-Zurรผcksetzen gesendet." msgid "An unknown error occurred." msgstr "Ein unbekannter Fehler ist aufgetreten." +msgid "Anonymize votes" +msgstr "Stimmen anonymisieren" + +msgid "Anonymous" +msgstr "Anonym" + msgid "Apply" msgstr "รœbernehmen" msgid "Arabic" msgstr "Arabisch" -msgid "Are you sure you want to copy the final version to the print template?" +msgid "Are you sure you want to anonymize all votes? This cannot be undone." msgstr "" -"Soll die Beschlussfassung weiter bearbeitet und eine Beschluss-Druckvorlage " -"erstellt werden?" +"Sollen alle Stimmen wirklich anonymisiert werden? Dies kann nicht rรผckgรคngig" +" gemacht werden." msgid "Are you sure you want to delete all selected elections?" msgstr "Sollen alle ausgewรคhlten Wahlen wirklich gelรถscht werden?" @@ -234,9 +240,6 @@ msgstr "Sollen wirklich alle Redner/innen von dieser Liste entfernt werden?" msgid "Are you sure you want to delete the print template?" msgstr "Soll die Beschluss-Druckvorlage wirklich gelรถscht werden?" -msgid "Are you sure you want to delete this ballot?" -msgstr "Soll dieser Wahlgang wirklich gelรถscht werden?" - msgid "Are you sure you want to delete this category and all subcategories?" msgstr "" "Soll dieses Sachgebiet und deren Untersachgebiete wirklich gelรถscht werden?" @@ -284,6 +287,9 @@ msgstr "Soll dieses Schlagwort wirklich gelรถscht werden?" msgid "Are you sure you want to delete this topic?" msgstr "Soll dieses Thema wirklich gelรถscht werden?" +msgid "Are you sure you want to delete this vote?" +msgstr "Soll diese Abstimmung wirklich gelรถscht werden?" + msgid "Are you sure you want to delete this workflow?" msgstr "Soll dieser Arbeitsablauf wirklich gelรถscht werden?" @@ -341,6 +347,9 @@ msgstr "" "Sollen wirklich alle Passwรถrter auf die initialen Passwรถrter zurรผckgesetzt " "werden?" +msgid "Are you sure you want to reset this vote?" +msgstr "Soll diese Abstimmung wirklich zurรผckgesetzt werden?" + msgid "Are you sure you want to send an invitation email to the user?" msgstr "Soll wirklich eine E-Mail den diesen Nutzer gesendet werden?" @@ -363,8 +372,8 @@ msgstr "Nachfragen, voreingestellt ja" msgid "Attachments" msgstr "Anhรคnge" -msgid "Automatic assign of method" -msgstr "Automatische Zuordnung der Methode" +msgid "Available votes" +msgstr "Verfรผgbare Stimmen" msgid "Back" msgstr "Zurรผck" @@ -375,8 +384,11 @@ msgstr "Zurรผck zur Anmeldung" msgid "Ballot" msgstr "Wahlgang" -msgid "Ballot and ballot papers" -msgstr "Wahlgang und Stimmzettel" +msgid "Ballot opened" +msgstr "Wahlgang erรถffnet" + +msgid "Ballot papers" +msgstr "Stimmzettel" msgid "Base folder" msgstr "Basisverzeichnis" @@ -588,6 +600,9 @@ msgstr "Antragsblock lรถschen" msgid "Clear tags" msgstr "Schlagwรถrter lรถschen" +msgid "Click here to vote!" +msgstr "Zur Stimmabgabe hier klicken!" + msgid "Close" msgstr "SchlieรŸen" @@ -655,6 +670,9 @@ msgstr "Countdown-Titel" msgid "Countdowns" msgstr "Countdowns" +msgid "Counting of votes is in progress ..." +msgstr "Die Auszรคhlung der Stimmen lรคuft ..." + msgid "Couple countdown with the list of speakers" msgstr "Countdown mit der Redeliste verkoppeln" @@ -715,8 +733,14 @@ msgstr "Entscheidung" msgid "Default" msgstr "Standard" -msgid "Default comment on the ballot paper" -msgstr "Voreingestellter Hinweis auf Stimmzettel" +msgid "Default 100 % base of a voting result" +msgstr "Voreingestellte 100-%-Basis eines Abstimmungsergebnisses" + +msgid "Default 100 % base of an election result" +msgstr "Voreingestellte 100-%-Basis eines Wahlergebnisses" + +msgid "Default election method" +msgstr "Voreingestellte Wahlmethode" msgid "Default encoding for all csv exports" msgstr "Voreingestelltes Encoding fรผr alle CSV-Exporte" @@ -724,6 +748,9 @@ msgstr "Voreingestelltes Encoding fรผr alle CSV-Exporte" msgid "Default group" msgstr "Vorgegebene Gruppen" +msgid "Default groups with voting rights" +msgstr "Voreingestellte Gruppen mit Stimmrecht" + msgid "Default line numbering" msgstr "Voreingestellte Zeilennummerierung" @@ -848,6 +875,13 @@ msgstr "" msgid "Duration" msgstr "Dauer" +msgid "" +"During voting, OpenSlides does not store the individual user ID of the voter. This in no way means that a\n" +" non-nominal vote is completely anonymous and secure. You cannot track the decisions of your voters after the\n" +" data has been submitted. The validity of the data cannot always be guaranteed, especially if you use OpenSlides\n" +" in a distributed online setup. You are responsible for your own actions." +msgstr "" + msgid "Edit" msgstr "Bearbeiten" @@ -872,24 +906,18 @@ msgstr "Schlagwort bearbeiten" msgid "Edit the whole motion text" msgstr "Vollstรคndigen Antragstext bearbeiten" +msgid "Edit to enter votes." +msgstr "Bearbeiten, um Stimmen einzugeben." + msgid "Edit topic" msgstr "Thema bearbeiten" -msgid "Elected" -msgstr "Gewรคhlt" - msgid "Election" msgstr "Wahl" msgid "Election documents" msgstr "Wahlunterlagen" -msgid "Election method" -msgstr "Wahlmethode" - -msgid "Election result" -msgstr "Wahlergebnis" - msgid "Elections" msgstr "Wahlen" @@ -944,14 +972,14 @@ msgstr "" msgid "Enter participant number" msgstr "Teilnehmernummer eingeben" -msgid "Enter votes" -msgstr "Stimmen eingeben" - msgid "Enter your email to send the password reset link" msgstr "" "Geben Sie Ihre E-Mail-Adresse ein um eine Link zum Zurรผcksetzen des " "Passworts zu erhalten." +msgid "Entitled to vote" +msgstr "Stimmberechtigte" + msgid "Error" msgstr "Fehler" @@ -961,6 +989,9 @@ msgstr "Fehler bei der PDF-Erstellung der Wahl:" msgid "Error during PDF creation of motion:" msgstr "Fehler bei der PDF-Erstellung in Antrag" +msgid "Error in form field." +msgstr "Fehler im Formularfeld." + msgid "Error: The new passwords do not match." msgstr "Fehler: Die neuen Passwรถrter stimmen nicht รผberein." @@ -1054,17 +1085,6 @@ msgstr "Empfehlungen fรผr alle Antrรคge folgen" msgid "Following users are currently editing this motion:" msgstr "Folgende Nutzer bearbeiten aktuell diesen Antrag:" -msgid "" -"For Yes/No/Abstain per candidate and Yes/No per candidate the 100-%-base " -"depends on the election method: If there is only one option per candidate, " -"the sum of all votes of all candidates is 100 %. Otherwise for each " -"candidate the sum of all votes is 100 %." -msgstr "" -"Fรผr Ja/Nein/Enthaltung pro Kandidat und Ja/Nein pro Kandidat hรคngt die " -"100%-Basis von der Wahlmethode ab: Wenn es nur eine Option pro Kandidat " -"gibt, ist 100% die Summe aller Stimmen von allen Kandidaten. Andernfalls ist" -" 100% die Summe aller Stimmen pro Kandidat." - msgid "Forgot Password?" msgstr "Passwort vergessen?" @@ -1086,6 +1106,12 @@ msgstr "Geschlecht" msgid "General" msgstr "Allgemein" +msgid "General Abstain" +msgstr "Generelle Enthaltung" + +msgid "General No" +msgstr "Generelles Nein" + msgid "Generate new passwords" msgstr "Neue Passwรถrter generieren" @@ -1116,9 +1142,15 @@ msgstr "Gast" msgid "Has amendments" msgstr "Hat ร„nderungsantrรคge" +msgid "Has been voted for" +msgstr "" + msgid "Has no speakers" msgstr "Keine Wortmeldungen vorhanden" +msgid "Has not been voted for" +msgstr "" + msgid "Has notes" msgstr "Hat Notizen" @@ -1156,8 +1188,8 @@ msgid "Hide the amount of speakers in subtitle of list of speakers slide" msgstr "" "Anzahl der Redner/innen im Untertitel der Redelistenprojektion ausblenden" -msgid "Hint for ballot paper" -msgstr "Hinweis auf dem Stimmzettel" +msgid "Hint on voting" +msgstr "Hinweis zur Stimmabgabe" msgid "History" msgstr "Chronik" @@ -1168,6 +1200,9 @@ msgstr "Startseite" msgid "How to create new amendments" msgstr "Erstellung von ร„nderungsantrรคgen" +msgid "I know the risk" +msgstr "Ich kenne das Risiko" + msgid "Identifier" msgstr "Bezeichner" @@ -1196,6 +1231,9 @@ msgstr "Themen importieren" msgid "In motion list, motion detail and PDF." msgstr "In Antragsliste, Detailansicht und PDF." +msgid "In the election process" +msgstr "Im Wahlvorgang" + msgid "Inactive" msgstr "Inaktiv" @@ -1337,6 +1375,9 @@ msgstr "Redeliste" msgid "List of speakers overlay" msgstr "Redelisten-Einblendung" +msgid "List of votes" +msgstr "Stimmabgaben" + msgid "List view" msgstr "Listenansicht" @@ -1355,8 +1396,8 @@ msgstr "Abmelden" msgid "Main motion and line number" msgstr "Hauptantrag und Zeilennummer" -msgid "Mark as elected" -msgstr "Als gewรคhlt markieren" +msgid "Mark as personal favorite" +msgstr "Als persรถnlichen Favorit markieren" msgid "Mark speaker" msgstr "Redner/in markieren" @@ -1373,6 +1414,9 @@ msgstr "Mitteilungen" msgid "Meta information" msgstr "Metainformationen" +msgid "More" +msgstr "Mehr" + msgid "Motion" msgstr "Antrag" @@ -1577,6 +1621,9 @@ msgstr "Keine persรถnliche Notiz" msgid "No recommendation" msgstr "keine Empfehlung gesetzt" +msgid "No results yet." +msgstr "Noch keine Ergebnisse." + msgid "No search result found" msgstr "Keine Suchergebnisse gefunden" @@ -1595,6 +1642,9 @@ msgstr "Nr." msgid "None" msgstr "aus" +msgid "Not suitable for formal secret voting!" +msgstr "Nicht geeignet fรผr fรถrmliche, geheime Stimmabgaben!" + msgid "" "Note, that the default password will be changed to the new generated one." msgstr "" @@ -1611,6 +1661,9 @@ msgstr "" msgid "Notes" msgstr "Notizen" +msgid "Number candidates" +msgstr "Kandidat/innen nummerieren" + msgid "Number motions" msgstr "Antrรคge nummerieren" @@ -1623,6 +1676,9 @@ msgstr "Anzahl aller Delegierten" msgid "Number of all participants" msgstr "Anzahl aller Teilnehmenden" +msgid "Number of ballot papers" +msgstr "Stimmzettelanzahl" + msgid "Number of ballot papers (selection)" msgstr "Anzahl der Stimmzettel (Vorauswahl)" @@ -1675,8 +1731,8 @@ msgstr "" "Mindestens Vor- oder Nachname muss angegeben werden. Alle รผbrigen Felder " "sind optional und dรผrfen leer sein." -msgid "One vote per candidate" -msgstr "Eine Stimme pro Kandidat/in" +msgid "Online voting is impossible to secure" +msgstr "" msgid "Only countdown" msgstr "Nur Countdown" @@ -1844,12 +1900,6 @@ msgstr "Bitte geben Sie eine gรผltige E-Mail-Adresse ein!" msgid "Please enter your new password" msgstr "Bitte geben Sie Ihr neues Passwort ein" -msgid "Please fill in all required values" -msgstr "Bitte fรผllen Sie alle erforderlichen Felder aus." - -msgid "Please fill in the values for each candidate" -msgstr "Bitte fรผllen Sie die Werte aller Kandidat/innen aus." - msgid "Please select the directory:" msgstr "Bitte wรคhlen Sie das Verzeichnis aus:" @@ -1886,12 +1936,6 @@ msgstr "Zurรผck" msgid "Previous slides" msgstr "Letzte Folien" -msgid "Print ballot paper" -msgstr "Stimmzettel drucken" - -msgid "Print ballot papers" -msgstr "Stimmzettel drucken" - msgid "Privacy Policy" msgstr "Datenschutzerklรคrung" @@ -1931,15 +1975,15 @@ msgstr "ร–ffentlicher Eintrag" msgid "Publish" msgstr "Verรถffentlichen" +msgid "Publish immediately" +msgstr "Direkt verรถffentlichen" + msgid "Put all candidates on the list of speakers" msgstr "Alle Kandidaten auf die Redeliste setzen" msgid "Queue" msgstr "Warteliste" -msgid "Quorum" -msgstr "Quorum" - msgid "Re-add last speaker" msgstr "Letzte/n Redner/in zurรผckholen" @@ -1949,6 +1993,9 @@ msgstr "Begrรผndung" msgid "Reason required for creating new motion" msgstr "Begrรผndung erforderlich zur Erstellung neuer Antrรคge" +msgid "Received votes" +msgstr "Empfangene Stimmen" + msgid "Recommendation" msgstr "Empfehlung" @@ -2266,6 +2313,9 @@ msgstr "Einfacher Arbeitsablauf" msgid "Simple majority" msgstr "Einfache Mehrheit" +msgid "Single votes" +msgstr "Einzelstimmen" + msgid "Slide" msgstr "Folie" @@ -2284,6 +2334,9 @@ msgstr "Sachgebiete sortieren" msgid "Sort comments" msgstr "Kommentare sortieren" +msgid "Sort election results by amount of votes" +msgstr "Wahlergebnisse nach Stimmanzahl sortieren" + msgid "Sort list of speakers" msgstr "Redeliste sortieren" @@ -2299,9 +2352,6 @@ msgstr "Namen der Teilnehmenden sortieren nach" msgid "Speakers" msgstr "Redner/innen" -msgid "Special values" -msgstr "Spezielle Werte" - msgid "Staff" msgstr "Mitarbeitende" @@ -2314,6 +2364,9 @@ msgstr "Standard-PDF-Papierformat" msgid "Start time" msgstr "Startzeit" +msgid "Start voting" +msgstr "Stimmabgabe starten" + msgid "State" msgstr "Status" @@ -2348,18 +2401,30 @@ msgid "Stop submitting new motions by non-staff users" msgstr "" "Einreichen von neuen Antrรคgen stoppen fรผr Nutzer ohne Verwaltungsrechte" +msgid "Stop voting" +msgstr "Stimmabgabe beenden" + msgid "Structure level" msgstr "Gliederungsebene" msgid "Subcategory" msgstr "Untersachgebiet" +msgid "Submit selection now?" +msgstr "Auswahl jetzt senden?" + +msgid "Submit vote now" +msgstr "Stimme(n) jetzt senden" + msgid "Submitters" msgstr "Antragsteller/in" msgid "Submitters changed" msgstr "Antragsteller/in geรคndert" +msgid "Sum of votes including general No/Abstain" +msgstr "Summe der Stimmen einschlieรŸlich generelles Nein/Enthaltung" + msgid "Summary of changes" msgstr "Zusammenfassung der ร„nderungen" @@ -2402,12 +2467,6 @@ msgstr "Textimport" msgid "Text separator" msgstr "Texttrenner" -msgid "The 100 % base of a voting result consists of" -msgstr "Die 100%-Basis eines Abstimmungsergebnisses besteht aus" - -msgid "The 100-%-base of an election result consists of" -msgstr "Die 100%-Basis eines Wahlergebnisses besteht aus" - msgid "The assembly may decide:" msgstr "Die Versammlung mรถge beschlieรŸen:" @@ -2430,6 +2489,9 @@ msgstr "" "Die Datei scheint einige ausgelassene Spalten zu haben. Sie werden als leer " "betrachtet." +msgid "The individual votes were anonymized." +msgstr "Die individuellen Stimmen wurden anonymisiert." + msgid "The link is broken. Please contact your system administrator." msgstr "" "Der Link ist defekt. Bitte kontaktieren Sie den zustรคndigen Administrator." @@ -2628,8 +2690,8 @@ msgstr "Typ" msgid "Undone" msgstr "unerledigt" -msgid "Unpublish" -msgstr "Nicht verรถffentlichen" +msgid "Unknown user" +msgstr "Unbekannter Nutzer" msgid "Unsupport" msgstr "Unterstรผtzung zurรผckziehen" @@ -2692,9 +2754,18 @@ msgstr "Abstimmung" msgid "Vote created" msgstr "Abstimmung erstellt" +msgid "Vote currently possible" +msgstr "Stimmabgabe aktuell mรถglich" + msgid "Vote deleted" msgstr "Abstimmung gelรถscht" +msgid "Vote finished" +msgstr "Stimmabgabe abgeschlossen" + +msgid "Vote not possible" +msgstr "Stimmabgabe nicht mรถglich" + msgid "Vote updated" msgstr "Abstimmung aktualisiert" @@ -2707,9 +2778,24 @@ msgstr "Im Wahlvorgang" msgid "Voting and ballot papers" msgstr "Abstimmung und Stimmzettel" +msgid "Voting is currently in progress." +msgstr "Stimmabgabe lรคuft aktuell " + +msgid "Voting method" +msgstr "Wahlmethode" + +msgid "Voting opened" +msgstr "Abstimmung erรถffnet" + msgid "Voting result" msgstr "Abstimmungsergebnis" +msgid "Voting successful." +msgstr "Stimmabgabe erfolgreich." + +msgid "Voting type" +msgstr "Art der Stimmabgabe" + msgid "WEP" msgstr "WEP" @@ -2728,9 +2814,6 @@ msgstr "WLAN-Passwort" msgid "WPA/WPA2" msgstr "WPA/WPA2" -msgid "Waiting for results" -msgstr "Auf Ergebnisse wartend ..." - msgid "Web interface header logo" msgstr "Web-Interface-Kopfzeilen-Logo" @@ -2772,6 +2855,9 @@ msgstr "Arbeitsablรคufe" msgid "Yes" msgstr "Ja" +msgid "Yes per candidate" +msgstr "Ja pro Kandidat" + msgid "Yes/No" msgstr "Ja/Nein" @@ -2808,6 +2894,9 @@ msgstr "" msgid "You do not have the required permission to see that page!" msgstr "Sie haben leider keine Berechtigung diese Seite zu sehen." +msgid "You have already voted." +msgstr "Sie haben bereits Ihre Stimme abgegeben." + msgid "You have to fill this field." msgstr "Sie mรผssen diese Feld ausfรผllen." @@ -2817,6 +2906,14 @@ msgstr "Sie haben ร„nderungen vorgenommen." msgid "You override the personally set password!" msgstr "Sie รผberschreiben hiermit das persรถnlich gesetzte Passwort!" +msgid "You reached the maximum amount of votes. Deselect somebody first." +msgstr "" +"Sie haben die maximale Anzahl von Stimmen erreicht. Deselektieren Sie zuerst" +" eine Auswahl." + +msgid "Your decision cannot be changed afterwards." +msgstr "Ihre Stimmabgabe kann anschlieรŸend nicht mehr geรคndert werden." + msgid "Your password was resetted successfully!" msgstr "Ihr Passwort wurde erfolgreich zurรผckgesetzt!" @@ -2854,6 +2951,9 @@ msgstr "Gruppe(n) hinzufรผgen" msgid "adjourned" msgstr "vertagt" +msgid "analog" +msgstr "analog" + msgid "and" msgstr "und" @@ -2872,6 +2972,9 @@ msgstr "Verbindungen" msgid "contribution" msgstr "Wortmeldung" +msgid "created" +msgstr "erstellt" + msgid "custom" msgstr "benutzerdefiniert" @@ -2896,6 +2999,9 @@ msgstr "Beispiel" msgid "female" msgstr "weiblich" +msgid "finished (unpublished)" +msgstr "abgeschlossen (unverรถffentlicht)" + msgid "fullscreen" msgstr "Vollbild" @@ -2917,9 +3023,6 @@ msgstr "innerhalb" msgid "internal" msgstr "intern" -msgid "is elected" -msgstr "gewรคhlt" - msgid "is now" msgstr "ist jetzt" @@ -2953,6 +3056,12 @@ msgstr "benรถtigt รœberprรผfung" msgid "no committee" msgstr "kein Gremium" +msgid "nominal" +msgstr "namentlich" + +msgid "non-nominal" +msgstr "nicht-namentlich" + msgid "none" msgstr "aus" @@ -2962,15 +3071,12 @@ msgstr "nicht befasst" msgid "not decided" msgstr "nicht entschieden" -msgid "not reached" -msgstr "nicht erreicht" - -msgid "not reached." -msgstr "nicht erreicht." - msgid "of" msgstr "von" +msgid "open votes" +msgstr "offene Stimmabgaben" + msgid "outside" msgstr "auรŸerhalb" @@ -2989,12 +3095,6 @@ msgstr "รถffentlich" msgid "published" msgstr "verรถffentlicht" -msgid "reached" -msgstr "erreicht" - -msgid "reached." -msgstr "erreicht." - msgid "refered to committee" msgstr "in Ausschuss verwiesen" @@ -3016,6 +3116,9 @@ msgstr "Ergebnisse" msgid "selected" msgstr "ausgewรคhlt" +msgid "started" +msgstr "gestartet" + msgid "statute paragraphs have been imported." msgstr "Satzungsabschnitte wurden importiert." @@ -3034,9 +3137,6 @@ msgstr "bis" msgid "undocumented" msgstr "nicht erfasst" -msgid "unpublished" -msgstr "unverรถffentlicht" - msgid "with filter" msgstr "mit Filter" diff --git a/client/src/assets/i18n/template-en.pot b/client/src/assets/i18n/template-en.pot index 086bde5df..b2f95b88d 100644 --- a/client/src/assets/i18n/template-en.pot +++ b/client/src/assets/i18n/template-en.pot @@ -7,6 +7,9 @@ msgstr "" msgid "%num% emails were send sucessfully." msgstr "" +msgid "100% base" +msgstr "" + msgid "" "OpenSlides is a free web based " "presentation and assembly system for visualizing and controlling agenda, " @@ -126,6 +129,9 @@ msgstr "" msgid "All valid ballots" msgstr "" +msgid "All votes will be lost." +msgstr "" + msgid "All your changes are saved immediately." msgstr "" @@ -153,15 +159,6 @@ msgstr "" msgid "Always" msgstr "" -msgid "Always Yes-No-Abstain per candidate" -msgstr "" - -msgid "Always Yes/No per candidate" -msgstr "" - -msgid "Always one option per candidate" -msgstr "" - msgid "Amendment" msgstr "" @@ -183,19 +180,28 @@ msgstr "" msgid "Amendments to" msgstr "" +msgid "Amount of votes" +msgstr "" + msgid "An email with a password reset link was send!" msgstr "" msgid "An unknown error occurred." msgstr "" +msgid "Anonymize votes" +msgstr "" + +msgid "Anonymous" +msgstr "" + msgid "Apply" msgstr "" msgid "Arabic" msgstr "" -msgid "Are you sure you want to copy the final version to the print template?" +msgid "Are you sure you want to anonymize all votes? This cannot be undone." msgstr "" msgid "Are you sure you want to delete all selected elections?" @@ -216,9 +222,6 @@ msgstr "" msgid "Are you sure you want to delete the print template?" msgstr "" -msgid "Are you sure you want to delete this ballot?" -msgstr "" - msgid "Are you sure you want to delete this category and all subcategories?" msgstr "" @@ -264,6 +267,9 @@ msgstr "" msgid "Are you sure you want to delete this topic?" msgstr "" +msgid "Are you sure you want to delete this vote?" +msgstr "" + msgid "Are you sure you want to delete this workflow?" msgstr "" @@ -308,6 +314,9 @@ msgstr "" msgid "Are you sure you want to reset all passwords to the default ones?" msgstr "" +msgid "Are you sure you want to reset this vote?" +msgstr "" + msgid "Are you sure you want to send an invitation email to the user?" msgstr "" @@ -329,7 +338,7 @@ msgstr "" msgid "Attachments" msgstr "" -msgid "Automatic assign of method" +msgid "Available votes" msgstr "" msgid "Back" @@ -341,7 +350,10 @@ msgstr "" msgid "Ballot" msgstr "" -msgid "Ballot and ballot papers" +msgid "Ballot opened" +msgstr "" + +msgid "Ballot papers" msgstr "" msgid "Base folder" @@ -551,6 +563,9 @@ msgstr "" msgid "Clear tags" msgstr "" +msgid "Click here to vote!" +msgstr "" + msgid "Close" msgstr "" @@ -617,6 +632,9 @@ msgstr "" msgid "Countdowns" msgstr "" +msgid "Counting of votes is in progress ..." +msgstr "" + msgid "Couple countdown with the list of speakers" msgstr "" @@ -668,7 +686,13 @@ msgstr "" msgid "Default" msgstr "" -msgid "Default comment on the ballot paper" +msgid "Default 100 % base of a voting result" +msgstr "" + +msgid "Default 100 % base of an election result" +msgstr "" + +msgid "Default election method" msgstr "" msgid "Default encoding for all csv exports" @@ -677,6 +701,9 @@ msgstr "" msgid "Default group" msgstr "" +msgid "Default groups with voting rights" +msgstr "" + msgid "Default line numbering" msgstr "" @@ -792,6 +819,17 @@ msgstr "" msgid "Duration" msgstr "" +msgid "" +"During voting, OpenSlides does not store the individual user ID of the " +"voter. This in no way means that a\n" +" non-nominal vote is completely anonymous and secure. You cannot " +"track the decisions of your voters after the\n" +" data has been submitted. The validity of the data cannot always be " +"guaranteed, especially if you use OpenSlides\n" +" in a distributed online setup. You are responsible for your own " +"actions." +msgstr "" + msgid "Edit" msgstr "" @@ -816,10 +854,10 @@ msgstr "" msgid "Edit the whole motion text" msgstr "" -msgid "Edit topic" +msgid "Edit to enter votes." msgstr "" -msgid "Elected" +msgid "Edit topic" msgstr "" msgid "Election" @@ -828,12 +866,6 @@ msgstr "" msgid "Election documents" msgstr "" -msgid "Election method" -msgstr "" - -msgid "Election result" -msgstr "" - msgid "Elections" msgstr "" @@ -884,10 +916,10 @@ msgstr "" msgid "Enter participant number" msgstr "" -msgid "Enter votes" +msgid "Enter your email to send the password reset link" msgstr "" -msgid "Enter your email to send the password reset link" +msgid "Entitled to vote" msgstr "" msgid "Error" @@ -899,6 +931,9 @@ msgstr "" msgid "Error during PDF creation of motion:" msgstr "" +msgid "Error in form field." +msgstr "" + msgid "Error: The new passwords do not match." msgstr "" @@ -992,13 +1027,6 @@ msgstr "" msgid "Following users are currently editing this motion:" msgstr "" -msgid "" -"For Yes/No/Abstain per candidate and Yes/No per candidate the 100-%-base " -"depends on the election method: If there is only one option per candidate, " -"the sum of all votes of all candidates is 100 %. Otherwise for each " -"candidate the sum of all votes is 100 %." -msgstr "" - msgid "Forgot Password?" msgstr "" @@ -1020,6 +1048,12 @@ msgstr "" msgid "General" msgstr "" +msgid "General Abstain" +msgstr "" + +msgid "General No" +msgstr "" + msgid "Generate new passwords" msgstr "" @@ -1050,9 +1084,15 @@ msgstr "" msgid "Has amendments" msgstr "" +msgid "Has been voted for" +msgstr "" + msgid "Has no speakers" msgstr "" +msgid "Has not been voted for" +msgstr "" + msgid "Has notes" msgstr "" @@ -1089,7 +1129,7 @@ msgstr "" msgid "Hide the amount of speakers in subtitle of list of speakers slide" msgstr "" -msgid "Hint for ballot paper" +msgid "Hint on voting" msgstr "" msgid "History" @@ -1101,6 +1141,9 @@ msgstr "" msgid "How to create new amendments" msgstr "" +msgid "I know the risk" +msgstr "" + msgid "Identifier" msgstr "" @@ -1127,6 +1170,9 @@ msgstr "" msgid "In motion list, motion detail and PDF." msgstr "" +msgid "In the election process" +msgstr "" + msgid "Inactive" msgstr "" @@ -1268,6 +1314,9 @@ msgstr "" msgid "List of speakers overlay" msgstr "" +msgid "List of votes" +msgstr "" + msgid "List view" msgstr "" @@ -1286,7 +1335,7 @@ msgstr "" msgid "Main motion and line number" msgstr "" -msgid "Mark as elected" +msgid "Mark as personal favorite" msgstr "" msgid "Mark speaker" @@ -1304,6 +1353,9 @@ msgstr "" msgid "Meta information" msgstr "" +msgid "More" +msgstr "" + msgid "Motion" msgstr "" @@ -1508,6 +1560,9 @@ msgstr "" msgid "No recommendation" msgstr "" +msgid "No results yet." +msgstr "" + msgid "No search result found" msgstr "" @@ -1526,6 +1581,9 @@ msgstr "" msgid "None" msgstr "" +msgid "Not suitable for formal secret voting!" +msgstr "" + msgid "Note, that the default password will be changed to the new generated one." msgstr "" @@ -1537,6 +1595,9 @@ msgstr "" msgid "Notes" msgstr "" +msgid "Number candidates" +msgstr "" + msgid "Number motions" msgstr "" @@ -1549,6 +1610,9 @@ msgstr "" msgid "Number of all participants" msgstr "" +msgid "Number of ballot papers" +msgstr "" + msgid "Number of ballot papers (selection)" msgstr "" @@ -1599,7 +1663,7 @@ msgid "" "fields are optional and may be empty." msgstr "" -msgid "One vote per candidate" +msgid "Online voting is impossible to secure" msgstr "" msgid "Only countdown" @@ -1767,12 +1831,6 @@ msgstr "" msgid "Please enter your new password" msgstr "" -msgid "Please fill in all required values" -msgstr "" - -msgid "Please fill in the values for each candidate" -msgstr "" - msgid "Please select the directory:" msgstr "" @@ -1809,12 +1867,6 @@ msgstr "" msgid "Previous slides" msgstr "" -msgid "Print ballot paper" -msgstr "" - -msgid "Print ballot papers" -msgstr "" - msgid "Privacy Policy" msgstr "" @@ -1854,15 +1906,15 @@ msgstr "" msgid "Publish" msgstr "" +msgid "Publish immediately" +msgstr "" + msgid "Put all candidates on the list of speakers" msgstr "" msgid "Queue" msgstr "" -msgid "Quorum" -msgstr "" - msgid "Re-add last speaker" msgstr "" @@ -1872,6 +1924,9 @@ msgstr "" msgid "Reason required for creating new motion" msgstr "" +msgid "Received votes" +msgstr "" + msgid "Recommendation" msgstr "" @@ -2182,6 +2237,9 @@ msgstr "" msgid "Simple majority" msgstr "" +msgid "Single votes" +msgstr "" + msgid "Slide" msgstr "" @@ -2200,6 +2258,9 @@ msgstr "" msgid "Sort comments" msgstr "" +msgid "Sort election results by amount of votes" +msgstr "" + msgid "Sort list of speakers" msgstr "" @@ -2215,9 +2276,6 @@ msgstr "" msgid "Speakers" msgstr "" -msgid "Special values" -msgstr "" - msgid "Staff" msgstr "" @@ -2230,6 +2288,9 @@ msgstr "" msgid "Start time" msgstr "" +msgid "Start voting" +msgstr "" + msgid "State" msgstr "" @@ -2263,18 +2324,30 @@ msgstr "" msgid "Stop submitting new motions by non-staff users" msgstr "" +msgid "Stop voting" +msgstr "" + msgid "Structure level" msgstr "" msgid "Subcategory" msgstr "" +msgid "Submit selection now?" +msgstr "" + +msgid "Submit vote now" +msgstr "" + msgid "Submitters" msgstr "" msgid "Submitters changed" msgstr "" +msgid "Sum of votes including general No/Abstain" +msgstr "" + msgid "Summary of changes" msgstr "" @@ -2317,12 +2390,6 @@ msgstr "" msgid "Text separator" msgstr "" -msgid "The 100 % base of a voting result consists of" -msgstr "" - -msgid "The 100-%-base of an election result consists of" -msgstr "" - msgid "The assembly may decide:" msgstr "" @@ -2341,6 +2408,9 @@ msgstr "" msgid "The file seems to have some ommitted columns. They will be considered empty." msgstr "" +msgid "The individual votes were anonymized." +msgstr "" + msgid "The link is broken. Please contact your system administrator." msgstr "" @@ -2505,7 +2575,7 @@ msgstr "" msgid "Undone" msgstr "" -msgid "Unpublish" +msgid "Unknown user" msgstr "" msgid "Unsupport" @@ -2563,9 +2633,18 @@ msgstr "" msgid "Vote created" msgstr "" +msgid "Vote currently possible" +msgstr "" + msgid "Vote deleted" msgstr "" +msgid "Vote finished" +msgstr "" + +msgid "Vote not possible" +msgstr "" + msgid "Vote updated" msgstr "" @@ -2578,9 +2657,24 @@ msgstr "" msgid "Voting and ballot papers" msgstr "" +msgid "Voting is currently in progress." +msgstr "" + +msgid "Voting method" +msgstr "" + +msgid "Voting opened" +msgstr "" + msgid "Voting result" msgstr "" +msgid "Voting successful." +msgstr "" + +msgid "Voting type" +msgstr "" + msgid "WEP" msgstr "" @@ -2599,9 +2693,6 @@ msgstr "" msgid "WPA/WPA2" msgstr "" -msgid "Waiting for results" -msgstr "" - msgid "Web interface header logo" msgstr "" @@ -2639,6 +2730,9 @@ msgstr "" msgid "Yes" msgstr "" +msgid "Yes per candidate" +msgstr "" + msgid "Yes/No" msgstr "" @@ -2669,6 +2763,9 @@ msgstr "" msgid "You do not have the required permission to see that page!" msgstr "" +msgid "You have already voted." +msgstr "" + msgid "You have to fill this field." msgstr "" @@ -2678,6 +2775,12 @@ msgstr "" msgid "You override the personally set password!" msgstr "" +msgid "You reached the maximum amount of votes. Deselect somebody first." +msgstr "" + +msgid "Your decision cannot be changed afterwards." +msgstr "" + msgid "Your password was resetted successfully!" msgstr "" @@ -2714,6 +2817,9 @@ msgstr "" msgid "adjourned" msgstr "" +msgid "analog" +msgstr "" + msgid "and" msgstr "" @@ -2732,6 +2838,9 @@ msgstr "" msgid "contribution" msgstr "" +msgid "created" +msgstr "" + msgid "custom" msgstr "" @@ -2756,6 +2865,9 @@ msgstr "" msgid "female" msgstr "" +msgid "finished (unpublished)" +msgstr "" + msgid "fullscreen" msgstr "" @@ -2777,9 +2889,6 @@ msgstr "" msgid "internal" msgstr "" -msgid "is elected" -msgstr "" - msgid "is now" msgstr "" @@ -2813,6 +2922,12 @@ msgstr "" msgid "no committee" msgstr "" +msgid "nominal" +msgstr "" + +msgid "non-nominal" +msgstr "" + msgid "none" msgstr "" @@ -2822,15 +2937,12 @@ msgstr "" msgid "not decided" msgstr "" -msgid "not reached" -msgstr "" - -msgid "not reached." -msgstr "" - msgid "of" msgstr "" +msgid "open votes" +msgstr "" + msgid "outside" msgstr "" @@ -2849,12 +2961,6 @@ msgstr "" msgid "published" msgstr "" -msgid "reached" -msgstr "" - -msgid "reached." -msgstr "" - msgid "refered to committee" msgstr "" @@ -2876,6 +2982,9 @@ msgstr "" msgid "selected" msgstr "" +msgid "started" +msgstr "" + msgid "statute paragraphs have been imported." msgstr "" @@ -2894,9 +3003,6 @@ msgstr "" msgid "undocumented" msgstr "" -msgid "unpublished" -msgstr "" - msgid "with filter" msgstr "" diff --git a/client/src/assets/styles/global-components-style.scss b/client/src/assets/styles/global-components-style.scss index df1853054..e657fdd85 100644 --- a/client/src/assets/styles/global-components-style.scss +++ b/client/src/assets/styles/global-components-style.scss @@ -99,6 +99,10 @@ font-weight: 400; } + .user-subtitle { + color: mat-color($foreground, secondary-text); + } + mat-card-header { background-color: mat-color($background, app-bar); } @@ -136,6 +140,15 @@ right: 0; } + .icon { + color: mat-color($foreground, icon); + } + + .small-icon { + @extend .icon; + font-size: 18px; + } + /** Custom themes for NGrid. Could be an own file if it gets more */ .pbl-ngrid-container { background: mat-color($background, card); @@ -149,7 +162,15 @@ background-color: rgba(0, 0, 0, 0.025); } - .mat-progress-bar-buffer { - background-color: mat-color($background, card) !important; + .pbl-ngrid-header-row, .pbl-ngrid-row { + align-items: stretch; + } + + .primary-foreground { + color: mat-color($primary); + } + + .accent-foreground { + color: mat-color($accent); } } diff --git a/client/src/assets/styles/poll-colors.scss b/client/src/assets/styles/poll-colors.scss new file mode 100644 index 000000000..c5df5bc8c --- /dev/null +++ b/client/src/assets/styles/poll-colors.scss @@ -0,0 +1,10 @@ +/** + * Define the colors used for yes, no and abstain + */ +$votes-yes-color: #4caf50; +$votes-no-color: #cc6c5b; +$votes-abstain-color: #a6a6a6; +$vote-active-color: white; +$poll-start-color: #4caf50; +$poll-stop-color: #ff5252; +$poll-publish-color: #e6b100; diff --git a/client/src/assets/styles/poll-common-styles.scss b/client/src/assets/styles/poll-common-styles.scss deleted file mode 100644 index 9e37c7a1d..000000000 --- a/client/src/assets/styles/poll-common-styles.scss +++ /dev/null @@ -1,63 +0,0 @@ -.poll-result { - .poll-progress-bar { - height: 5px; - width: 100%; - .mat-progress-bar { - height: 100%; - width: 100%; - } - } - .poll-progress { - display: flex; - margin-bottom: 15px; - margin-top: 15px; - mat-icon { - min-width: 40px; - margin-right: 5px; - } - .progress-container { - width: 85%; - } - } -} - -.poll-progress-bar { - mat-progress-bar { - &.progress-green { - .mat-progress-bar-fill::after { - background-color: #4caf50; - } - .mat-progress-bar-buffer { - background-color: #d5ecd5; - } - } - &.progress-red { - .mat-progress-bar-fill::after { - background-color: #f44336; - } - .mat-progress-bar-buffer { - background-color: #fcd2cf; - } - } - &.progress-yellow { - .mat-progress-bar-fill::after { - background-color: #ffc107; - } - .mat-progress-bar-buffer { - background-color: #fff0c4; - } - } - } -} - -.poll-quorum-line { - display: flex; - vertical-align: bottom; - .mat-button { - padding: 1px; - } -} - -.main-nav-color { - color: rgba(0, 0, 0, 0.54); -} diff --git a/client/src/assets/styles/poll-styles-common.scss b/client/src/assets/styles/poll-styles-common.scss new file mode 100644 index 000000000..7612dfb4a --- /dev/null +++ b/client/src/assets/styles/poll-styles-common.scss @@ -0,0 +1,40 @@ +@import '~assets/styles/poll-colors.scss'; + +.yes { + color: $votes-yes-color; +} + +.no { + color: $votes-no-color; +} + +.abstain { + color: $votes-abstain-color; +} + +.voted-yes { + background-color: $votes-yes-color; + color: $vote-active-color; +} + +.voted-no { + background-color: $votes-no-color; + color: $vote-active-color; +} + +.voted-abstain { + background-color: $votes-abstain-color; + color: $vote-active-color; +} + +.start-poll-button { + color: $poll-start-color; +} + +.stop-poll-button { + color: $poll-stop-color; +} + +.publish-poll-button { + color: $poll-publish-color; +} diff --git a/client/src/styles.scss b/client/src/styles.scss index 8cff8d348..5c2b2570b 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -27,6 +27,11 @@ @import './app/site/config/components/config-field/config-field.component.scss-theme.scss'; @import './app/site/motions/modules/motion-detail/components/amendment-create-wizard/amendment-create-wizard.components.scss-theme.scss'; @import './app/site/motions/modules/motion-detail/components/motion-detail-diff/motion-detail-diff.component.scss-theme.scss'; +@import './app/shared/components/banner/banner.component.scss-theme.scss'; +@import './app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss-theme.scss'; +@import './app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss'; +@import './app/site/assignments/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss'; +@import './app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss'; /** fonts */ @import './assets/styles/fonts.scss'; @@ -54,6 +59,11 @@ $narrow-spacing: ( @include os-config-field-style($theme); @include os-amendment-create-wizard-style($theme); @include os-motion-detail-diff-style($theme); + @include os-banner-style($theme); + @include os-motion-poll-style($theme); + @include os-motion-poll-detail-style($theme); + @include os-assignment-poll-detail-style($theme); + @include os-progress-snack-bar-style($theme); } /** Load projector specific SCSS values */ @@ -282,6 +292,10 @@ b, font-weight: 500; } +.italic { + font-style: italic; +} + .generic-mini-button { bottom: -28px; z-index: 100; @@ -850,6 +864,39 @@ button.mat-menu-item.selected { } } +/** + * Fix to enable multi line mat hints. See: + * https://github.com/angular/components/issues/5227 + */ +.mat-form-field { + .mat-form-field-wrapper { + padding-bottom: 0; + + .mat-form-field-underline { + position: initial !important; + display: block; + margin-top: -1px; + } + + .mat-form-field-subscript-wrapper, + .mat-form-field-ripple { + position: initial !important; + display: table; + } + + .mat-form-field-subscript-wrapper { + min-height: calc(1em + 1px); + } + } +} + +/** + * Use to disable events on (i.e) matMenuTriggerFor + */ +.disabled { + pointer-events: none; +} + // custom horrizontal scroll-bar .h-scroller { diff --git a/openslides/agenda/apps.py b/openslides/agenda/apps.py index 75d532715..88baf1fb8 100644 --- a/openslides/agenda/apps.py +++ b/openslides/agenda/apps.py @@ -64,7 +64,7 @@ class AgendaAppConfig(AppConfig): yield self.get_model("ListOfSpeakers") -def required_users(element: Dict[str, Any]) -> Set[int]: +async def required_users(element: Dict[str, Any]) -> Set[int]: """ Returns all user ids that are displayed as speaker in the given element. """ diff --git a/openslides/agenda/mixins.py b/openslides/agenda/mixins.py index db80a62ae..4272a7a48 100644 --- a/openslides/agenda/mixins.py +++ b/openslides/agenda/mixins.py @@ -21,16 +21,22 @@ class AgendaItemMixin(models.Model): class Meta(Unsafe): abstract = True - """ - Container for runtime information for agenda app (on create or update of this instance). - Can be an attribute of an item, e.g. "type", "parent_id", "comment", "duration", "weight", - or "create", which determinates, if the items should be created. If not given, the - config value is used. - """ - agenda_item_update_information: Dict[str, Any] = {} - agenda_item_skip_autoupdate = False + def __init__(self, *args, **kwargs): + self.agenda_item_update_information: Dict[str, Any] = {} + """ + Container for runtime information for agenda app (on create or update of this instance). + Can be an attribute of an item, e.g. "type", "parent_id", "comment", "duration", "weight", + or "create", which determinates, if the items should be created. If not given, the + config value is used. + + Important: Do not just write this into the class definition, becuase the object would become + shared within all instances inherited from this class! + """ + + super().__init__(*args, **kwargs) + @property def agenda_item(self): """ diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index d8f992401..e25f00498 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -12,20 +12,24 @@ from openslides.core.config import config from openslides.core.models import Countdown from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError -from openslides.utils.models import RESTModelMixin +from openslides.utils.manager import BaseManager +from openslides.utils.models import ( + CASCADE_AND_AUTOUPDATE, + SET_NULL_AND_AUTOUPDATE, + RESTModelMixin, +) from openslides.utils.utils import to_roman -from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE from .access_permissions import ItemAccessPermissions, ListOfSpeakersAccessPermissions -class ItemManager(models.Manager): +class ItemManager(BaseManager): """ Customized model manager with special methods for agenda tree and numbering. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all items. In the background all related items (topics, motions, assignments) are prefetched from the database. @@ -34,7 +38,11 @@ class ItemManager(models.Manager): # because this is some kind of cyclic lookup. The _prefetched_objects_cache of every # content object will hold wrong values for the agenda item. # See issue #4738 - return self.get_queryset().prefetch_related("content_object") + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .prefetch_related("content_object", "parent") + ) def get_only_non_public_items(self): """ @@ -331,17 +339,18 @@ class Item(RESTModelMixin, models.Model): return self.parent.level + 1 -class ListOfSpeakersManager(models.Manager): - """ - """ - - def get_full_queryset(self): +class ListOfSpeakersManager(BaseManager): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all items. In the background all speakers and related items (topics, motions, assignments) are prefetched from the database. """ - return self.get_queryset().prefetch_related("speakers", "content_object") + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .prefetch_related("speakers", "content_object") + ) class ListOfSpeakers(RESTModelMixin, models.Model): @@ -417,12 +426,12 @@ class SpeakerManager(models.Manager): list of speakers and that someone is twice on one list (off coming speakers). Cares also initial sorting of the coming speakers. """ + if isinstance(user, AnonymousUser): + raise OpenSlidesError("An anonymous user can not be on lists of speakers.") if self.filter( user=user, list_of_speakers=list_of_speakers, begin_time=None ).exists(): raise OpenSlidesError(f"{user} is already on the list of speakers.") - if isinstance(user, AnonymousUser): - raise OpenSlidesError("An anonymous user can not be on lists of speakers.") if config["agenda_present_speakers_only"] and not user.is_present: raise OpenSlidesError("Only present users can be on the lists of speakers.") weight = ( @@ -434,7 +443,11 @@ class SpeakerManager(models.Manager): speaker = self.model( list_of_speakers=list_of_speakers, user=user, weight=weight + 1 ) - speaker.save(force_insert=True, skip_autoupdate=skip_autoupdate) + speaker.save( + force_insert=True, + skip_autoupdate=skip_autoupdate, + no_delete_on_restriction=True, + ) return speaker diff --git a/openslides/agenda/signals.py b/openslides/agenda/signals.py index ed876ea0d..8e2fb38d0 100644 --- a/openslides/agenda/signals.py +++ b/openslides/agenda/signals.py @@ -35,7 +35,6 @@ def listen_to_related_object_post_save(sender, instance, created, **kwargs): if is_agenda_item_content_object: if created: - if instance.get_collection_string() == "topics/topic": should_create_item = True elif config["agenda_item_creation"] == "always": diff --git a/openslides/agenda/views.py b/openslides/agenda/views.py index 0d98122f9..9d60f3c70 100644 --- a/openslides/agenda/views.py +++ b/openslides/agenda/views.py @@ -356,8 +356,7 @@ class ListOfSpeakersViewSet( # Send new speaker via autoupdate because users without permission # to see users may not have it but can get it now. - inform_changed_data([user]) - # TODO: inform_changed_data(user) should work. But isinstance(user, Iterable) is true... + inform_changed_data(user, disable_history=True) # Toggle 'marked' for the speaker elif request.method == "PATCH": diff --git a/openslides/assignments/access_permissions.py b/openslides/assignments/access_permissions.py index f37ed22d6..ee64c404c 100644 --- a/openslides/assignments/access_permissions.py +++ b/openslides/assignments/access_permissions.py @@ -1,7 +1,9 @@ -from typing import Any, Dict, List - +from ..poll.access_permissions import ( + BaseOptionAccessPermissions, + BasePollAccessPermissions, + BaseVoteAccessPermissions, +) from ..utils.access_permissions import BaseAccessPermissions -from ..utils.auth import async_has_perm class AssignmentAccessPermissions(BaseAccessPermissions): @@ -11,34 +13,18 @@ class AssignmentAccessPermissions(BaseAccessPermissions): base_permission = "assignments.can_see" - async def get_restricted_data( - self, full_data: List[Dict[str, Any]], user_id: int - ) -> List[Dict[str, Any]]: - """ - Returns the restricted serialized data for the instance prepared - for the user. Removes unpublished polls for non admins so that they - only get a result like the AssignmentShortSerializer would give them. - """ - # Parse data. - if await async_has_perm( - user_id, "assignments.can_see" - ) and await async_has_perm(user_id, "assignments.can_manage"): - data = full_data - elif await async_has_perm(user_id, "assignments.can_see"): - # Exclude unpublished poll votes. - data = [] - for full in full_data: - full_copy = full.copy() - polls = full_copy["polls"] - for poll in polls: - if not poll["published"]: - for option in poll["options"]: - option["votes"] = [] # clear votes for not published polls - poll[ - "has_votes" - ] = False # A user should see, if there are votes. - data.append(full_copy) - else: - data = [] - return data +class AssignmentPollAccessPermissions(BasePollAccessPermissions): + base_permission = "assignments.can_see" + manage_permission = "assignments.can_manage" + additional_fields = ["amount_global_no", "amount_global_abstain"] + + +class AssignmentOptionAccessPermissions(BaseOptionAccessPermissions): + base_permission = "assignments.can_see" + manage_permission = "assignments.can_manage" + + +class AssignmentVoteAccessPermissions(BaseVoteAccessPermissions): + base_permission = "assignments.can_see" + manage_permission = "assignments.can_manage" diff --git a/openslides/assignments/apps.py b/openslides/assignments/apps.py index 2c4e9b018..fb65e081e 100644 --- a/openslides/assignments/apps.py +++ b/openslides/assignments/apps.py @@ -15,7 +15,12 @@ class AssignmentsAppConfig(AppConfig): from . import serializers # noqa from .projector import register_projector_slides from .signals import get_permission_change_data - from .views import AssignmentViewSet, AssignmentPollViewSet + from .views import ( + AssignmentViewSet, + AssignmentPollViewSet, + AssignmentOptionViewSet, + AssignmentVoteViewSet, + ) # Define projector elements. register_projector_slides() @@ -30,11 +35,27 @@ class AssignmentsAppConfig(AppConfig): router.register( self.get_model("Assignment").get_collection_string(), AssignmentViewSet ) - router.register("assignments/poll", AssignmentPollViewSet) + router.register( + self.get_model("AssignmentPoll").get_collection_string(), + AssignmentPollViewSet, + ) + router.register( + self.get_model("AssignmentOption").get_collection_string(), + AssignmentOptionViewSet, + ) + router.register( + self.get_model("AssignmentVote").get_collection_string(), + AssignmentVoteViewSet, + ) # Register required_users required_user.add_collection_string( - self.get_model("Assignment").get_collection_string(), required_users + self.get_model("Assignment").get_collection_string(), + required_users_assignments, + ) + required_user.add_collection_string( + self.get_model("AssignmentPoll").get_collection_string(), + required_users_options, ) def get_config_variables(self): @@ -47,17 +68,42 @@ class AssignmentsAppConfig(AppConfig): Yields all Cachables required on startup i. e. opening the websocket connection. """ - yield self.get_model("Assignment") + for model_name in ( + "Assignment", + "AssignmentPoll", + "AssignmentVote", + "AssignmentOption", + ): + yield self.get_model(model_name) -def required_users(element: Dict[str, Any]) -> Set[int]: +async def required_users_assignments(element: Dict[str, Any]) -> Set[int]: """ Returns all user ids that are displayed as candidates (including poll options) in the assignment element. """ + from openslides.assignments.models import AssignmentPoll, AssignmentOption + from openslides.utils.cache import element_cache + candidates = set( related_user["user_id"] for related_user in element["assignment_related_users"] ) - for poll in element["polls"]: - candidates.update(option["candidate_id"] for option in poll["options"]) + for poll_id in element["polls_id"]: + poll = await element_cache.get_element_data( + AssignmentPoll.get_collection_string(), poll_id + ) + if poll: + for option_id in poll["options_id"]: + option = await element_cache.get_element_data( + AssignmentOption.get_collection_string(), option_id + ) + if option: + candidates.add(option["user_id"]) return candidates + + +async def required_users_options(element: Dict[str, Any]) -> Set[int]: + """ + Returns all user ids that have voted on an option and are therefore required for the single votes table. + """ + return element["voted_id"] diff --git a/openslides/assignments/config_variables.py b/openslides/assignments/config_variables.py index a7bc6ead4..64b7f12e0 100644 --- a/openslides/assignments/config_variables.py +++ b/openslides/assignments/config_variables.py @@ -1,77 +1,96 @@ from django.core.validators import MinValueValidator +from openslides.assignments.models import AssignmentPoll from openslides.core.config import ConfigVariable -from openslides.poll.majority import majorityMethods def get_config_variables(): """ Generator which yields all config variables of this app. - They are grouped in 'Ballot and ballot papers' and 'PDF'. The generator has to be evaluated during app loading (see apps.py). """ - # Ballot and ballot papers + # Voting yield ConfigVariable( - name="assignments_poll_vote_values", - default_value="auto", + name="assignment_poll_method", + default_value="votes", input_type="choice", - label="Election method", - choices=( - {"value": "auto", "display_name": "Automatic assign of method"}, - {"value": "votes", "display_name": "Always one option per candidate"}, - { - "value": "yesnoabstain", - "display_name": "Always Yes-No-Abstain per candidate", - }, - {"value": "yesno", "display_name": "Always Yes/No per candidate"}, + label="Default election method", + choices=tuple( + {"value": method[0], "display_name": method[1]} + for method in AssignmentPoll.POLLMETHODS ), + weight=400, + group="Elections", + subgroup="Ballot", + ) + + yield ConfigVariable( + name="assignment_poll_default_100_percent_base", + default_value="valid", + input_type="choice", + label="Default 100 % base of an election result", + choices=tuple( + {"value": base[0], "display_name": base[1]} + for base in AssignmentPoll.PERCENT_BASES + ), + weight=405, + group="Elections", + subgroup="Ballot", + ) + + yield ConfigVariable( + name="assignment_poll_default_groups", + default_value=[], + input_type="groups", + label="Default groups with voting rights", weight=410, group="Elections", - subgroup="Ballot and ballot papers", + subgroup="Ballot", ) yield ConfigVariable( - name="assignments_poll_100_percent_base", - default_value="YES_NO_ABSTAIN", + name="assignment_poll_default_majority_method", + default_value="simple", input_type="choice", - label="The 100-%-base of an election result consists of", - choices=( - {"value": "YES_NO_ABSTAIN", "display_name": "Yes/No/Abstain per candidate"}, - {"value": "YES_NO", "display_name": "Yes/No per candidate"}, - {"value": "VALID", "display_name": "All valid ballots"}, - {"value": "CAST", "display_name": "All casted ballots"}, - {"value": "DISABLED", "display_name": "Disabled (no percents)"}, + choices=tuple( + {"value": method[0], "display_name": method[1]} + for method in AssignmentPoll.MAJORITY_METHODS ), - help_text=( - "For Yes/No/Abstain per candidate and Yes/No per candidate the 100-%-base " - "depends on the election method: If there is only one option per candidate, " - "the sum of all votes of all candidates is 100 %. Otherwise for each " - "candidate the sum of all votes is 100 %." - ), - weight=420, - group="Elections", - subgroup="Ballot and ballot papers", - ) - - # TODO: Add server side validation of the choices. - yield ConfigVariable( - name="assignments_poll_default_majority_method", - default_value=majorityMethods[0]["value"], - input_type="choice", - choices=majorityMethods, label="Required majority", help_text="Default method to check whether a candidate has reached the required majority.", - weight=425, + weight=415, + hidden=True, group="Elections", - subgroup="Ballot and ballot papers", + subgroup="Ballot", ) + yield ConfigVariable( + name="assignment_poll_sort_poll_result_by_votes", + default_value=True, + input_type="boolean", + label="Sort election results by amount of votes", + weight=420, + group="Elections", + subgroup="Ballot", + ) + + yield ConfigVariable( + name="assignment_poll_add_candidates_to_list_of_speakers", + default_value=True, + input_type="boolean", + label="Put all candidates on the list of speakers", + weight=425, + group="Elections", + subgroup="Ballot", + ) + + # Ballot Paper yield ConfigVariable( name="assignments_pdf_ballot_papers_selection", default_value="CUSTOM_NUMBER", input_type="choice", - label="Number of ballot papers (selection)", + label="Number of ballot papers", choices=( {"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"}, { @@ -85,7 +104,7 @@ def get_config_variables(): ), weight=430, group="Elections", - subgroup="Ballot and ballot papers", + subgroup="Ballot papers", ) yield ConfigVariable( @@ -95,22 +114,11 @@ def get_config_variables(): label="Custom number of ballot papers", weight=435, group="Elections", - subgroup="Ballot and ballot papers", + subgroup="Ballot papers", validators=(MinValueValidator(1),), ) - yield ConfigVariable( - name="assignments_add_candidates_to_list_of_speakers", - default_value=True, - input_type="boolean", - label="Put all candidates on the list of speakers", - weight=440, - group="Elections", - subgroup="Ballot and ballot papers", - ) - # PDF - yield ConfigVariable( name="assignments_pdf_title", default_value="Elections", diff --git a/openslides/assignments/migrations/0008_voting_1.py b/openslides/assignments/migrations/0008_voting_1.py new file mode 100644 index 000000000..851d4d9dc --- /dev/null +++ b/openslides/assignments/migrations/0008_voting_1.py @@ -0,0 +1,234 @@ +# Generated by Django 2.2.6 on 2019-10-17 08:40 + +from decimal import Decimal + +import django.core.validators +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0011_postgresql_auth_group_id_sequence"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("assignments", "0007_assignment_attachments"), + ] + + operations = [ + migrations.RenameField( + model_name="assignmentoption", old_name="candidate", new_name="user" + ), + migrations.AddField( + model_name="assignmentpoll", + name="global_abstain", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="assignmentpoll", + name="global_no", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="assignmentpoll", + name="db_amount_global_abstain", + field=models.DecimalField( + blank=True, + decimal_places=6, + max_digits=15, + null=True, + validators=[django.core.validators.MinValueValidator(Decimal("-2"))], + ), + ), + migrations.AddField( + model_name="assignmentpoll", + name="db_amount_global_no", + field=models.DecimalField( + blank=True, + decimal_places=6, + max_digits=15, + null=True, + validators=[django.core.validators.MinValueValidator(Decimal("-2"))], + ), + ), + migrations.AddField( + model_name="assignmentpoll", + name="groups", + field=models.ManyToManyField(blank=True, to="users.Group"), + ), + migrations.AddField( + model_name="assignmentpoll", + name="state", + field=models.IntegerField( + choices=[ + (1, "Created"), + (2, "Started"), + (3, "Finished"), + (4, "Published"), + ], + default=1, + ), + ), + migrations.AddField( + model_name="assignmentpoll", + name="title", + field=models.CharField(default="Poll", max_length=255, blank=True), + preserve_default=False, + ), + migrations.AddField( + model_name="assignmentpoll", + name="type", + field=models.CharField( + choices=[ + ("analog", "Analog"), + ("named", "Named"), + ("pseudoanonymous", "Pseudoanonymous"), + ], + default="analog", + max_length=64, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="assignmentpoll", + name="votes_amount", + field=models.IntegerField( + default=1, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + migrations.AddField( + model_name="assignmentpoll", + name="voted", + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name="assignmentvote", + name="user", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="assignmentpoll", + name="allow_multiple_votes_per_candidate", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="assignmentpoll", + name="majority_method", + field=models.CharField( + choices=[ + ("simple", "Simple majority"), + ("two_thirds", "Two-thirds majority"), + ("three_quarters", "Three-quarters majority"), + ("disabled", "Disabled"), + ], + default="", + max_length=14, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="assignmentpoll", + name="onehundred_percent_base", + field=models.CharField( + choices=[ + ("YN", "Yes/No per candidate"), + ("YNA", "Yes/No/Abstain per candidate"), + ("votes", "Sum of votes including general No/Abstain"), + ("valid", "All valid ballots"), + ("cast", "All casted ballots"), + ("disabled", "Disabled (no percents)"), + ], + default="", + max_length=8, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="assignment", + name="number_poll_candidates", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="assignment", + name="poll_description_default", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AlterField( + model_name="assignmentoption", + name="poll", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, + related_name="options", + to="assignments.AssignmentPoll", + ), + ), + migrations.AlterField( + model_name="assignmentvote", + name="option", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, + related_name="votes", + to="assignments.AssignmentOption", + ), + ), + migrations.RenameField( + model_name="assignment", + old_name="poll_description_default", + new_name="default_poll_description", + ), + migrations.AlterField( + model_name="assignmentpoll", + name="description", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AlterField( + model_name="assignmentpoll", + name="pollmethod", + field=models.CharField( + choices=[ + ("votes", "Yes per candidate"), + ("YN", "Yes/No per candidate"), + ("YNA", "Yes/No/Abstain per candidate"), + ], + max_length=5, + ), + ), + migrations.AlterField( + model_name="assignmentvote", + name="weight", + field=models.DecimalField( + decimal_places=6, + default=Decimal("1"), + max_digits=15, + validators=[django.core.validators.MinValueValidator(Decimal("-2"))], + ), + ), + migrations.AlterField( + model_name="assignmentpoll", + name="assignment", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, + related_name="polls", + to="assignments.Assignment", + ), + ), + migrations.RenameField( + model_name="assignmentpoll", old_name="votescast", new_name="db_votescast" + ), + migrations.RenameField( + model_name="assignmentpoll", + old_name="votesinvalid", + new_name="db_votesinvalid", + ), + migrations.RenameField( + model_name="assignmentpoll", old_name="votesvalid", new_name="db_votesvalid" + ), + ] diff --git a/openslides/assignments/migrations/0009_voting_2.py b/openslides/assignments/migrations/0009_voting_2.py new file mode 100644 index 000000000..fc5d1501c --- /dev/null +++ b/openslides/assignments/migrations/0009_voting_2.py @@ -0,0 +1,145 @@ +# Generated by Finn Stutzenstein on 2019-10-29 10:55 + +from decimal import Decimal + +from django.db import migrations, transaction + + +def change_pollmethods(apps, schema_editor): + """ yn->YN, yna->YNA """ + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + pollmethod_map = { + "yn": "YN", + "yna": "YNA", + "votes": "votes", + } + for poll in AssignmentPoll.objects.all(): + poll.pollmethod = pollmethod_map.get(poll.pollmethod, "YNA") + poll.save(skip_autoupdate=True) + + +def set_poll_titles(apps, schema_editor): + """ + Sets titles to their indexes + """ + Assignment = apps.get_model("assignments", "Assignment") + for assignment in Assignment.objects.all(): + for i, poll in enumerate(assignment.polls.order_by("pk").all()): + poll.title = str(i + 1) + poll.save(skip_autoupdate=True) + + +def set_onehunderd_percent_bases(apps, schema_editor): + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + ConfigStore = apps.get_model("core", "ConfigStore") + base_map = { + "YES_NO_ABSTAIN": "YNA", + "YES_NO": "YN", + "VALID": "valid", + "CAST": "cast", + "DISABLED": "disabled", + } + try: + config = ConfigStore.objects.get(key="assignments_poll_100_percent_base") + value = base_map[config.value] + except (ConfigStore.DoesNotExist, KeyError): + value = "YNA" + + for poll in AssignmentPoll.objects.all(): + if poll.pollmethod == "votes" and value in ("YN", "YNA"): + poll.onehundred_percent_base = "votes" + elif poll.pollmethod == "YN" and value == "YNA": + poll.onehundred_percent_base = "YN" + else: + poll.onehundred_percent_base = value + poll.save(skip_autoupdate=True) + + +def set_majority_methods(apps, schema_editor): + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + ConfigStore = apps.get_model("core", "ConfigStore") + majority_map = { + "simple_majority": "simple", + "two-thirds_majority": "two_thirds", + "three-quarters_majority": "three_quarters", + "disabled": "disabled", + } + try: + config = ConfigStore.objects.get(key="assignments_poll_default_majority_method") + value = majority_map[config.value] + except (ConfigStore.DoesNotExist, KeyError): + value = "simple" + + for poll in AssignmentPoll.objects.all(): + poll.majority_method = value + poll.save(skip_autoupdate=True) + + +def convert_votes(apps, schema_editor): + AssignmentVote = apps.get_model("assignments", "AssignmentVote") + value_map = { + "Yes": "Y", + "No": "N", + "Abstain": "A", + "Votes": "Y", + } + for vote in AssignmentVote.objects.all(): + vote.value = value_map[vote.value] + vote.save(skip_autoupdate=True) + + +def convert_votesabstain(apps, schema_editor): + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + AssignmentVote = apps.get_model("assignments", "AssignmentVote") + for poll in AssignmentPoll.objects.all(): + if poll.votesabstain is not None and poll.votesabstain > Decimal(0): + with transaction.atomic(): + option = poll.options.first() + vote = AssignmentVote( + option=option, value="A", weight=poll.votesabstain + ) + vote.save(skip_autoupdate=True) + + +def convert_votesno(apps, schema_editor): + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + AssignmentVote = apps.get_model("assignments", "AssignmentVote") + for poll in AssignmentPoll.objects.all(): + if poll.votesno is not None and poll.votesno > Decimal(0): + with transaction.atomic(): + option = poll.options.first() + vote = AssignmentVote(option=option, value="N", weight=poll.votesno) + vote.save(skip_autoupdate=True) + + +def set_correct_state(apps, schema_editor): + """ if poll.published, set state to published """ + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + AssignmentVote = apps.get_model("assignments", "AssignmentVote") + for poll in AssignmentPoll.objects.all(): + # Voting, that are published (old field) but have no votes, will be + # left at the created state... + if AssignmentVote.objects.filter(option__poll__pk=poll.pk).exists(): + if poll.published: + poll.state = 4 # published + else: + poll.state = 3 # finished + poll.save(skip_autoupdate=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("assignments", "0008_voting_1"), + ] + + operations = [ + migrations.RunPython(change_pollmethods), + migrations.RunPython(set_poll_titles), + migrations.RunPython(set_onehunderd_percent_bases), + migrations.RunPython(set_majority_methods), + migrations.RunPython(convert_votes), + migrations.RunPython(convert_votesabstain), + migrations.RunPython(convert_votesno), + migrations.RunPython(set_correct_state), + ] diff --git a/openslides/assignments/migrations/0010_voting_3.py b/openslides/assignments/migrations/0010_voting_3.py new file mode 100644 index 000000000..b3f8bf115 --- /dev/null +++ b/openslides/assignments/migrations/0010_voting_3.py @@ -0,0 +1,24 @@ +# Generated by Finn Stutzenstein on 2019-10-29 11:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assignments", "0009_voting_2"), + ] + + operations = [ + migrations.AlterField( + model_name="assignmentvote", + name="value", + field=models.CharField( + choices=[("Y", "Y"), ("N", "N"), ("A", "A")], max_length=1 + ), + ), + migrations.RemoveField(model_name="assignmentpoll", name="votesabstain"), + migrations.RemoveField(model_name="assignmentpoll", name="votesno"), + migrations.RemoveField(model_name="assignmentpoll", name="published"), + migrations.RemoveField(model_name="assignmentrelateduser", name="elected",), + ] diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 062246909..4b0361b7d 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -1,6 +1,4 @@ -from collections import OrderedDict from decimal import Decimal -from typing import Any, Dict, List from django.conf import settings from django.core.validators import MinValueValidator @@ -11,19 +9,19 @@ from openslides.agenda.models import Speaker from openslides.core.config import config from openslides.core.models import Tag from openslides.mediafiles.models import Mediafile -from openslides.poll.models import ( - BaseOption, - BasePoll, - BaseVote, - CollectDefaultVotesMixin, - PublishPollMixin, -) +from openslides.poll.models import BaseOption, BasePoll, BaseVote from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError +from openslides.utils.manager import BaseManager from openslides.utils.models import RESTModelMixin from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE -from .access_permissions import AssignmentAccessPermissions +from .access_permissions import ( + AssignmentAccessPermissions, + AssignmentOptionAccessPermissions, + AssignmentPollAccessPermissions, + AssignmentVoteAccessPermissions, +) class AssignmentRelatedUser(RESTModelMixin, models.Model): @@ -43,11 +41,6 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model): ForeinKey to the user who is related to the assignment. """ - elected = models.BooleanField(default=False) - """ - Saves the election state of each user - """ - weight = models.IntegerField(default=0) """ The sort order of the candidates. @@ -67,24 +60,30 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model): return self.assignment -class AssignmentManager(models.Manager): +class AssignmentManager(BaseManager): """ - Customized model manager to support our get_full_queryset method. + Customized model manager to support our get_prefetched_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all assignments. In the background all related users (candidates), the related agenda item and all polls are prefetched from the database. """ - return self.get_queryset().prefetch_related( - "related_users", - "agenda_items", - "lists_of_speakers", - "polls", - "tags", - "attachments", + + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .prefetch_related( + "assignment_related_users", + "agenda_items", + "lists_of_speakers", + "tags", + "attachments", + "polls", + "polls__options", + ) ) @@ -123,7 +122,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model The number of members to be elected. """ - poll_description_default = models.CharField(max_length=79, blank=True) + default_poll_description = models.CharField(max_length=255, blank=True) """ Default text for the poll description. """ @@ -137,7 +136,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model settings.AUTH_USER_MODEL, through="AssignmentRelatedUser" ) """ - Users that are candidates or elected. + Users that are candidates. See AssignmentRelatedUser for more information. """ @@ -152,6 +151,11 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model Mediafiles as attachments for this assignment. """ + number_poll_candidates = models.BooleanField(default=False) + """ + Controls whether the candidates in polls for this assignment should be numbered or listed with bullet points. + """ + class Meta: default_permissions = () permissions = ( @@ -171,14 +175,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model """ Queryset that represents the candidates for the assignment. """ - return self.related_users.filter(assignmentrelateduser__elected=False) - - @property - def elected(self): - """ - Queryset that represents all elected users for the assignment. - """ - return self.related_users.filter(assignmentrelateduser__elected=True) + return self.related_users.all() def is_candidate(self, user): """ @@ -188,15 +185,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model """ return self.candidates.filter(pk=user.pk).exists() - def is_elected(self, user): - """ - Returns True if the user is elected for this assignment. - - Costs one database query. - """ - return self.elected.filter(pk=user.pk).exists() - - def set_candidate(self, user): + def add_candidate(self, user): """ Adds the user as candidate. """ @@ -204,18 +193,10 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model self.assignment_related_users.aggregate(models.Max("weight"))["weight__max"] or 0 ) - defaults = {"elected": False, "weight": weight + 1} + defaults = {"weight": weight + 1} self.assignment_related_users.update_or_create(user=user, defaults=defaults) - def set_elected(self, user): - """ - Makes user an elected user for this assignment. - """ - self.assignment_related_users.update_or_create( - user=user, defaults={"elected": True} - ) - - def delete_related_user(self, user): + def remove_candidate(self, user): """ Delete the connection from the assignment to the user. """ @@ -233,187 +214,226 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model self.phase = phase - def create_poll(self): - """ - Creates a new poll for the assignment and adds all candidates to all - lists of speakers of related agenda items. - """ - candidates = self.candidates.all() - - # Find out the method of the election - if config["assignments_poll_vote_values"] == "votes": - pollmethod = "votes" - elif config["assignments_poll_vote_values"] == "yesnoabstain": - pollmethod = "yna" - elif config["assignments_poll_vote_values"] == "yesno": - pollmethod = "yn" - else: - # config['assignments_poll_vote_values'] == 'auto' - # candidates <= available posts -> yes/no/abstain - if len(candidates) <= (self.open_posts - self.elected.count()): - pollmethod = "yna" - else: - pollmethod = "votes" - - # Create the poll with the candidates. - poll = self.polls.create( - description=self.poll_description_default, pollmethod=pollmethod - ) - options = [] - related_users = AssignmentRelatedUser.objects.filter( - assignment__id=self.id - ).exclude(elected=True) - for related_user in related_users: - options.append( - {"candidate": related_user.user, "weight": related_user.weight} - ) - poll.set_options(options, skip_autoupdate=True) - inform_changed_data(self) - - # Add all candidates to list of speakers of related agenda item - # TODO: Try to do this in a bulk create - if config["assignments_add_candidates_to_list_of_speakers"]: - for candidate in self.candidates: - try: - Speaker.objects.add( - candidate, self.list_of_speakers, skip_autoupdate=True - ) - except OpenSlidesError: - # The Speaker is already on the list. Do nothing. - # TODO: Find a smart way not to catch the error concerning AnonymousUser. - pass - inform_changed_data(self.list_of_speakers) - - return poll - - def vote_results(self, only_published): - """ - Returns a table represented as a list with all candidates from all - related polls and their vote results. - """ - vote_results_dict: Dict[Any, List[AssignmentVote]] = OrderedDict() - - polls = self.polls.all() - if only_published: - polls = polls.filter(published=True) - - # All PollOption-Objects related to this assignment - options: List[AssignmentOption] = [] - for poll in polls: - options += poll.get_options() - - for option in options: - candidate = option.candidate - if candidate in vote_results_dict: - continue - vote_results_dict[candidate] = [] - for poll in polls: - votes: Any = {} - try: - # candidate related to this poll - poll_option = poll.get_options().get(candidate=candidate) - for vote in poll_option.get_votes(): - votes[vote.value] = vote.print_weight() - except AssignmentOption.DoesNotExist: - # candidate not in related to this poll - votes = None - vote_results_dict[candidate].append(votes) - return vote_results_dict - def get_title_information(self): return {"title": self.title} +class AssignmentVoteManager(BaseManager): + """ + Customized model manager to support our get_prefetched_queryset method. + """ + + def get_prefetched_queryset(self, *args, **kwargs): + """ + Returns the normal queryset with all assignment votes. In the background we + join and prefetch all related models. + """ + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .select_related("user", "option", "option__poll") + ) + + class AssignmentVote(RESTModelMixin, BaseVote): + access_permissions = AssignmentVoteAccessPermissions() + objects = AssignmentVoteManager() + option = models.ForeignKey( - "AssignmentOption", on_delete=models.CASCADE, related_name="votes" + "AssignmentOption", on_delete=CASCADE_AND_AUTOUPDATE, related_name="votes" ) class Meta: default_permissions = () - def get_root_rest_element(self): + +class AssignmentOptionManager(BaseManager): + """ + Customized model manager to support our get_prefetched_queryset method. + """ + + def get_prefetched_queryset(self, *args, **kwargs): """ - Returns the assignment to this instance which is the root REST element. + Returns the normal queryset. In the background we + join and prefetch all related models. """ - return self.option.poll.assignment + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .select_related("user", "poll") + .prefetch_related("votes") + ) class AssignmentOption(RESTModelMixin, BaseOption): + access_permissions = AssignmentOptionAccessPermissions() + can_see_permission = "assignments.can_see" + objects = AssignmentOptionManager() + vote_class = AssignmentVote + poll = models.ForeignKey( - "AssignmentPoll", on_delete=models.CASCADE, related_name="options" + "AssignmentPoll", on_delete=CASCADE_AND_AUTOUPDATE, related_name="options" ) - candidate = models.ForeignKey( + user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True ) weight = models.IntegerField(default=0) - vote_class = AssignmentVote - class Meta: default_permissions = () - def __str__(self): - return str(self.candidate) - def get_root_rest_element(self): +class AssignmentPollManager(BaseManager): + """ + Customized model manager to support our get_prefetched_queryset method. + """ + + def get_prefetched_queryset(self, *args, **kwargs): """ - Returns the assignment to this instance which is the root REST element. + Returns the normal queryset with all assignment polls. In the background we + join and prefetch all related models. """ - return self.poll.assignment + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .select_related("assignment") + .prefetch_related( + "options", "options__user", "options__votes", "voted", "groups" + ) + ) -# TODO: remove the type-ignoring in the next line, after this is solved: -# https://github.com/python/mypy/issues/3855 -class AssignmentPoll( # type: ignore - RESTModelMixin, CollectDefaultVotesMixin, PublishPollMixin, BasePoll -): +class AssignmentPoll(RESTModelMixin, BasePoll): + access_permissions = AssignmentPollAccessPermissions() + can_see_permission = "assignments.can_see" + objects = AssignmentPollManager() + option_class = AssignmentOption assignment = models.ForeignKey( - Assignment, on_delete=models.CASCADE, related_name="polls" + Assignment, on_delete=CASCADE_AND_AUTOUPDATE, related_name="polls" ) - pollmethod = models.CharField(max_length=5, default="yna") - description = models.CharField(max_length=79, blank=True) - votesabstain = models.DecimalField( + description = models.CharField(max_length=255, blank=True) + + POLLMETHOD_YN = "YN" + POLLMETHOD_YNA = "YNA" + POLLMETHOD_VOTES = "votes" + POLLMETHODS = ( + (POLLMETHOD_VOTES, "Yes per candidate"), + (POLLMETHOD_YN, "Yes/No per candidate"), + (POLLMETHOD_YNA, "Yes/No/Abstain per candidate"), + ) + pollmethod = models.CharField(max_length=5, choices=POLLMETHODS) + + PERCENT_BASE_YN = "YN" + PERCENT_BASE_YNA = "YNA" + PERCENT_BASE_VOTES = "votes" + PERCENT_BASE_VALID = "valid" + PERCENT_BASE_CAST = "cast" + PERCENT_BASE_DISABLED = "disabled" + PERCENT_BASES = ( + (PERCENT_BASE_YN, "Yes/No per candidate"), + (PERCENT_BASE_YNA, "Yes/No/Abstain per candidate"), + (PERCENT_BASE_VOTES, "Sum of votes including general No/Abstain"), + (PERCENT_BASE_VALID, "All valid ballots"), + (PERCENT_BASE_CAST, "All casted ballots"), + (PERCENT_BASE_DISABLED, "Disabled (no percents)"), + ) + onehundred_percent_base = models.CharField( + max_length=8, blank=False, null=False, choices=PERCENT_BASES + ) + + global_abstain = models.BooleanField(default=True) + db_amount_global_abstain = models.DecimalField( null=True, blank=True, + default=Decimal("0"), validators=[MinValueValidator(Decimal("-2"))], max_digits=15, decimal_places=6, ) - """ General abstain votes, used for pollmethod 'votes' """ - votesno = models.DecimalField( + global_no = models.BooleanField(default=True) + db_amount_global_no = models.DecimalField( null=True, blank=True, + default=Decimal("0"), validators=[MinValueValidator(Decimal("-2"))], max_digits=15, decimal_places=6, ) - """ General no votes, used for pollmethod 'votes' """ + + votes_amount = models.IntegerField(default=1, validators=[MinValueValidator(1)]) + """ For "votes" mode: The amount of votes a voter can give. """ + + allow_multiple_votes_per_candidate = models.BooleanField(default=False) class Meta: default_permissions = () - def get_assignment(self): - return self.assignment + def get_amount_global_abstain(self): + if not self.global_abstain: + return None + elif self.type == self.TYPE_ANALOG: + return self.db_amount_global_abstain + elif self.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: + return sum(option.abstain for option in self.options.all()) + else: + return None - def get_vote_values(self): - if self.pollmethod == "yna": - return ["Yes", "No", "Abstain"] - if self.pollmethod == "yn": - return ["Yes", "No"] - return ["Votes"] + def set_amount_global_abstain(self, value): + if self.type != self.TYPE_ANALOG: + raise ValueError("Do not set amount_global_abstain for non analog polls") + self.db_amount_global_abstain = value - def get_ballot(self): - return self.assignment.polls.filter(id__lte=self.pk).count() + amount_global_abstain = property( + get_amount_global_abstain, set_amount_global_abstain + ) - def get_percent_base_choice(self): - return config["assignments_poll_100_percent_base"] + def get_amount_global_no(self): + if not self.global_no: + return None + elif self.type == self.TYPE_ANALOG: + return self.db_amount_global_no + elif self.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: + return sum(option.no for option in self.options.all()) + else: + return None - def get_root_rest_element(self): - """ - Returns the assignment to this instance which is the root REST element. - """ - return self.assignment + def set_amount_global_no(self, value): + if self.type != self.TYPE_ANALOG: + raise ValueError("Do not set amount_global_no for non analog polls") + self.db_amount_global_no = value + + amount_global_no = property(get_amount_global_no, set_amount_global_no) + + def create_options(self, skip_autoupdate=False): + related_users = AssignmentRelatedUser.objects.filter( + assignment__id=self.assignment.id + ) + + for related_user in related_users: + option = AssignmentOption( + user=related_user.user, weight=related_user.weight, poll=self + ) + option.save(skip_autoupdate=skip_autoupdate) + + # Add all candidates to list of speakers of related agenda item + if config["assignment_poll_add_candidates_to_list_of_speakers"]: + for related_user in related_users: + try: + Speaker.objects.add( + related_user.user, + self.assignment.list_of_speakers, + skip_autoupdate=True, + ) + except OpenSlidesError: + # The Speaker is already on the list. Do nothing. + pass + if not skip_autoupdate: + inform_changed_data(self.assignment.list_of_speakers) + + def reset(self): + self.db_amount_global_abstain = Decimal(0) + self.db_amount_global_no = Decimal(0) + super().reset() diff --git a/openslides/assignments/projector.py b/openslides/assignments/projector.py index 581166f25..413e22abc 100644 --- a/openslides/assignments/projector.py +++ b/openslides/assignments/projector.py @@ -1,12 +1,8 @@ from typing import Any, Dict, List from ..users.projector import get_user_name -from ..utils.projector import ( - AllData, - ProjectorElementException, - get_config, - register_projector_slide, -) +from ..utils.projector import AllData, get_model, get_models, register_projector_slide +from .models import AssignmentPoll # Important: All functions have to be prune. This means, that thay can only @@ -14,30 +10,16 @@ from ..utils.projector import ( # side effects. -def get_assignment(all_data: AllData, id: Any) -> Dict[str, Any]: - if id is None: - raise ProjectorElementException("id is required for assignment slide") - - try: - assignment = all_data["assignments/assignment"][id] - except KeyError: - raise ProjectorElementException(f"assignment with id {id} does not exist") - return assignment - - async def assignment_slide( all_data: AllData, element: Dict[str, Any], projector_id: int ) -> Dict[str, Any]: """ Assignment slide. """ - assignment = get_assignment(all_data, element.get("id")) + assignment = get_model(all_data, "assignments/assignment", element.get("id")) assignment_related_users: List[Dict[str, Any]] = [ - { - "user": await get_user_name(all_data, aru["user_id"]), - "elected": aru["elected"], - } + {"user": await get_user_name(all_data, aru["user_id"])} for aru in sorted( assignment["assignment_related_users"], key=lambda aru: aru["weight"] ) @@ -49,60 +31,63 @@ async def assignment_slide( "open_posts": assignment["open_posts"], "description": assignment["description"], "assignment_related_users": assignment_related_users, + "number_poll_candidates": assignment["number_poll_candidates"], } -async def poll_slide( +async def assignment_poll_slide( all_data: AllData, element: Dict[str, Any], projector_id: int ) -> Dict[str, Any]: """ Poll slide. """ - assignment = get_assignment(all_data, element.get("assignment_id")) + poll = get_model(all_data, "assignments/assignment-poll", element.get("id")) + assignment = get_model(all_data, "assignments/assignment", poll["assignment_id"]) - # get poll - poll_id = element.get("poll_id") - if poll_id is None: - raise ProjectorElementException("id is required for poll slide") + poll_data = { + key: poll[key] + for key in ( + "title", + "type", + "pollmethod", + "votes_amount", + "description", + "state", + "onehundred_percent_base", + "majority_method", + ) + } - poll = None - for p in assignment["polls"]: - if p["id"] == poll_id: - poll = p - break - if poll is None: - raise ProjectorElementException(f"poll with id {poll_id} does not exist") + # Add options: + poll_data["options"] = [] + options = get_models(all_data, "assignments/assignment-option", poll["options_id"]) + for option in sorted(options, key=lambda option: option["weight"]): + option_data: Dict[str, Any] = { + "user": {"full_name": await get_user_name(all_data, option["user_id"])} + } + if poll["state"] == AssignmentPoll.STATE_PUBLISHED: + option_data["yes"] = float(option["yes"]) + option_data["no"] = float(option["no"]) + option_data["abstain"] = float(option["abstain"]) + poll_data["options"].append(option_data) - poll_data = {"published": poll["published"]} - - if poll["published"]: - poll_data["description"] = poll["description"] - poll_data["has_votes"] = poll["has_votes"] - poll_data["pollmethod"] = poll["pollmethod"] - poll_data["votesno"] = poll["votesno"] - poll_data["votesabstain"] = poll["votesabstain"] - poll_data["votesvalid"] = poll["votesvalid"] - poll_data["votesinvalid"] = poll["votesinvalid"] - poll_data["votescast"] = poll["votescast"] - - poll_data["options"] = [ - { - "user": await get_user_name(all_data, option["candidate_id"]), - "is_elected": option["is_elected"], - "votes": option["votes"], - } - for option in sorted(poll["options"], key=lambda option: option["weight"]) - ] + if poll["state"] == AssignmentPoll.STATE_PUBLISHED: + poll_data["amount_global_no"] = ( + float(poll["amount_global_no"]) if poll["amount_global_no"] else None + ) + poll_data["amount_global_abstain"] = ( + float(poll["amount_global_abstain"]) if poll["amount_global_no"] else None + ) + poll_data["votesvalid"] = float(poll["votesvalid"]) + poll_data["votesinvalid"] = float(poll["votesinvalid"]) + poll_data["votescast"] = float(poll["votescast"]) return { - "title": assignment["title"], - "assignments_poll_100_percent_base": await get_config( - all_data, "assignments_poll_100_percent_base" - ), + "assignment": {"title": assignment["title"]}, "poll": poll_data, } def register_projector_slides() -> None: register_projector_slide("assignments/assignment", assignment_slide) - register_projector_slide("assignments/poll", poll_slide) + register_projector_slide("assignments/assignment-poll", assignment_poll_slide) diff --git a/openslides/assignments/serializers.py b/openslides/assignments/serializers.py index 7c061d143..32459627c 100644 --- a/openslides/assignments/serializers.py +++ b/openslides/assignments/serializers.py @@ -1,14 +1,17 @@ -from django.db import transaction - -from openslides.poll.serializers import default_votes_validator +from openslides.poll.serializers import ( + BASE_OPTION_FIELDS, + BASE_POLL_FIELDS, + BASE_VOTE_FIELDS, + BaseOptionSerializer, + BasePollSerializer, + BaseVoteSerializer, +) from openslides.utils.rest_api import ( BooleanField, DecimalField, - DictField, + IdPrimaryKeyRelatedField, IntegerField, - ListField, ModelSerializer, - SerializerMethodField, ValidationError, ) @@ -42,152 +45,100 @@ class AssignmentRelatedUserSerializer(ModelSerializer): class Meta: model = AssignmentRelatedUser - fields = ( - "id", - "user", - "elected", - "assignment", - "weight", - ) # js-data needs the assignment-id in the nested object to define relations. + fields = ("id", "user", "weight") -class AssignmentVoteSerializer(ModelSerializer): +class AssignmentVoteSerializer(BaseVoteSerializer): """ Serializer for assignment.models.AssignmentVote objects. """ class Meta: model = AssignmentVote - fields = ("weight", "value") + fields = BASE_VOTE_FIELDS + read_only_fields = BASE_VOTE_FIELDS -class AssignmentOptionSerializer(ModelSerializer): +class AssignmentOptionSerializer(BaseOptionSerializer): """ Serializer for assignment.models.AssignmentOption objects. """ - votes = AssignmentVoteSerializer(many=True, read_only=True) - is_elected = SerializerMethodField() - class Meta: model = AssignmentOption - fields = ("id", "candidate", "is_elected", "votes", "poll", "weight") - - def get_is_elected(self, obj): - """ - Returns the election status of the candidate of this option. - If the candidate is None (e.g. deleted) the result is False. - """ - if not obj.candidate: - return False - return obj.poll.assignment.is_elected(obj.candidate) + fields = ("user", "weight") + BASE_OPTION_FIELDS + read_only_fields = ("user", "weight") + BASE_OPTION_FIELDS -class AssignmentAllPollSerializer(ModelSerializer): +class AssignmentPollSerializer(BasePollSerializer): """ Serializer for assignment.models.AssignmentPoll objects. Serializes all polls. """ - options = AssignmentOptionSerializer(many=True, read_only=True) - votes = ListField( - child=DictField( - child=DecimalField(max_digits=15, decimal_places=6, min_value=-2) - ), - write_only=True, - required=False, + amount_global_no = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True + ) + amount_global_abstain = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True ) - has_votes = SerializerMethodField() class Meta: model = AssignmentPoll fields = ( - "id", - "pollmethod", - "description", - "published", - "options", - "votesabstain", - "votesno", - "votesvalid", - "votesinvalid", - "votescast", - "votes", - "has_votes", "assignment", - ) # js-data needs the assignment-id in the nested object to define relations. - read_only_fields = ("pollmethod",) - validators = (default_votes_validator,) + "description", + "pollmethod", + "votes_amount", + "allow_multiple_votes_per_candidate", + "global_no", + "amount_global_no", + "global_abstain", + "amount_global_abstain", + ) + BASE_POLL_FIELDS + read_only_fields = ("state",) - def get_has_votes(self, obj): - """ - Returns True if this poll has some votes. - """ - return obj.has_votes() - - @transaction.atomic def update(self, instance, validated_data): + """ Prevent updating the assignment """ + validated_data.pop("assignment", None) + return super().update(instance, validated_data) + + def norm_100_percent_base_to_pollmethod( + self, onehundred_percent_base, pollmethod, old_100_percent_base=None + ): """ - Customized update method for polls. To update votes use the write - only field 'votes'. - - Example data for a 'pollmethod'='yna' poll with two candidates: - - "votes": [{"Yes": 10, "No": 4, "Abstain": -2}, - {"Yes": -1, "No": 0, "Abstain": -2}] - - Example data for a 'pollmethod' ='yn' poll with two candidates: - "votes": [{"Votes": 10}, {"Votes": 0}] + Returns None, if the 100-%-base must not be changed, otherwise the correct 100-%-base. """ - # Update votes. - votes = validated_data.get("votes") - if votes: - options = list(instance.get_options()) - if len(votes) != len(options): - raise ValidationError( - { - "detail": "You have to submit data for {0} candidates.", - "args": [len(options)], - } - ) - for index, option in enumerate(options): - if len(votes[index]) != len(instance.get_vote_values()): - raise ValidationError( - { - "detail": "You have to submit data for {0} vote values", - "args": [len(instance.get_vote_values())], - } - ) - for vote_value, __ in votes[index].items(): - if vote_value not in instance.get_vote_values(): - raise ValidationError( - { - "detail": "Vote value {0} is invalid.", - "args": [vote_value], - } - ) - instance.set_vote_objects_with_values( - option, votes[index], skip_autoupdate=True - ) - - # Update remaining writeable fields. - instance.description = validated_data.get("description", instance.description) - instance.published = validated_data.get("published", instance.published) - instance.votesabstain = validated_data.get( - "votesabstain", instance.votesabstain - ) - instance.votesno = validated_data.get("votesno", instance.votesno) - instance.votesvalid = validated_data.get("votesvalid", instance.votesvalid) - instance.votesinvalid = validated_data.get( - "votesinvalid", instance.votesinvalid - ) - instance.votescast = validated_data.get("votescast", instance.votescast) - instance.save() - return instance + if pollmethod == AssignmentPoll.POLLMETHOD_YN and onehundred_percent_base in ( + AssignmentPoll.PERCENT_BASE_VOTES, + AssignmentPoll.PERCENT_BASE_YNA, + ): + return AssignmentPoll.PERCENT_BASE_YN + if ( + pollmethod == AssignmentPoll.POLLMETHOD_YNA + and onehundred_percent_base == AssignmentPoll.PERCENT_BASE_VOTES + ): + if old_100_percent_base is None: + return AssignmentPoll.PERCENT_BASE_YNA + else: + if old_100_percent_base in ( + AssignmentPoll.PERCENT_BASE_YN, + AssignmentPoll.PERCENT_BASE_YNA, + ): + return old_100_percent_base + else: + return pollmethod + if ( + pollmethod == AssignmentPoll.POLLMETHOD_VOTES + and onehundred_percent_base + in (AssignmentPoll.PERCENT_BASE_YN, AssignmentPoll.PERCENT_BASE_YNA) + ): + return AssignmentPoll.PERCENT_BASE_VOTES + return None -class AssignmentFullSerializer(ModelSerializer): +class AssignmentSerializer(ModelSerializer): """ Serializer for assignment.models.Assignment objects. With all polls. """ @@ -195,12 +146,12 @@ class AssignmentFullSerializer(ModelSerializer): assignment_related_users = AssignmentRelatedUserSerializer( many=True, read_only=True ) - polls = AssignmentAllPollSerializer(many=True, read_only=True) agenda_create = BooleanField(write_only=True, required=False, allow_null=True) agenda_type = IntegerField( write_only=True, required=False, min_value=1, max_value=3, allow_null=True ) agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1) + polls = IdPrimaryKeyRelatedField(many=True, read_only=True) class Meta: model = Assignment @@ -211,8 +162,7 @@ class AssignmentFullSerializer(ModelSerializer): "open_posts", "phase", "assignment_related_users", - "poll_description_default", - "polls", + "default_poll_description", "agenda_item_id", "list_of_speakers_id", "agenda_create", @@ -220,6 +170,8 @@ class AssignmentFullSerializer(ModelSerializer): "agenda_parent_id", "tags", "attachments", + "number_poll_candidates", + "polls", ) validators = (posts_validator,) diff --git a/openslides/assignments/views.py b/openslides/assignments/views.py index d0ac09214..b5ee02529 100644 --- a/openslides/assignments/views.py +++ b/openslides/assignments/views.py @@ -1,21 +1,27 @@ +from decimal import Decimal + from django.contrib.auth import get_user_model from django.db import transaction +from openslides.poll.views import BaseOptionViewSet, BasePollViewSet, BaseVoteViewSet +from openslides.utils.auth import has_perm from openslides.utils.autoupdate import inform_changed_data from openslides.utils.rest_api import ( - DestroyModelMixin, - GenericViewSet, ModelViewSet, Response, - UpdateModelMixin, ValidationError, detail_route, ) +from openslides.utils.utils import is_int -from ..utils.auth import has_perm from .access_permissions import AssignmentAccessPermissions -from .models import Assignment, AssignmentPoll, AssignmentRelatedUser -from .serializers import AssignmentAllPollSerializer +from .models import ( + Assignment, + AssignmentOption, + AssignmentPoll, + AssignmentRelatedUser, + AssignmentVote, +) # Viewsets for the REST API @@ -26,8 +32,7 @@ class AssignmentViewSet(ModelViewSet): API endpoint for assignments. There are the following views: metadata, list, retrieve, create, - partial_update, update, destroy, candidature_self, candidature_other, - mark_elected and create_poll. + partial_update, update, destroy, candidature_self, candidature_other and create_poll. """ access_permissions = AssignmentAccessPermissions() @@ -47,8 +52,6 @@ class AssignmentViewSet(ModelViewSet): "partial_update", "update", "destroy", - "mark_elected", - "create_poll", "sort_related_users", ): result = has_perm(self.request.user, "assignments.can_see") and has_perm( @@ -76,8 +79,6 @@ class AssignmentViewSet(ModelViewSet): candidature (DELETE). """ assignment = self.get_object() - if assignment.is_elected(request.user): - raise ValidationError({"detail": "You are already elected."}) if request.method == "POST": message = self.nominate_self(request, assignment) else: @@ -98,7 +99,7 @@ class AssignmentViewSet(ModelViewSet): # To nominate self during voting you have to be a manager. self.permission_denied(request) # If the request.user is already a candidate he can nominate himself nevertheless. - assignment.set_candidate(request.user) + assignment.add_candidate(request.user) # Send new candidate via autoupdate because users without permission # to see users may not have it but can get it now. inform_changed_data([request.user]) @@ -121,14 +122,13 @@ class AssignmentViewSet(ModelViewSet): raise ValidationError( {"detail": "You are not a candidate of this election."} ) - assignment.delete_related_user(request.user) + assignment.remove_candidate(request.user) return "You have withdrawn your candidature successfully." def get_user_from_request_data(self, request): """ Helper method to get a specific user from request data (not the - request.user) so that the views self.candidature_other or - self.mark_elected can play with it. + request.user) so that the view self.candidature_other can play with it. """ if not isinstance(request.data, dict): raise ValidationError( @@ -167,10 +167,6 @@ class AssignmentViewSet(ModelViewSet): return self.delete_other(request, user, assignment) def nominate_other(self, request, user, assignment): - if assignment.is_elected(user): - raise ValidationError( - {"detail": "User {0} is already elected.", "args": [str(user)]} - ) if assignment.phase == assignment.PHASE_FINISHED: raise ValidationError( { @@ -186,7 +182,7 @@ class AssignmentViewSet(ModelViewSet): raise ValidationError( {"detail": "User {0} is already nominated.", "args": [str(user)]} ) - assignment.set_candidate(user) + assignment.add_candidate(user) # Send new candidate via autoupdate because users without permission # to see users may not have it but can get it now. inform_changed_data(user) @@ -204,65 +200,18 @@ class AssignmentViewSet(ModelViewSet): "detail": "You can not delete someone's candidature to this election because it is finished." } ) - if not assignment.is_candidate(user) and not assignment.is_elected(user): + if not assignment.is_candidate(user): raise ValidationError( { "detail": "User {0} has no status in this election.", "args": [str(user)], } ) - assignment.delete_related_user(user) + assignment.remove_candidate(user) return Response( {"detail": "Candidate {0} was withdrawn successfully.", "args": [str(user)]} ) - @detail_route(methods=["post", "delete"]) - def mark_elected(self, request, pk=None): - """ - View to mark other users as elected (POST) or undo this (DELETE). - The client has to send {'user': }. - """ - user = self.get_user_from_request_data(request) - assignment = self.get_object() - if request.method == "POST": - if not assignment.is_candidate(user): - raise ValidationError( - { - "detail": "User {0} is not a candidate of this election.", - "args": [str(user)], - } - ) - assignment.set_elected(user) - message = "User {0} was successfully elected." - else: - # request.method == 'DELETE' - if not assignment.is_elected(user): - raise ValidationError( - { - "detail": "User {0} is not an elected candidate of this election.", - "args": [str(user)], - } - ) - assignment.set_candidate(user) - message = "User {0} was successfully unelected." - return Response({"detail": message, "args": [str(user)]}) - - @detail_route(methods=["post"]) - def create_poll(self, request, pk=None): - """ - View to create a poll. It is a POST request without any data. - """ - assignment = self.get_object() - if not assignment.candidates.exists(): - raise ValidationError( - {"detail": "Can not create ballot because there are no candidates."} - ) - with transaction.atomic(): - poll = assignment.create_poll() - return Response( - {"detail": "Ballot created successfully.", "createdPollId": poll.pk} - ) - @detail_route(methods=["post"]) def sort_related_users(self, request, pk=None): """ @@ -309,7 +258,7 @@ class AssignmentViewSet(ModelViewSet): return Response({"detail": "Assignment related users successfully sorted."}) -class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class AssignmentPollViewSet(BasePollViewSet): """ API endpoint for assignment polls. @@ -317,12 +266,309 @@ class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet) """ queryset = AssignmentPoll.objects.all() - serializer_class = AssignmentAllPollSerializer - def check_view_permissions(self): + def has_manage_permissions(self): """ Returns True if the user has required permissions. """ return has_perm(self.request.user, "assignments.can_see") and has_perm( self.request.user, "assignments.can_manage" ) + + def perform_create(self, serializer): + assignment = serializer.validated_data["assignment"] + if not assignment.candidates.exists(): + raise ValidationError( + {"detail": "Cannot create poll because there are no candidates."} + ) + + super().perform_create(serializer) + poll = AssignmentPoll.objects.get(pk=serializer.data["id"]) + poll.db_amount_global_abstain = Decimal(0) + poll.db_amount_global_no = Decimal(0) + poll.save() + + def handle_analog_vote(self, data, poll, user): + for field in ["votesvalid", "votesinvalid", "votescast"]: + setattr(poll, field, data[field]) + + global_no_enabled = ( + poll.global_no and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES + ) + if global_no_enabled: + poll.amount_global_no = data.get("amount_global_no", Decimal(0)) + global_abstain_enabled = ( + poll.global_abstain and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES + ) + if global_abstain_enabled: + poll.amount_global_abstain = data.get("amount_global_abstain", Decimal(0)) + + options = poll.get_options() + options_data = data.get("options") + + with transaction.atomic(): + for option_id, vote in options_data.items(): + option = options.get(pk=int(option_id)) + vote_obj, _ = AssignmentVote.objects.get_or_create( + option=option, value="Y" + ) + vote_obj.weight = vote["Y"] + vote_obj.save() + + if poll.pollmethod in ( + AssignmentPoll.POLLMETHOD_YN, + AssignmentPoll.POLLMETHOD_YNA, + ): + vote_obj, _ = AssignmentVote.objects.get_or_create( + option=option, value="N" + ) + vote_obj.weight = vote["N"] + vote_obj.save() + + if poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA: + vote_obj, _ = AssignmentVote.objects.get_or_create( + option=option, value="A" + ) + vote_obj.weight = vote["A"] + vote_obj.save() + inform_changed_data(option) + + poll.save() + + def validate_vote_data(self, data, poll, user): + """ + Request data: + analog: + { + "options": {: {"Y": , ["N": ], ["A": ] }}, + ["votesvalid": ], ["votesinvalid": ], ["votescast": ], + ["amount_global_no": ], ["amount_global_abstain": ] + } + All amounts are decimals as strings + required fields per pollmethod: + - votes: Y + - YN: YN + - YNA: YNA + named|pseudoanonymous: + votes: + {: } | 'N' | 'A' + - Exactly one of the three options must be given + - 'N' is only valid if poll.global_no==True + - 'A' is only valid if poll.global_abstain==True + - amounts must be integer numbers >= 0. + - ids should be integers of valid option ids for this poll + - amounts must be 0 or 1, if poll.allow_multiple_votes_per_candidate is False + - The sum of all amounts must be grater than 0 and <= poll.votes_amount + + YN/YNA: + {: 'Y' | 'N' [|'A']} + - 'A' is only allowed in YNA pollmethod + """ + if poll.type == AssignmentPoll.TYPE_ANALOG: + if not isinstance(data, dict): + raise ValidationError({"detail": "Data must be a dict"}) + + options_data = data.get("options") + if not isinstance(options_data, dict): + raise ValidationError({"detail": "You must provide options"}) + + for key, value in options_data.items(): + if not is_int(key): + raise ValidationError({"detail": "Keys must be int"}) + if not isinstance(value, dict): + raise ValidationError({"detail": "A dict per option is required"}) + value["Y"] = self.parse_vote_value(value, "Y") + if poll.pollmethod in ( + AssignmentPoll.POLLMETHOD_YN, + AssignmentPoll.POLLMETHOD_YNA, + ): + value["N"] = self.parse_vote_value(value, "N") + if poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA: + value["A"] = self.parse_vote_value(value, "A") + + for field in ["votesvalid", "votesinvalid", "votescast"]: + data[field] = self.parse_vote_value(data, field) + + global_no_enabled = ( + poll.global_no and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES + ) + global_abstain_enabled = ( + poll.global_abstain + and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES + ) + if "amount_global_abstain" in data and global_abstain_enabled: + data["amount_global_abstain"] = self.parse_vote_value( + data, "amount_global_abstain" + ) + if "amount_global_no" in data and global_no_enabled: + data["amount_global_no"] = self.parse_vote_value( + data, "amount_global_no" + ) + + else: + if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: + if isinstance(data, dict): + amount_sum = 0 + for option_id, amount in data.items(): + if not is_int(option_id): + raise ValidationError({"detail": "Each id must be an int."}) + if not AssignmentOption.objects.filter(id=option_id).exists(): + raise ValidationError( + {"detail": f"Option {option_id} does not exist."} + ) + if not is_int(amount): + raise ValidationError( + {"detail": "Each amounts must be int"} + ) + amount = int(amount) + if amount < 0: + raise ValidationError( + {"detail": "Negative votes are not allowed"} + ) + # skip empty votes + if amount == 0: + continue + if not poll.allow_multiple_votes_per_candidate and amount != 1: + raise ValidationError( + {"detail": "Multiple votes are not allowed"} + ) + amount_sum += amount + + if amount_sum > poll.votes_amount: + raise ValidationError( + { + "detail": "You can give a maximum of {0} votes", + "args": [poll.votes_amount], + } + ) + elif data == "N" and poll.global_no: + return # return because we dont have to check option presence + elif data == "A" and poll.global_abstain: + return # return because we dont have to check option presence + else: + raise ValidationError({"detail": "invalid data."}) + + elif poll.pollmethod in ( + AssignmentPoll.POLLMETHOD_YN, + AssignmentPoll.POLLMETHOD_YNA, + ): + if not isinstance(data, dict): + raise ValidationError({"detail": "Data must be a dict."}) + for option_id, value in data.items(): + if not is_int(option_id): + raise ValidationError({"detail": "Keys must be int"}) + if not AssignmentOption.objects.filter(id=option_id).exists(): + raise ValidationError( + {"detail": f"Option {option_id} does not exist."} + ) + if ( + poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA + and value not in ("Y", "N", "A",) + ): + raise ValidationError( + {"detail": "Every value must be Y, N or A"} + ) + elif ( + poll.pollmethod == AssignmentPoll.POLLMETHOD_YN + and value not in ("Y", "N",) + ): + raise ValidationError({"detail": "Every value must be Y or N"}) + + options_data = data + + def create_votes_type_votes(self, data, poll, user): + """ + Helper function for handle_(named|pseudoanonymous)_vote + Assumes data is already validated + """ + options = poll.get_options() + if isinstance(data, dict): + for option_id, amount in data.items(): + # Add user to the option's voted array + option = options.get(pk=option_id) + # skip creating votes with empty weights + if amount == 0: + continue + vote = AssignmentVote.objects.create( + option=option, user=user, weight=Decimal(amount), value="Y" + ) + inform_changed_data(vote, no_delete_on_restriction=True) + else: # global_no or global_abstain + option = options[0] + vote = AssignmentVote.objects.create( + option=option, user=user, weight=Decimal(1), value=data + ) + inform_changed_data(vote, no_delete_on_restriction=True) + inform_changed_data(option) + inform_changed_data(poll) + + poll.voted.add(user) + + def create_votes_type_named_pseudoanonymous( + self, data, poll, check_user, vote_user + ): + """ check_user is used for the voted-array, vote_user is the one put into the vote """ + options = poll.get_options() + for option_id, result in data.items(): + option = options.get(pk=option_id) + vote = AssignmentVote.objects.create( + option=option, user=vote_user, value=result + ) + inform_changed_data(vote, no_delete_on_restriction=True) + inform_changed_data(option, no_delete_on_restriction=True) + + poll.voted.add(check_user) + + def handle_named_vote(self, data, poll, user): + if user in poll.voted.all(): + raise ValidationError({"detail": "You have already voted"}) + + if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: + self.create_votes_type_votes(data, poll, user) + elif poll.pollmethod in ( + AssignmentPoll.POLLMETHOD_YN, + AssignmentPoll.POLLMETHOD_YNA, + ): + self.create_votes_type_named_pseudoanonymous(data, poll, user, user) + + def handle_pseudoanonymous_vote(self, data, poll, user): + if user in poll.voted.all(): + raise ValidationError({"detail": "You have already voted"}) + + if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: + self.create_votes_type_votes(data, poll, user) + + elif poll.pollmethod in ( + AssignmentPoll.POLLMETHOD_YN, + AssignmentPoll.POLLMETHOD_YNA, + ): + self.create_votes_type_named_pseudoanonymous(data, poll, user, None) + + def convert_option_data(self, poll, data): + poll_options = poll.get_options() + new_option_data = {} + option_data = data.get("options") + if option_data is None: + raise ValidationError({"detail": "You must provide options"}) + for id, val in option_data.items(): + option = poll_options.filter(user_id=id).first() + if option is None: + raise ValidationError( + {"detail": f"Assignment related user with id {id} not found"} + ) + new_option_data[option.id] = val + data["options"] = new_option_data + + +class AssignmentOptionViewSet(BaseOptionViewSet): + queryset = AssignmentOption.objects.all() + + def check_view_permissions(self): + return has_perm(self.request.user, "assignments.can_see") + + +class AssignmentVoteViewSet(BaseVoteViewSet): + queryset = AssignmentVote.objects.all() + + def check_view_permissions(self): + return has_perm(self.request.user, "assignments.can_see") diff --git a/openslides/core/apps.py b/openslides/core/apps.py index 0967428f0..5a3fe5155 100644 --- a/openslides/core/apps.py +++ b/openslides/core/apps.py @@ -144,6 +144,7 @@ class CoreAppConfig(AppConfig): "PRIORITIZED_GROUP_IDS", "PING_INTERVAL", "PING_TIMEOUT", + "ENABLE_ELECTRONIC_VOTING", ] client_settings_dict = {} for key in client_settings_keys: diff --git a/openslides/core/config.py b/openslides/core/config.py index cf913e84d..d4f39a271 100644 --- a/openslides/core/config.py +++ b/openslides/core/config.py @@ -23,6 +23,7 @@ INPUT_TYPE_MAPPING = { "datetimepicker": int, "static": dict, "translations": list, + "groups": list, } ALLOWED_NONE = ("datetimepicker",) @@ -143,6 +144,13 @@ class ConfigHandler: ): raise ConfigError("Invalid input. Choice does not match.") + if config_variable.input_type == "groups": + from ..users.models import Group + + groups = set(group.id for group in Group.objects.all()) + if not groups.issuperset(set(value)): + raise ConfigError("Invalid input. Chosen group does not exist.") + for validator in config_variable.validators: try: validator(value) diff --git a/openslides/core/migrations/0029_remove_history_restricted.py b/openslides/core/migrations/0029_remove_history_restricted.py new file mode 100644 index 000000000..dc555d992 --- /dev/null +++ b/openslides/core/migrations/0029_remove_history_restricted.py @@ -0,0 +1,10 @@ +# Generated by Django 2.2.6 on 2019-10-28 11:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("core", "0028_projector_size_3")] + + operations = [migrations.RemoveField(model_name="history", name="restricted")] diff --git a/openslides/core/migrations/0030_voting_projection_defaults.py b/openslides/core/migrations/0030_voting_projection_defaults.py new file mode 100644 index 000000000..1b779d837 --- /dev/null +++ b/openslides/core/migrations/0030_voting_projection_defaults.py @@ -0,0 +1,40 @@ +# Generated by Fin Stutzenstein on 2019-20-11 16:30 +from django.db import migrations + + +def add_poll_projection_defaults(apps, schema_editor): + """ + Adds projectiondefaults for messages and countdowns. + """ + Projector = apps.get_model("core", "Projector") + ProjectionDefault = apps.get_model("core", "ProjectionDefault") + default_projector = Projector.objects.order_by("pk").first() + + projectiondefaults = [] + + projectiondefaults.append( + ProjectionDefault( + name="assignment_poll", + display_name="Assignment poll", + projector=default_projector, + ) + ) + projectiondefaults.append( + ProjectionDefault( + name="motion_poll", display_name="Motion Poll", projector=default_projector + ) + ) + + # Create all new projectiondefaults + ProjectionDefault.objects.bulk_create(projectiondefaults) + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0029_remove_history_restricted"), + ] + + operations = [ + migrations.RunPython(add_poll_projection_defaults), + ] diff --git a/openslides/core/models.py b/openslides/core/models.py index ab8697a7e..e192d3955 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -1,13 +1,17 @@ +from typing import Iterable + from asgiref.sync import async_to_sync from django.conf import settings from django.db import models, transaction from django.utils.timezone import now from jsonfield import JSONField -from ..utils.autoupdate import Element -from ..utils.cache import element_cache, get_element_id -from ..utils.locking import locking -from ..utils.models import SET_NULL_AND_AUTOUPDATE, RESTModelMixin +from openslides.utils.autoupdate import AutoupdateElement +from openslides.utils.cache import element_cache, get_element_id +from openslides.utils.locking import locking +from openslides.utils.manager import BaseManager +from openslides.utils.models import SET_NULL_AND_AUTOUPDATE, RESTModelMixin + from .access_permissions import ( ConfigAccessPermissions, CountdownAccessPermissions, @@ -18,17 +22,21 @@ from .access_permissions import ( ) -class ProjectorManager(models.Manager): +class ProjectorManager(BaseManager): """ - Customized model manager to support our get_full_queryset method. + Customized model manager to support our get_prefetched_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all projectors. In the background projector defaults are prefetched from the database. """ - return self.get_queryset().prefetch_related("projectiondefaults") + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .prefetch_related("projectiondefaults") + ) class Projector(RESTModelMixin, models.Model): @@ -249,12 +257,12 @@ class HistoryData(models.Model): default_permissions = () -class HistoryManager(models.Manager): +class HistoryManager(BaseManager): """ Customized model manager for the history model. """ - def add_elements(self, elements): + def add_elements(self, elements: Iterable[AutoupdateElement]): """ Method to add elements to the history. This does not trigger autoupdate. """ @@ -266,14 +274,13 @@ class HistoryManager(models.Manager): # Do not update history if history is disabled. continue # HistoryData is not a root rest element so there is no autoupdate and not history saving here. - data = HistoryData.objects.create(full_data=element["full_data"]) + data = HistoryData.objects.create(full_data=element.get("full_data")) instance = self.model( element_id=get_element_id( element["collection_string"], element["id"] ), now=history_time, information=element.get("information", []), - restricted=element.get("restricted", False), user_id=element.get("user_id"), full_data=data, ) @@ -297,7 +304,7 @@ class HistoryManager(models.Manager): for collection_string, data in all_full_data.items(): for full_data in data: elements.append( - Element( + AutoupdateElement( id=full_data["id"], collection_string=collection_string, full_data=full_data, @@ -324,8 +331,6 @@ class History(models.Model): information = JSONField() - restricted = models.BooleanField(default=False) - user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL ) diff --git a/openslides/core/signals.py b/openslides/core/signals.py index 68de03f7f..ffb964470 100644 --- a/openslides/core/signals.py +++ b/openslides/core/signals.py @@ -9,7 +9,7 @@ from django.db.models import Q from django.dispatch import Signal from ..utils import logging -from ..utils.autoupdate import Element, inform_changed_elements +from ..utils.autoupdate import AutoupdateElement, inform_elements # This signal is send when the migrate command is done. That means it is sent @@ -100,18 +100,16 @@ def autoupdate_for_many_to_many_relations(sender, instance, **kwargs): ) for field in m2m_fields: queryset = getattr(instance, field.get_accessor_name()).all() + elements = [] for related_instance in queryset: if hasattr(related_instance, "get_root_rest_element"): # The related instance is or has a root rest element. # So lets send it via autoupdate. root_rest_element = related_instance.get_root_rest_element() - inform_changed_elements( - [ - Element( - collection_string=root_rest_element.get_collection_string(), - id=root_rest_element.pk, - full_data=None, - reload=True, - ) - ] + elements.append( + AutoupdateElement( + collection_string=root_rest_element.get_collection_string(), + id=root_rest_element.pk, + ) ) + inform_elements(elements) diff --git a/openslides/mediafiles/config.py b/openslides/mediafiles/config.py index 83f727904..cb42ba88d 100644 --- a/openslides/mediafiles/config.py +++ b/openslides/mediafiles/config.py @@ -12,12 +12,12 @@ def watch_and_update_configs(): are updated. """ # 1) map logo and font config keys to mediafile ids - mediafiles = Mediafile.objects.get_full_queryset().all() + mediafiles = Mediafile.objects.get_prefetched_queryset().all() logos = build_mapping("logos_available", mediafiles) fonts = build_mapping("fonts_available", mediafiles) yield # 2) update changed paths/urls - mediafiles = Mediafile.objects.get_full_queryset().all() + mediafiles = Mediafile.objects.get_prefetched_queryset().all() update_mapping(logos, mediafiles) update_mapping(fonts, mediafiles) diff --git a/openslides/mediafiles/models.py b/openslides/mediafiles/models.py index 8dada566c..8c7a04c42 100644 --- a/openslides/mediafiles/models.py +++ b/openslides/mediafiles/models.py @@ -7,6 +7,7 @@ from django.db import connections, models from jsonfield import JSONField from openslides.utils import logging +from openslides.utils.manager import BaseManager from ..agenda.mixins import ListOfSpeakersMixin from ..core.config import config @@ -23,18 +24,20 @@ if "mediafiles" in connections: logger.info("Using a standalone mediafile database") -class MediafileManager(models.Manager): +class MediafileManager(BaseManager): """ Customized model manager to support our get_full_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all mediafiles. In the background all related list of speakers are prefetched from the database. """ - return self.get_queryset().prefetch_related( - "lists_of_speakers", "parent", "access_groups" + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .prefetch_related("lists_of_speakers", "parent", "access_groups") ) def delete(self, *args, **kwargs): diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py index 621666bfb..6000b552d 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -1,6 +1,11 @@ import json from typing import Any, Dict, List +from ..poll.access_permissions import ( + BaseOptionAccessPermissions, + BasePollAccessPermissions, + BaseVoteAccessPermissions, +) from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm, async_in_some_groups @@ -179,3 +184,18 @@ class StateAccessPermissions(BaseAccessPermissions): """ base_permission = "motions.can_see" + + +class MotionPollAccessPermissions(BasePollAccessPermissions): + base_permission = "motions.can_see" + manage_permission = "motions.can_manage_polls" + + +class MotionOptionAccessPermissions(BaseOptionAccessPermissions): + base_permission = "motions.can_see" + manage_permission = "motions.can_manage_polls" + + +class MotionVoteAccessPermissions(BaseVoteAccessPermissions): + base_permission = "motions.can_see" + manage_permission = "motions.can_manage_polls" diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py index 73e9319dd..087aedb0d 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -20,8 +20,10 @@ class MotionsAppConfig(AppConfig): StatuteParagraphViewSet, MotionViewSet, MotionCommentSectionViewSet, + MotionVoteViewSet, MotionBlockViewSet, MotionPollViewSet, + MotionOptionViewSet, MotionChangeRecommendationViewSet, StateViewSet, WorkflowViewSet, @@ -66,11 +68,22 @@ class MotionsAppConfig(AppConfig): router.register( self.get_model("MotionPoll").get_collection_string(), MotionPollViewSet ) + router.register( + self.get_model("MotionOption").get_collection_string(), MotionOptionViewSet + ) + router.register( + self.get_model("MotionVote").get_collection_string(), MotionVoteViewSet + ) router.register(self.get_model("State").get_collection_string(), StateViewSet) # Register required_users required_user.add_collection_string( - self.get_model("Motion").get_collection_string(), required_users + self.get_model("Motion").get_collection_string(), required_users_motions + ) + + required_user.add_collection_string( + self.get_model("MotionPoll").get_collection_string(), + required_users_options, ) def get_config_variables(self): @@ -92,11 +105,14 @@ class MotionsAppConfig(AppConfig): "State", "MotionChangeRecommendation", "MotionCommentSection", + "MotionPoll", + "MotionOption", + "MotionVote", ): yield self.get_model(model_name) -def required_users(element: Dict[str, Any]) -> Set[int]: +async def required_users_motions(element: Dict[str, Any]) -> Set[int]: """ Returns all user ids that are displayed as as submitter or supporter in any motion if request_user can see motions. This function may return an @@ -107,3 +123,10 @@ def required_users(element: Dict[str, Any]) -> Set[int]: ) submitters_supporters.update(element["supporters_id"]) return submitters_supporters + + +async def required_users_options(element: Dict[str, Any]) -> Set[int]: + """ + Returns all user ids that have voted on an option and are therefore required for the single votes table. + """ + return element["voted_id"] diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index abc052e74..f9761aa3c 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -1,7 +1,7 @@ from django.core.validators import MinValueValidator from openslides.core.config import ConfigVariable -from openslides.poll.majority import majorityMethods +from openslides.motions.models import MotionPoll from .models import Workflow @@ -332,30 +332,40 @@ def get_config_variables(): # Voting and ballot papers yield ConfigVariable( - name="motions_poll_100_percent_base", - default_value="YES_NO_ABSTAIN", + name="motion_poll_default_100_percent_base", + default_value="YNA", input_type="choice", - label="The 100 % base of a voting result consists of", - choices=( - {"value": "YES_NO_ABSTAIN", "display_name": "Yes/No/Abstain"}, - {"value": "YES_NO", "display_name": "Yes/No"}, - {"value": "VALID", "display_name": "All valid ballots"}, - {"value": "CAST", "display_name": "All casted ballots"}, - {"value": "DISABLED", "display_name": "Disabled (no percents)"}, + label="Default 100 % base of a voting result", + choices=tuple( + {"value": base[0], "display_name": base[1]} + for base in MotionPoll.PERCENT_BASES ), weight=370, group="Motions", subgroup="Voting and ballot papers", ) - # TODO: Add server side validation of the choices. yield ConfigVariable( - name="motions_poll_default_majority_method", - default_value=majorityMethods[0]["value"], + name="motion_poll_default_majority_method", + default_value="simple", input_type="choice", - choices=majorityMethods, + choices=tuple( + {"value": method[0], "display_name": method[1]} + for method in MotionPoll.MAJORITY_METHODS + ), label="Required majority", help_text="Default method to check whether a motion has reached the required majority.", + weight=371, + hidden=True, + group="Motions", + subgroup="Voting and ballot papers", + ) + + yield ConfigVariable( + name="motion_poll_default_groups", + default_value=[], + input_type="groups", + label="Default groups with voting rights", weight=372, group="Motions", subgroup="Voting and ballot papers", @@ -365,7 +375,7 @@ def get_config_variables(): name="motions_pdf_ballot_papers_selection", default_value="CUSTOM_NUMBER", input_type="choice", - label="Number of ballot papers (selection)", + label="Number of ballot papers", choices=( {"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"}, { @@ -377,7 +387,7 @@ def get_config_variables(): "display_name": "Use the following custom number", }, ), - weight=374, + weight=373, group="Motions", subgroup="Voting and ballot papers", ) @@ -387,7 +397,7 @@ def get_config_variables(): default_value=8, input_type="integer", label="Custom number of ballot papers", - weight=376, + weight=374, group="Motions", subgroup="Voting and ballot papers", validators=(MinValueValidator(1),), diff --git a/openslides/motions/migrations/0033_voting_1.py b/openslides/motions/migrations/0033_voting_1.py new file mode 100644 index 000000000..b680625ee --- /dev/null +++ b/openslides/motions/migrations/0033_voting_1.py @@ -0,0 +1,186 @@ +# Generated by Django 2.2.6 on 2019-10-17 09:00 + +from decimal import Decimal + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0011_postgresql_auth_group_id_sequence"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("motions", "0032_category_cascade_delete"), + ] + + operations = [ + migrations.AddField( + model_name="motionpoll", + name="groups", + field=models.ManyToManyField(blank=True, to="users.Group"), + ), + migrations.AddField( + model_name="motionpoll", + name="state", + field=models.IntegerField( + choices=[ + (1, "Created"), + (2, "Started"), + (3, "Finished"), + (4, "Published"), + ], + default=1, + ), + ), + migrations.AddField( + model_name="motionpoll", + name="title", + field=models.CharField(default="Poll", blank=True, max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name="motionpoll", + name="type", + field=models.CharField( + choices=[ + ("analog", "Analog"), + ("named", "Named"), + ("pseudoanonymous", "Pseudoanonymous"), + ], + default="analog", + max_length=64, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="motionpoll", + name="voted", + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name="motionvote", + name="user", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="motionpoll", + name="pollmethod", + field=models.CharField( + choices=[("YN", "YN"), ("YNA", "YNA")], default="YNA", max_length=3 + ), + preserve_default=False, + ), + migrations.AddField( + model_name="motionpoll", + name="majority_method", + field=models.CharField( + choices=[ + ("simple", "Simple majority"), + ("two_thirds", "Two-thirds majority"), + ("three_quarters", "Three-quarters majority"), + ("disabled", "Disabled"), + ], + default="", + max_length=14, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="motionpoll", + name="onehundred_percent_base", + field=models.CharField( + choices=[ + ("YN", "Yes/No"), + ("YNA", "Yes/No/Abstain"), + ("valid", "All valid ballots"), + ("cast", "All casted ballots"), + ("disabled", "Disabled (no percents)"), + ], + default="", + max_length=8, + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="motionvote", + name="option", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, + related_name="votes", + to="motions.MotionOption", + ), + ), + migrations.AlterField( + model_name="motionvote", + name="value", + field=models.CharField( + choices=[("Y", "Y"), ("N", "N"), ("A", "A")], max_length=1 + ), + ), + migrations.AlterField( + model_name="motionvote", + name="weight", + field=models.DecimalField( + decimal_places=6, + default=Decimal("1"), + max_digits=15, + validators=[django.core.validators.MinValueValidator(Decimal("-2"))], + ), + ), + migrations.AlterField( + model_name="motionoption", + name="poll", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, + related_name="options", + to="motions.MotionPoll", + ), + ), + migrations.AlterField( + model_name="motionpoll", + name="motion", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, + related_name="polls", + to="motions.Motion", + ), + ), + migrations.RenameField( + model_name="motionpoll", old_name="votescast", new_name="db_votescast" + ), + migrations.RenameField( + model_name="motionpoll", old_name="votesinvalid", new_name="db_votesinvalid" + ), + migrations.RenameField( + model_name="motionpoll", old_name="votesvalid", new_name="db_votesvalid" + ), + migrations.AlterModelOptions( + name="motion", + options={ + "default_permissions": (), + "ordering": ("identifier",), + "permissions": ( + ("can_see", "Can see motions"), + ("can_see_internal", "Can see motions in internal state"), + ("can_create", "Can create motions"), + ("can_create_amendments", "Can create amendments"), + ("can_support", "Can support motions"), + ("can_manage_metadata", "Can manage motion metadata"), + ("can_manage", "Can manage motions"), + ("can_manage_polls", "Can manage motion polls"), + ), + "verbose_name": "Motion", + }, + ), + ] diff --git a/openslides/motions/migrations/0034_voting_2.py b/openslides/motions/migrations/0034_voting_2.py new file mode 100644 index 000000000..1fb620d86 --- /dev/null +++ b/openslides/motions/migrations/0034_voting_2.py @@ -0,0 +1,108 @@ +# Generated by Finn Stutzenstein on 2019-10-30 13:43 + +from django.db import migrations + + +def change_pollmethods(apps, schema_editor): + """ yn->YN, yna->YNA """ + MotionPoll = apps.get_model("motions", "MotionPoll") + pollmethod_map = { + "yn": "YN", + "yna": "YNA", + } + for poll in MotionPoll.objects.all(): + poll.pollmethod = pollmethod_map.get(poll.pollmethod, "YNA") + poll.save(skip_autoupdate=True) + + +def set_poll_titles(apps, schema_editor): + """ + Sets titles to their indexes + """ + Motion = apps.get_model("motions", "Motion") + for motion in Motion.objects.all(): + for i, poll in enumerate(motion.polls.order_by("pk").all()): + poll.title = str(i + 1) + poll.save(skip_autoupdate=True) + + +def set_onehunderd_percent_bases(apps, schema_editor): + MotionPoll = apps.get_model("motions", "MotionPoll") + ConfigStore = apps.get_model("core", "ConfigStore") + base_map = { + "YES_NO_ABSTAIN": "YNA", + "YES_NO": "YN", + "VALID": "valid", + "CAST": "cast", + "DISABLED": "disabled", + } + try: + config = ConfigStore.objects.get(key="motions_poll_100_percent_base") + value = base_map[config.value] + except (ConfigStore.DoesNotExist, KeyError): + value = "YNA" + + for poll in MotionPoll.objects.all(): + # The pollmethod is new (default is YNA), so we do not need + # to check, if the 100% base is valid. + poll.onehundred_percent_base = value + poll.save(skip_autoupdate=True) + + +def set_majority_methods(apps, schema_editor): + MotionPoll = apps.get_model("motions", "MotionPoll") + ConfigStore = apps.get_model("core", "ConfigStore") + majority_map = { + "simple_majority": "simple", + "two-thirds_majority": "two_thirds", + "three-quarters_majority": "three_quarters", + "disabled": "disabled", + } + try: + config = ConfigStore.objects.get(key="motions_poll_default_majority_method") + value = majority_map[config.value] + except (ConfigStore.DoesNotExist, KeyError): + value = "simple" + + for poll in MotionPoll.objects.all(): + poll.majority_method = value + poll.save(skip_autoupdate=True) + + +def convert_votes(apps, schema_editor): + MotionVote = apps.get_model("motions", "MotionVote") + value_map = { + "Yes": "Y", + "No": "N", + "Abstain": "A", + } + for vote in MotionVote.objects.all(): + if vote.value not in value_map.values(): + vote.value = value_map[vote.value] + vote.save(skip_autoupdate=True) + + +def set_correct_state(apps, schema_editor): + """ If there are votes, set the state to finished """ + MotionPoll = apps.get_model("motions", "MotionPoll") + MotionVote = apps.get_model("motions", "MotionVote") + for poll in MotionPoll.objects.all(): + if MotionVote.objects.filter(option__poll__pk=poll.pk).exists(): + poll.state = 3 # finished + poll.save(skip_autoupdate=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("motions", "0033_voting_1"), + ] + + operations = [ + migrations.RunPython(change_pollmethods), + migrations.RunPython(set_poll_titles), + migrations.RunPython(set_onehunderd_percent_bases), + migrations.RunPython(set_majority_methods), + migrations.RunPython(convert_votes), + migrations.RunPython(set_correct_state), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index 7b619c7ec..9a78f1b38 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -8,14 +8,10 @@ from openslides.agenda.mixins import AgendaItemWithListOfSpeakersMixin from openslides.core.config import config from openslides.core.models import Tag from openslides.mediafiles.models import Mediafile -from openslides.poll.models import ( - BaseOption, - BasePoll, - BaseVote, - CollectDefaultVotesMixin, -) +from openslides.poll.models import BaseOption, BasePoll, BaseVote from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError +from openslides.utils.manager import BaseManager from openslides.utils.models import RESTModelMixin from openslides.utils.rest_api import ValidationError @@ -26,6 +22,9 @@ from .access_permissions import ( MotionBlockAccessPermissions, MotionChangeRecommendationAccessPermissions, MotionCommentSectionAccessPermissions, + MotionOptionAccessPermissions, + MotionPollAccessPermissions, + MotionVoteAccessPermissions, StateAccessPermissions, StatuteParagraphAccessPermissions, WorkflowAccessPermissions, @@ -59,18 +58,19 @@ class StatuteParagraph(RESTModelMixin, models.Model): return self.title -class MotionManager(models.Manager): +class MotionManager(BaseManager): """ - Customized model manager to support our get_full_queryset method. + Customized model manager to support our get_prefetched_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all motions. In the background we join and prefetch all related models. """ return ( - self.get_queryset() + super() + .get_prefetched_queryset(*args, **kwargs) .select_related("state") .prefetch_related( "state__workflow", @@ -79,7 +79,6 @@ class MotionManager(models.Manager): "comments__section__read_groups", "agenda_items", "lists_of_speakers", - "polls", "attachments", "tags", "submitters", @@ -269,6 +268,7 @@ class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model): ("can_support", "Can support motions"), ("can_manage_metadata", "Can manage motion metadata"), ("can_manage", "Can manage motions"), + ("can_manage_polls", "Can manage motion polls"), ) ordering = ("identifier",) verbose_name = "Motion" @@ -424,22 +424,6 @@ class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model): """ return user in self.supporters.all() - def create_poll(self, skip_autoupdate=False): - """ - Create a new poll for this motion. - - Return the new poll object. - """ - if self.state.allow_create_poll: - poll = MotionPoll(motion=self) - poll.save(skip_autoupdate=skip_autoupdate) - poll.set_options(skip_autoupdate=skip_autoupdate) - return poll - else: - raise WorkflowError( - f"You can not create a poll in state {self.state.name}." - ) - @property def workflow_id(self): """ @@ -682,19 +666,6 @@ class Submitter(RESTModelMixin, models.Model): return self.motion -class MotionChangeRecommendationManager(models.Manager): - """ - Customized model manager to support our get_full_queryset method. - """ - - def get_full_queryset(self): - """ - Returns the normal queryset with all change recommendations. In the background we - join and prefetch all related models. - """ - return self.get_queryset() - - class MotionChangeRecommendation(RESTModelMixin, models.Model): """ A MotionChangeRecommendation object saves change recommendations for a specific Motion @@ -702,8 +673,6 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model): access_permissions = MotionChangeRecommendationAccessPermissions() - objects = MotionChangeRecommendationManager() - motion = models.ForeignKey( Motion, on_delete=CASCADE_AND_AUTOUPDATE, related_name="change_recommendations" ) @@ -839,17 +808,21 @@ class Category(RESTModelMixin, models.Model): return self.parent.level + 1 -class MotionBlockManager(models.Manager): +class MotionBlockManager(BaseManager): """ - Customized model manager to support our get_full_queryset method. + Customized model manager to support our get_prefetched_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all motion blocks. In the background the related agenda item is prefetched from the database. """ - return self.get_queryset().prefetch_related("agenda_items", "lists_of_speakers") + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .prefetch_related("agenda_items", "lists_of_speakers") + ) class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model): @@ -880,83 +853,106 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode return {"title": self.title} +class MotionVoteManager(BaseManager): + """ + Customized model manager to support our get_prefetched_queryset method. + """ + + def get_prefetched_queryset(self, *args, **kwargs): + """ + Returns the normal queryset with all motion votes. In the background we + join and prefetch all related models. + """ + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .select_related("user", "option", "option__poll") + ) + + class MotionVote(RESTModelMixin, BaseVote): - """Saves the votes for a MotionPoll. + access_permissions = MotionVoteAccessPermissions() + option = models.ForeignKey( + "MotionOption", on_delete=CASCADE_AND_AUTOUPDATE, related_name="votes" + ) - There should allways be three MotionVote objects for each poll, - one for 'yes', 'no', and 'abstain'.""" - - option = models.ForeignKey("MotionOption", on_delete=models.CASCADE) - """The option object, to witch the vote belongs.""" + objects = MotionVoteManager() class Meta: default_permissions = () - def get_root_rest_element(self): + +class MotionOptionManager(BaseManager): + """ + Customized model manager to support our get_prefetched_queryset method. + """ + + def get_prefetched_queryset(self, *args, **kwargs): """ - Returns the motion to this instance which is the root REST element. + Returns the normal queryset. In the background we + join and prefetch all related models. """ - return self.option.poll.motion + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .select_related("poll") + .prefetch_related("votes") + ) class MotionOption(RESTModelMixin, BaseOption): - """Links between the MotionPollClass and the MotionVoteClass. - - There should be one MotionOption object for each poll.""" - - poll = models.ForeignKey("MotionPoll", on_delete=models.CASCADE) - """The poll object, to witch the object belongs.""" - + access_permissions = MotionOptionAccessPermissions() + can_see_permission = "motions.can_see" + objects = MotionOptionManager() vote_class = MotionVote - """The VoteClass, to witch this Class links.""" + + poll = models.ForeignKey( + "MotionPoll", related_name="options", on_delete=CASCADE_AND_AUTOUPDATE + ) class Meta: default_permissions = () - def get_root_rest_element(self): + +class MotionPollManager(BaseManager): + """ + Customized model manager to support our get_prefetched_queryset method. + """ + + def get_prefetched_queryset(self, *args, **kwargs): """ - Returns the motion to this instance which is the root REST element. + Returns the normal queryset with all motion polls. In the background we + join and prefetch all related models. """ - return self.poll.motion + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .select_related("motion") + .prefetch_related("options", "options__votes", "voted", "groups") + ) -# TODO: remove the type-ignoring in the next line, after this is solved: -# https://github.com/python/mypy/issues/3855 -class MotionPoll(RESTModelMixin, CollectDefaultVotesMixin, BasePoll): # type: ignore - """The Class to saves the vote result for a motion poll.""" - - motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls") - """The motion to witch the object belongs.""" - +class MotionPoll(RESTModelMixin, BasePoll): + access_permissions = MotionPollAccessPermissions() + can_see_permission = "motions.can_see" option_class = MotionOption - """The option class, witch links between this object the the votes.""" - vote_values = ["Yes", "No", "Abstain"] - """The possible anwers for the poll. 'Yes, 'No' and 'Abstain'.""" + objects = MotionPollManager() + + motion = models.ForeignKey( + Motion, on_delete=CASCADE_AND_AUTOUPDATE, related_name="polls" + ) + + POLLMETHOD_YN = "YN" + POLLMETHOD_YNA = "YNA" + POLLMETHODS = (("YN", "YN"), ("YNA", "YNA")) + pollmethod = models.CharField(max_length=3, choices=POLLMETHODS) class Meta: default_permissions = () - def __str__(self): - """ - Representation method only for debugging purposes. - """ - return f"MotionPoll for motion {self.motion}" - - def set_options(self, skip_autoupdate=False): - """Create the option class for this poll.""" - # TODO: maybe it is possible with .create() to call this without poll=self - # or call this in save() - self.get_option_class()(poll=self).save(skip_autoupdate=skip_autoupdate) - - def get_percent_base_choice(self): - return config["motions_poll_100_percent_base"] - - def get_root_rest_element(self): - """ - Returns the motion to this instance which is the root REST element. - """ - return self.motion + def create_options(self): + MotionOption.objects.create(poll=self) class State(RESTModelMixin, models.Model): @@ -1100,21 +1096,18 @@ class State(RESTModelMixin, models.Model): return state_id in next_state_ids or state_id in previous_state_ids -class WorkflowManager(models.Manager): +class WorkflowManager(BaseManager): """ - Customized model manager to support our get_full_queryset method. + Customized model manager to support our get_prefetched_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all workflows. In the background - the first state is joined and all states and next states are - prefetched from the database. + all states are prefetched from the database. """ return ( - self.get_queryset() - .select_related("first_state") - .prefetch_related("states", "states__next_states") + super().get_prefetched_queryset(*args, **kwargs).prefetch_related("states") ) diff --git a/openslides/motions/projector.py b/openslides/motions/projector.py index 5c33eb077..74a9a399f 100644 --- a/openslides/motions/projector.py +++ b/openslides/motions/projector.py @@ -6,8 +6,10 @@ from ..utils.projector import ( AllData, ProjectorElementException, get_config, + get_model, register_projector_slide, ) +from .models import MotionPoll motion_placeholder_regex = re.compile(r"\[motion:(\d+)\]") @@ -91,11 +93,7 @@ async def get_amendments_for_motion(motion, all_data): async def get_amendment_base_motion(amendment, all_data): - try: - motion = all_data["motions/motion"][amendment["parent_id"]] - except KeyError: - motion_id = amendment["parent_id"] - raise ProjectorElementException(f"motion with id {motion_id} does not exist") + motion = get_model(all_data, "motions/motion", amendment.get("parent_id")) return { "identifier": motion["identifier"], @@ -105,14 +103,9 @@ async def get_amendment_base_motion(amendment, all_data): async def get_amendment_base_statute(amendment, all_data): - try: - statute = all_data["motions/statute-paragraph"][ - amendment["statute_paragraph_id"] - ] - except KeyError: - statute_id = amendment["statute_paragraph_id"] - raise ProjectorElementException(f"statute with id {statute_id} does not exist") - + statute = get_model( + all_data, "motions/statute-paragraph", amendment.get("statute_paragraph_id") + ) return {"title": statute["title"], "text": statute["text"]} @@ -167,15 +160,7 @@ async def motion_slide( mode = element.get( "mode", await get_config(all_data, "motions_recommendation_text_mode") ) - motion_id = element.get("id") - - if motion_id is None: - raise ProjectorElementException("id is required for motion slide") - - try: - motion = all_data["motions/motion"][motion_id] - except KeyError: - raise ProjectorElementException(f"motion with id {motion_id} does not exist") + motion = get_model(all_data, "motions/motion", element.get("id")) # Add submitters submitters = [ @@ -270,7 +255,7 @@ async def motion_slide( # Add recommendation-referencing motions return_value[ "recommendation_referencing_motions" - ] = await get_recommendation_referencing_motions(all_data, motion_id) + ] = await get_recommendation_referencing_motions(all_data, motion["id"]) return return_value @@ -317,17 +302,7 @@ async def motion_block_slide( """ Motion block slide. """ - motion_block_id = element.get("id") - - if motion_block_id is None: - raise ProjectorElementException("id is required for motion block slide") - - try: - motion_block = all_data["motions/motion-block"][motion_block_id] - except KeyError: - raise ProjectorElementException( - f"motion block with id {motion_block_id} does not exist" - ) + motion_block = get_model(all_data, "motions/motion-block", element.get("id")) # All motions in this motion block motions = [] @@ -337,7 +312,7 @@ async def motion_block_slide( # Search motions. for motion in all_data["motions/motion"].values(): - if motion["motion_block_id"] == motion_block_id: + if motion["motion_block_id"] == motion_block["id"]: motion_object = { "title": motion["title"], "identifier": motion["identifier"], @@ -366,6 +341,49 @@ async def motion_block_slide( } +async def motion_poll_slide( + all_data: AllData, element: Dict[str, Any], projector_id: int +) -> Dict[str, Any]: + """ + Poll slide. + """ + poll = get_model(all_data, "motions/motion-poll", element.get("id")) + motion = get_model(all_data, "motions/motion", poll["motion_id"]) + + poll_data = { + key: poll[key] + for key in ( + "title", + "type", + "pollmethod", + "state", + "onehundred_percent_base", + "majority_method", + ) + } + + if poll["state"] == MotionPoll.STATE_PUBLISHED: + option = get_model( + all_data, "motions/motion-option", poll["options_id"][0] + ) # there can only be exactly one option + poll_data["options"] = [ + { + "yes": float(option["yes"]), + "no": float(option["no"]), + "abstain": float(option["abstain"]), + } + ] + poll_data["votesvalid"] = poll["votesvalid"] + poll_data["votesinvalid"] = poll["votesinvalid"] + poll_data["votescast"] = poll["votescast"] + + return { + "motion": {"title": motion["title"], "identifier": motion["identifier"]}, + "poll": poll_data, + } + + def register_projector_slides() -> None: register_projector_slide("motions/motion", motion_slide) register_projector_slide("motions/motion-block", motion_block_slide) + register_projector_slide("motions/motion-poll", motion_poll_slide) diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index c3397cf22..8a4f7d40f 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -1,17 +1,21 @@ -from typing import Dict, Optional - import jsonschema from django.db import transaction +from openslides.poll.serializers import ( + BASE_OPTION_FIELDS, + BASE_POLL_FIELDS, + BASE_VOTE_FIELDS, + BaseOptionSerializer, + BasePollSerializer, + BaseVoteSerializer, +) + from ..core.config import config -from ..poll.serializers import default_votes_validator from ..utils.auth import get_group_model, has_perm from ..utils.autoupdate import inform_changed_data from ..utils.rest_api import ( BooleanField, CharField, - DecimalField, - DictField, Field, IdPrimaryKeyRelatedField, IntegerField, @@ -28,7 +32,9 @@ from .models import ( MotionChangeRecommendation, MotionComment, MotionCommentSection, + MotionOption, MotionPoll, + MotionVote, State, StatuteParagraph, Submitter, @@ -220,116 +226,44 @@ class AmendmentParagraphsJSONSerializerField(Field): return data -class MotionPollSerializer(ModelSerializer): +class MotionVoteSerializer(BaseVoteSerializer): + class Meta: + model = MotionVote + fields = BASE_VOTE_FIELDS + read_only_fields = BASE_VOTE_FIELDS + + +class MotionOptionSerializer(BaseOptionSerializer): + class Meta: + model = MotionOption + fields = BASE_OPTION_FIELDS + read_only_fields = BASE_OPTION_FIELDS + + +class MotionPollSerializer(BasePollSerializer): """ Serializer for motion.models.MotionPoll objects. """ - yes = SerializerMethodField() - no = SerializerMethodField() - abstain = SerializerMethodField() - votes = DictField( - child=DecimalField( - max_digits=15, decimal_places=6, min_value=-2, allow_null=True - ), - write_only=True, - ) - has_votes = SerializerMethodField() - class Meta: model = MotionPoll - fields = ( - "id", - "motion", - "yes", - "no", - "abstain", - "votesvalid", - "votesinvalid", - "votescast", - "votes", - "has_votes", - ) - validators = (default_votes_validator,) + fields = ("motion", "pollmethod") + BASE_POLL_FIELDS + read_only_fields = ("state",) - def __init__(self, *args, **kwargs): - # The following dictionary is just a cache for several votes. - self._votes_dicts: Dict[int, Dict[int, int]] = {} - super().__init__(*args, **kwargs) - - def get_yes(self, obj): - try: - result: Optional[str] = str(self.get_votes_dict(obj)["Yes"]) - except KeyError: - result = None - return result - - def get_no(self, obj): - try: - result: Optional[str] = str(self.get_votes_dict(obj)["No"]) - except KeyError: - result = None - return result - - def get_abstain(self, obj): - try: - result: Optional[str] = str(self.get_votes_dict(obj)["Abstain"]) - except KeyError: - result = None - return result - - def get_votes_dict(self, obj): - try: - votes_dict = self._votes_dicts[obj.pk] - except KeyError: - votes_dict = self._votes_dicts[obj.pk] = {} - for vote in obj.get_votes(): - votes_dict[vote.value] = vote.weight - return votes_dict - - def get_has_votes(self, obj): - """ - Returns True if this poll has some votes. - """ - return obj.has_votes() - - @transaction.atomic def update(self, instance, validated_data): - """ - Customized update method for polls. To update votes use the write - only field 'votes'. + """ Prevent updating the motion """ + validated_data.pop("motion", None) + return super().update(instance, validated_data) - Example data: - - "votes": {"Yes": 10, "No": 4, "Abstain": -2} - """ - # Update votes. - votes = validated_data.get("votes") - if votes: - if len(votes) != len(instance.get_vote_values()): - raise ValidationError( - { - "detail": "You have to submit data for {0} vote values.", - "args": [len(instance.get_vote_values())], - } - ) - for vote_value in votes.keys(): - if vote_value not in instance.get_vote_values(): - raise ValidationError( - {"detail": "Vote value {0} is invalid.", "args": [vote_value]} - ) - instance.set_vote_objects_with_values( - instance.get_options().get(), votes, skip_autoupdate=True - ) - - # Update remaining writeable fields. - instance.votesvalid = validated_data.get("votesvalid", instance.votesvalid) - instance.votesinvalid = validated_data.get( - "votesinvalid", instance.votesinvalid - ) - instance.votescast = validated_data.get("votescast", instance.votescast) - instance.save() - return instance + def norm_100_percent_base_to_pollmethod( + self, onehundred_percent_base, pollmethod, old_100_percent_base=None + ): + if ( + pollmethod == MotionPoll.POLLMETHOD_YN + and onehundred_percent_base == MotionPoll.PERCENT_BASE_YNA + ): + return MotionPoll.PERCENT_BASE_YN + return None class MotionChangeRecommendationSerializer(ModelSerializer): @@ -418,7 +352,6 @@ class MotionSerializer(ModelSerializer): """ comments = MotionCommentSerializer(many=True, read_only=True) - polls = MotionPollSerializer(many=True, read_only=True) modified_final_version = CharField(allow_blank=True, required=False) reason = CharField(allow_blank=True, required=False) state_restriction = SerializerMethodField() @@ -466,7 +399,6 @@ class MotionSerializer(ModelSerializer): "recommendation_extension", "tags", "attachments", - "polls", "agenda_item_id", "list_of_speakers_id", "agenda_create", diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 98140c2f6..cb3fb79bb 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -1,3 +1,4 @@ +from decimal import Decimal from typing import List, Set import jsonschema @@ -8,17 +9,16 @@ from django.db.models.deletion import ProtectedError from django.http.request import QueryDict from rest_framework import status +from openslides.poll.views import BaseOptionViewSet, BasePollViewSet, BaseVoteViewSet + from ..core.config import config from ..core.models import Tag from ..utils.auth import has_perm, in_some_groups from ..utils.autoupdate import inform_changed_data, inform_deleted_data from ..utils.rest_api import ( - DestroyModelMixin, - GenericViewSet, ModelViewSet, Response, ReturnDict, - UpdateModelMixin, ValidationError, detail_route, list_route, @@ -34,7 +34,6 @@ from .access_permissions import ( StatuteParagraphAccessPermissions, WorkflowAccessPermissions, ) -from .exceptions import WorkflowError from .models import ( Category, Motion, @@ -42,14 +41,15 @@ from .models import ( MotionChangeRecommendation, MotionComment, MotionCommentSection, + MotionOption, MotionPoll, + MotionVote, State, StatuteParagraph, Submitter, Workflow, ) from .numbering import numbering -from .serializers import MotionPollSerializer # Viewsets for the REST API @@ -87,7 +87,6 @@ class MotionViewSet(TreeSortMixin, ModelViewSet): "follow_recommendation", "manage_multiple_submitters", "manage_multiple_tags", - "create_poll", ): result = has_perm(self.request.user, "motions.can_see") and has_perm( self.request.user, "motions.can_manage_metadata" @@ -194,9 +193,7 @@ class MotionViewSet(TreeSortMixin, ModelViewSet): if isinstance(request.data, QueryDict): submitters_id = request.data.getlist("submitters_id") else: - submitters_id = request.data.get("submitters_id") - if submitters_id is None: - submitters_id = [] + submitters_id = request.data.get("submitters_id", []) if not isinstance(submitters_id, list): raise ValidationError( {"detail": "If submitters_id is given, it has to be a list."} @@ -400,9 +397,7 @@ class MotionViewSet(TreeSortMixin, ModelViewSet): message = ["Comment {arg1} deleted", section.name] # Fire autoupdate again to save information to OpenSlides history. - inform_changed_data( - motion, information=message, user_id=request.user.pk, restricted=True - ) + inform_changed_data(motion, information=message, user_id=request.user.pk) return Response({"detail": message}) @@ -1042,31 +1037,6 @@ class MotionViewSet(TreeSortMixin, ModelViewSet): return Response({"detail": "Recommendation followed successfully."}) - @detail_route(methods=["post"]) - def create_poll(self, request, pk=None): - """ - View to create a poll. It is a POST request without any data. - """ - motion = self.get_object() - if not motion.state.allow_create_poll: - raise ValidationError( - {"detail": "You can not create a poll in this motion state."} - ) - try: - with transaction.atomic(): - poll = motion.create_poll(skip_autoupdate=True) - except WorkflowError as err: - raise ValidationError({"detail": err}) - - # Fire autoupdate again to save information to OpenSlides history. - inform_changed_data( - motion, information=["Vote created"], user_id=request.user.pk - ) - - return Response( - {"detail": "Vote created successfully.", "createdPollId": poll.pk} - ) - @list_route(methods=["post"]) @transaction.atomic def manage_multiple_tags(self, request): @@ -1137,7 +1107,7 @@ class MotionViewSet(TreeSortMixin, ModelViewSet): ) -class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class MotionPollViewSet(BasePollViewSet): """ API endpoint for motion polls. @@ -1145,14 +1115,38 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): """ queryset = MotionPoll.objects.all() - serializer_class = MotionPollSerializer - def check_view_permissions(self): + required_analog_fields = ["Y", "N", "votescast", "votesvalid", "votesinvalid"] + + def has_manage_permissions(self): """ Returns True if the user has required permissions. """ return has_perm(self.request.user, "motions.can_see") and has_perm( - self.request.user, "motions.can_manage_metadata" + self.request.user, "motions.can_manage" + ) + + def create(self, request, *args, **kwargs): + # set default pollmethod to YNA + if "pollmethod" not in request.data: + # hack to make request.data mutable. Otherwise fields cannot be changed. + if isinstance(request.data, QueryDict): + request.data._mutable = True + request.data["pollmethod"] = MotionPoll.POLLMETHOD_YNA + return super().create(request, *args, **kwargs) + + def perform_create(self, serializer): + motion = serializer.validated_data["motion"] + if not motion.state.allow_create_poll: + raise ValidationError( + {"detail": "You can not create a poll in this motion state."} + ) + + super().perform_create(serializer) + + # Fire autoupdate again to save information to OpenSlides history. + inform_changed_data( + motion, information=["Poll created"], user_id=self.request.user.pk ) def update(self, *args, **kwargs): @@ -1160,11 +1154,11 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): Customized view endpoint to update a motion poll. """ response = super().update(*args, **kwargs) - poll = self.get_object() # Fire autoupdate again to save information to OpenSlides history. + poll = self.get_object() inform_changed_data( - poll.motion, information=["Vote updated"], user_id=self.request.user.pk + poll.motion, information=["Poll updated"], user_id=self.request.user.pk ) return response @@ -1178,11 +1172,94 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): # Fire autoupdate again to save information to OpenSlides history. inform_changed_data( - poll.motion, information=["Vote deleted"], user_id=self.request.user.pk + poll.motion, information=["Poll deleted"], user_id=self.request.user.pk ) return result + def handle_analog_vote(self, data, poll, user): + option = poll.options.get() + vote, _ = MotionVote.objects.get_or_create(option=option, value="Y") + vote.weight = data["Y"] + vote.save() + vote, _ = MotionVote.objects.get_or_create(option=option, value="N") + vote.weight = data["N"] + vote.save() + if poll.pollmethod == MotionPoll.POLLMETHOD_YNA: + vote, _ = MotionVote.objects.get_or_create(option=option, value="A") + vote.weight = data["A"] + vote.save() + inform_changed_data(option) + + for field in ["votesvalid", "votesinvalid", "votescast"]: + setattr(poll, field, data.get(field)) + + poll.save() + + def validate_vote_data(self, data, poll, user): + """ + Request data for analog: + { "Y": , "N": , ["A": ], + ["votesvalid": ], ["votesinvalid": ], ["votescast": ]} + All amounts are decimals as strings + Request data for named/pseudoanonymous is just "Y" | "N" [| "A"] + """ + if poll.type == MotionPoll.TYPE_ANALOG: + if not isinstance(data, dict): + raise ValidationError({"detail": "Data must be a dict"}) + + for field in ["Y", "N", "votesvalid", "votesinvalid", "votescast"]: + data[field] = self.parse_vote_value(data, field) + if poll.pollmethod == MotionPoll.POLLMETHOD_YNA: + data["A"] = self.parse_vote_value(data, "A") + + else: + if poll.pollmethod == MotionPoll.POLLMETHOD_YNA and data not in ( + "Y", + "N", + "A", + ): + raise ValidationError("Data must be Y, N or A") + elif poll.pollmethod == MotionPoll.POLLMETHOD_YN and data not in ("Y", "N"): + raise ValidationError("Data must be Y or N") + + if poll.type == MotionPoll.TYPE_PSEUDOANONYMOUS: + if user in poll.voted.all(): + raise ValidationError("You already voted on this poll") + + def handle_named_vote(self, data, poll, user): + option = poll.options.get() + vote, _ = MotionVote.objects.get_or_create(user=user, option=option) + self.handle_named_and_pseudoanonymous_vote(data, user, poll, option, vote) + + def handle_pseudoanonymous_vote(self, data, poll, user): + option = poll.options.get() + vote = MotionVote.objects.create(user=None, option=option) + self.handle_named_and_pseudoanonymous_vote(data, user, poll, option, vote) + + def handle_named_and_pseudoanonymous_vote(self, data, user, poll, option, vote): + vote.value = data + vote.weight = Decimal("1") + vote.save(no_delete_on_restriction=True) + inform_changed_data(option) + + poll.voted.add(user) + poll.save() + + +class MotionOptionViewSet(BaseOptionViewSet): + queryset = MotionOption.objects.all() + + def check_view_permissions(self): + return has_perm(self.request.user, "motions.can_see") + + +class MotionVoteViewSet(BaseVoteViewSet): + queryset = MotionVote.objects.all() + + def check_view_permissions(self): + return has_perm(self.request.user, "motions.can_see") + class MotionChangeRecommendationViewSet(ModelViewSet): """ @@ -1620,7 +1697,6 @@ class StateViewSet(ModelViewSet, ProtectedErrorMessageMixin): """ queryset = State.objects.all() - # serializer_class = StateSerializer access_permissions = StateAccessPermissions() def check_view_permissions(self): diff --git a/openslides/poll/access_permissions.py b/openslides/poll/access_permissions.py new file mode 100644 index 000000000..576ee0ace --- /dev/null +++ b/openslides/poll/access_permissions.py @@ -0,0 +1,93 @@ +import json +from typing import Any, Dict, List + +from ..poll.views import BasePoll +from ..utils.access_permissions import BaseAccessPermissions +from ..utils.auth import async_has_perm + + +class BaseVoteAccessPermissions(BaseAccessPermissions): + manage_permission = "" # set by subclass + + async def get_restricted_data( + self, full_data: List[Dict[str, Any]], user_id: int + ) -> List[Dict[str, Any]]: + """ + Poll-managers have full access, even during an active poll. + Every user can see it's own votes. + If the pollstate is published, everyone can see the votes. + """ + + if await async_has_perm(user_id, self.manage_permission): + data = full_data + else: + data = [ + vote + for vote in full_data + if vote["pollstate"] == BasePoll.STATE_PUBLISHED + or vote["user_id"] == user_id + ] + return data + + +class BaseOptionAccessPermissions(BaseAccessPermissions): + manage_permission = "" # set by subclass + + async def get_restricted_data( + self, full_data: List[Dict[str, Any]], user_id: int + ) -> List[Dict[str, Any]]: + + if await async_has_perm(user_id, self.manage_permission): + data = full_data + else: + data = [] + for option in full_data: + if option["pollstate"] != BasePoll.STATE_PUBLISHED: + option = json.loads( + json.dumps(option) + ) # copy, so we can remove some fields. + del option["yes"] + del option["no"] + del option["abstain"] + data.append(option) + return data + + +class BasePollAccessPermissions(BaseAccessPermissions): + manage_permission = "" # set by subclass + + additional_fields: List[str] = [] + """ Add fields to be removed from each unpublished poll """ + + async def get_restricted_data( + self, full_data: List[Dict[str, Any]], user_id: int + ) -> List[Dict[str, Any]]: + """ + Poll-managers have full access, even during an active poll. + Non-published polls will be restricted: + - Remove votes* values from the poll + - Remove yes/no/abstain fields from options + - Remove fields given in self.assitional_fields from the poll + """ + + # add has_voted for all users to check whether op has voted + for poll in full_data: + poll["user_has_voted"] = user_id in poll["voted_id"] + + if await async_has_perm(user_id, self.manage_permission): + data = full_data + else: + data = [] + for poll in full_data: + if poll["state"] != BasePoll.STATE_PUBLISHED: + poll = json.loads( + json.dumps(poll) + ) # copy, so we can remove some fields. + del poll["votesvalid"] + del poll["votesinvalid"] + del poll["votescast"] + del poll["voted_id"] + for field in self.additional_fields: + del poll[field] + data.append(poll) + return data diff --git a/openslides/poll/majority.py b/openslides/poll/majority.py deleted file mode 100644 index 7f498542b..000000000 --- a/openslides/poll/majority.py +++ /dev/null @@ -1,7 +0,0 @@ -# Common majority methods for all apps using polls. The first one should be the default. -majorityMethods = ( - {"value": "simple_majority", "display_name": "Simple majority"}, - {"value": "two-thirds_majority", "display_name": "Two-thirds majority"}, - {"value": "three-quarters_majority", "display_name": "Three-quarters majority"}, - {"value": "disabled", "display_name": "Disabled"}, -) diff --git a/openslides/poll/models.py b/openslides/poll/models.py index 146342814..305535b41 100644 --- a/openslides/poll/models.py +++ b/openslides/poll/models.py @@ -1,20 +1,41 @@ -import locale from decimal import Decimal -from typing import Optional, Type +from typing import Iterable, Optional, Tuple, Type -from django.core.exceptions import ObjectDoesNotExist +from django.conf import settings from django.core.validators import MinValueValidator from django.db import models +from ..utils.autoupdate import inform_changed_data, inform_deleted_data +from ..utils.models import SET_NULL_AND_AUTOUPDATE + + +class BaseVote(models.Model): + """ + All subclasses must have option attribute with the related name "votes" + """ + + weight = models.DecimalField( + default=Decimal("1"), + validators=[MinValueValidator(Decimal("-2"))], + max_digits=15, + decimal_places=6, + ) + value = models.CharField(max_length=1, choices=(("Y", "Y"), ("N", "N"), ("A", "A"))) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + default=None, + null=True, + blank=True, + on_delete=SET_NULL_AND_AUTOUPDATE, + ) + + class Meta: + abstract = True + class BaseOption(models.Model): """ - Base option class for a poll. - - Subclasses have to define a poll field. This must be a ForeignKeyField - to a subclass of BasePoll. There must also be a vote_class attribute - which has to be a subclass of BaseVote. Otherwise you have to override the - get_vote_class method. + All subclasses must have poll attribute with the related name "options" """ vote_class: Optional[Type["BaseVote"]] = None @@ -22,170 +43,206 @@ class BaseOption(models.Model): class Meta: abstract = True + @property + def yes(self) -> Decimal: + return self.sum_weight("Y") + + @property + def no(self) -> Decimal: + return self.sum_weight("N") + + @property + def abstain(self) -> Decimal: + return self.sum_weight("A") + + def sum_weight(self, value): + # We could do this in a nice .aggregate(Sum...) querystatement, + # but these might be expensive DB queries, because they are not preloaded. + # With this in-logic-counting, we operate inmemory. + weight_sum = Decimal(0) + for vote in self.votes.all(): + if vote.value == value: + weight_sum += vote.weight + return weight_sum + + @classmethod + def get_vote_class(cls): + if cls.vote_class is None: + raise NotImplementedError( + f"The option class {cls} has to have an attribute vote_class." + ) + return cls.vote_class + def get_votes(self): + """ + Return a QuerySet with all vote objects related to this option. + """ return self.get_vote_class().objects.filter(option=self) - def get_vote_class(self): - if self.vote_class is None: - raise NotImplementedError( - f"The option class {self} has to have an attribute vote_class." - ) - return self.vote_class + def pseudoanonymize(self): + for vote in self.get_votes(): + vote.user = None + vote.save() - def __getitem__(self, name): - try: - return self.get_votes().get(value=name) - except self.get_vote_class().DoesNotExist: - raise KeyError + def reset(self): + # Delete votes + votes = self.get_votes() + votes_id = [vote.id for vote in votes] + votes.delete() + collection = self.get_vote_class().get_collection_string() + inform_deleted_data((collection, id) for id in votes_id) - -class BaseVote(models.Model): - """ - Base vote class for an option. - - Subclasses have to define an option field. This must be a ForeignKeyField - to a subclass of BasePoll. - """ - - weight = models.DecimalField( - default=Decimal("1"), - null=True, - validators=[MinValueValidator(Decimal("-2"))], - max_digits=15, - decimal_places=6, - ) - value = models.CharField(max_length=255, null=True) - - class Meta: - abstract = True - - def __str__(self): - return self.print_weight() - - def get_value(self): - return self.value - - def print_weight(self, raw=False): - if raw: - return self.weight - try: - percent_base = self.option.poll.get_percent_base() - except AttributeError: - # The poll class is no child of CollectVotesCast - percent_base = 0 - return print_value(self.weight, percent_base) - - -class CollectDefaultVotesMixin(models.Model): - """ - Mixin for a poll to collect the default vote values for valid votes, - invalid votes and votes cast. - """ - - votesvalid = models.DecimalField( - null=True, - blank=True, - validators=[MinValueValidator(Decimal("-2"))], - max_digits=15, - decimal_places=6, - ) - votesinvalid = models.DecimalField( - null=True, - blank=True, - validators=[MinValueValidator(Decimal("-2"))], - max_digits=15, - decimal_places=6, - ) - votescast = models.DecimalField( - null=True, - blank=True, - validators=[MinValueValidator(Decimal("-2"))], - max_digits=15, - decimal_places=6, - ) - - class Meta: - abstract = True - - def get_percent_base_choice(self): - """ - Returns one of the strings of the percent base. - """ - raise NotImplementedError( - "You have to provide a get_percent_base_choice() method." - ) - - -class PublishPollMixin(models.Model): - """ - Mixin for a poll to add a flag whether the poll is published or not. - """ - - published = models.BooleanField(default=False) - - class Meta: - abstract = True - - def set_published(self, published): - self.published = published - self.save() + # update self because the changed voted relation + inform_changed_data(self) class BasePoll(models.Model): - """ - Base poll class. - """ + option_class: Optional[Type["BaseOption"]] = None - vote_values = ["Votes"] + STATE_CREATED = 1 + STATE_STARTED = 2 + STATE_FINISHED = 3 + STATE_PUBLISHED = 4 + STATES = ( + (STATE_CREATED, "Created"), + (STATE_STARTED, "Started"), + (STATE_FINISHED, "Finished"), + (STATE_PUBLISHED, "Published"), + ) + state = models.IntegerField(choices=STATES, default=STATE_CREATED) + + TYPE_ANALOG = "analog" + TYPE_NAMED = "named" + TYPE_PSEUDOANONYMOUS = "pseudoanonymous" + TYPES = ( + (TYPE_ANALOG, "Analog"), + (TYPE_NAMED, "Named"), + (TYPE_PSEUDOANONYMOUS, "Pseudoanonymous"), + ) + type = models.CharField(max_length=64, blank=False, null=False, choices=TYPES) + + title = models.CharField(max_length=255, blank=True, null=False) + groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True) + voted = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True) + + db_votesvalid = models.DecimalField( + null=True, + blank=True, + validators=[MinValueValidator(Decimal("-2"))], + max_digits=15, + decimal_places=6, + ) + db_votesinvalid = models.DecimalField( + null=True, + blank=True, + validators=[MinValueValidator(Decimal("-2"))], + max_digits=15, + decimal_places=6, + ) + db_votescast = models.DecimalField( + null=True, + blank=True, + validators=[MinValueValidator(Decimal("-2"))], + max_digits=15, + decimal_places=6, + ) + + PERCENT_BASE_YN = "YN" + PERCENT_BASE_YNA = "YNA" + PERCENT_BASE_VALID = "valid" + PERCENT_BASE_CAST = "cast" + PERCENT_BASE_DISABLED = "disabled" + PERCENT_BASES: Iterable[Tuple[str, str]] = ( + (PERCENT_BASE_YN, "Yes/No"), + (PERCENT_BASE_YNA, "Yes/No/Abstain"), + (PERCENT_BASE_VALID, "All valid ballots"), + (PERCENT_BASE_CAST, "All casted ballots"), + (PERCENT_BASE_DISABLED, "Disabled (no percents)"), + ) # type: ignore + onehundred_percent_base = models.CharField( + max_length=8, blank=False, null=False, choices=PERCENT_BASES + ) + + MAJORITY_SIMPLE = "simple" + MAJORITY_TWO_THIRDS = "two_thirds" + MAJORITY_THREE_QUARTERS = "three_quarters" + MAJORITY_DISABLED = "disabled" + MAJORITY_METHODS = ( + (MAJORITY_SIMPLE, "Simple majority"), + (MAJORITY_TWO_THIRDS, "Two-thirds majority"), + (MAJORITY_THREE_QUARTERS, "Three-quarters majority"), + (MAJORITY_DISABLED, "Disabled"), + ) + majority_method = models.CharField( + max_length=14, blank=False, null=False, choices=MAJORITY_METHODS + ) class Meta: abstract = True - def has_votes(self): - """ - Returns True if there are votes in the poll. - """ - if self.get_votes().exists(): - return True - return False + def get_votesvalid(self): + if self.type == self.TYPE_ANALOG: + return self.db_votesvalid + else: + return Decimal(self.amount_users_voted()) - def set_options(self, options_data=None, skip_autoupdate=False): - """ - Adds new option objects to the poll. + def set_votesvalid(self, value): + if self.type != self.TYPE_ANALOG: + raise ValueError("Do not set votesvalid for non analog polls") + self.db_votesvalid = value - option_data: A list of arguments for the option. - """ - if options_data is None: - options_data = [] + votesvalid = property(get_votesvalid, set_votesvalid) - for option_data in options_data: - option = self.get_option_class()(**option_data) - option.poll = self - option.save(skip_autoupdate=skip_autoupdate) + def get_votesinvalid(self): + if self.type == self.TYPE_ANALOG: + return self.db_votesinvalid + else: + return Decimal(0) + + def set_votesinvalid(self, value): + if self.type != self.TYPE_ANALOG: + raise ValueError("Do not set votesinvalid for non analog polls") + self.db_votesinvalid = value + + votesinvalid = property(get_votesinvalid, set_votesinvalid) + + def get_votescast(self): + if self.type == self.TYPE_ANALOG: + return self.db_votescast + else: + return Decimal(self.amount_users_voted()) + + def set_votescast(self, value): + if self.type != self.TYPE_ANALOG: + raise ValueError("Do not set votescast for non analog polls") + self.db_votescast = value + + votescast = property(get_votescast, set_votescast) + + def amount_users_voted(self): + return len(self.voted.all()) + + def create_options(self): + """ Should be called after creation of this model. """ + raise NotImplementedError() + + @classmethod + def get_option_class(cls): + if cls.option_class is None: + raise NotImplementedError( + f"The poll class {cls} has to have an attribute option_class." + ) + return cls.option_class def get_options(self): """ Returns the option objects for the poll. """ - return self.get_option_class().objects.filter(poll=self) + return self.options.all() - def get_option_class(self): - """ - Returns the option class for the poll. Default is self.option_class. - """ - return self.option_class - - def get_vote_values(self): - """ - Returns the possible values for the poll. Default is as list. - """ - return self.vote_values - - def get_vote_class(self): - """ - Returns the related vote class. - """ - return self.get_option_class().vote_class + @classmethod + def get_vote_class(cls): + return cls.get_option_class().get_vote_class() def get_votes(self): """ @@ -193,51 +250,20 @@ class BasePoll(models.Model): """ return self.get_vote_class().objects.filter(option__poll__id=self.id) - def set_vote_objects_with_values(self, option, data, skip_autoupdate=False): - """ - Creates or updates the vote objects for the poll. - """ - for value in self.get_vote_values(): - try: - vote = self.get_votes().filter(option=option).get(value=value) - except ObjectDoesNotExist: - vote = self.get_vote_class()(option=option, value=value) - vote.weight = data[value] - vote.save(skip_autoupdate=skip_autoupdate) + def pseudoanonymize(self): + for option in self.get_options(): + option.pseudoanonymize() - def get_vote_objects_with_values(self, option_id): - """ - Returns the vote values and their weight as a list with two elements. - """ - values = [] - for value in self.get_vote_values(): - try: - vote = self.get_votes().filter(option=option_id).get(value=value) - except ObjectDoesNotExist: - values.append(self.get_vote_class()(value=value, weight="")) - else: - values.append(vote) - return values + def reset(self): + for option in self.get_options(): + option.reset() + self.voted.clear() -def print_value(value, percent_base=0): - """ - Returns a human readable string for the vote value. It is 'majority', - 'undocumented' or the vote value with percent value if so. - """ - if value == -1: - verbose_value = "majority" - elif value == -2: - verbose_value = "undocumented" - elif value is None: - verbose_value = "undocumented" - else: - if percent_base: - locale.setlocale(locale.LC_ALL, "") - verbose_value = "%d (%s %%)" % ( - value, - locale.format("%.1f", value * percent_base), - ) - else: - verbose_value = value - return verbose_value + # Reset state + self.state = BasePoll.STATE_CREATED + if self.type == self.TYPE_ANALOG: + self.votesvalid = None + self.votesinvalid = None + self.votescast = None + self.save() diff --git a/openslides/poll/serializers.py b/openslides/poll/serializers.py index 72b7f132b..a2b5d8311 100644 --- a/openslides/poll/serializers.py +++ b/openslides/poll/serializers.py @@ -1,19 +1,129 @@ -from ..utils.rest_api import ValidationError +from django.conf import settings + +from ..utils.auth import get_group_model +from ..utils.rest_api import ( + CharField, + DecimalField, + IdPrimaryKeyRelatedField, + ModelSerializer, + SerializerMethodField, + ValidationError, +) +from .models import BasePoll -def default_votes_validator(data): - """ - Use this validator in your poll serializer. It checks that the values - for the default votes (see models.CollectDefaultVotesMixin) are greater - than or equal to -2. - """ - for key in data: +BASE_VOTE_FIELDS = ("id", "weight", "value", "user", "option", "pollstate") + + +class BaseVoteSerializer(ModelSerializer): + pollstate = SerializerMethodField() + + def get_pollstate(self, vote): + return vote.option.poll.state + + +BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain", "poll_id", "pollstate") + + +class BaseOptionSerializer(ModelSerializer): + yes = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True) + no = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True) + abstain = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True + ) + + pollstate = SerializerMethodField() + + def get_pollstate(self, option): + return option.poll.state + + +BASE_POLL_FIELDS = ( + "state", + "type", + "title", + "groups", + "votesvalid", + "votesinvalid", + "votescast", + "options", + "id", + "onehundred_percent_base", + "majority_method", + "voted", +) + + +class BasePollSerializer(ModelSerializer): + title = CharField(allow_blank=False, required=True) + groups = IdPrimaryKeyRelatedField( + many=True, required=False, queryset=get_group_model().objects.all() + ) + options = IdPrimaryKeyRelatedField(many=True, read_only=True) + voted = IdPrimaryKeyRelatedField(many=True, read_only=True) + + votesvalid = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True + ) + votesinvalid = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True + ) + votescast = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True + ) + + def create(self, validated_data): + """ + Match the 100 percent base to the pollmethod. Change the base, if it does not + fit to the pollmethod + """ + new_100_percent_base = self.norm_100_percent_base_to_pollmethod( + validated_data["onehundred_percent_base"], validated_data["pollmethod"] + ) + if new_100_percent_base is not None: + validated_data["onehundred_percent_base"] = new_100_percent_base + return super().create(validated_data) + + def update(self, instance, validated_data): + """ + Adjusts the 100%-base to the pollmethod. This might be needed, + if at least one of them was changed. Wrong combinations should be + also handled by the client, but here we make it sure aswell! + + E.g. the pollmethod is YN, but the 100%-base is YNA, this might not be + possible (see implementing serializers to see forbidden combinations) + """ + old_100_percent_base = instance.onehundred_percent_base + instance = super().update(instance, validated_data) + + new_100_percent_base = self.norm_100_percent_base_to_pollmethod( + instance.onehundred_percent_base, instance.pollmethod, old_100_percent_base + ) + if new_100_percent_base is not None: + instance.onehundred_percent_base = new_100_percent_base + instance.save() + + return instance + + def validate(self, data): + """ + Check that the given polltype is allowed. + """ + # has to be called in function instead of globally to enable tests to change the setting + ENABLE_ELECTRONIC_VOTING = getattr(settings, "ENABLE_ELECTRONIC_VOTING", False) if ( - key in ("votesvalid", "votesinvalid", "votescast") - and data[key] is not None - and data[key] < -2 + "type" in data + and data["type"] != BasePoll.TYPE_ANALOG + and not ENABLE_ELECTRONIC_VOTING ): raise ValidationError( - {"detail": "Value for {0} must not be less than -2", "args": [key]} + { + "detail": "Electronic voting is disabled. Only analog polls are allowed" + } ) - return data + return data + + def norm_100_percent_base_to_pollmethod( + self, onehundred_percent_base, pollmethod, old_100_percent_base=None + ): + raise NotImplementedError() diff --git a/openslides/poll/views.py b/openslides/poll/views.py new file mode 100644 index 000000000..bc7fcc643 --- /dev/null +++ b/openslides/poll/views.py @@ -0,0 +1,289 @@ +from textwrap import dedent + +from django.contrib.auth.models import AnonymousUser +from django.db import transaction +from rest_framework import status + +from openslides.utils.auth import in_some_groups +from openslides.utils.autoupdate import inform_changed_data +from openslides.utils.rest_api import ( + DecimalField, + GenericViewSet, + ListModelMixin, + ModelViewSet, + Response, + RetrieveModelMixin, + ValidationError, + detail_route, +) + +from .models import BasePoll + + +class BasePollViewSet(ModelViewSet): + valid_update_keys = [ + "majority_method", + "onehundred_percent_base", + "title", + "description", + ] + + def check_view_permissions(self): + """ + the vote view is checked seperately. For all other views manage permissions + are required. + """ + if self.action == "vote": + return True + else: + return self.has_manage_permissions() + + @transaction.atomic + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # for analog polls, votes can be given directly when the poll is created + # for assignment polls, the options do not exist yet, so the AssignmentRelatedUser ids are needed + if "votes" in request.data: + poll = serializer.save() + poll.create_options() + self.handle_request_with_votes(request, poll) + else: + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + def perform_create(self, serializer): + poll = serializer.save() + poll.create_options() + + @transaction.atomic + def update(self, request, *args, **kwargs): + """ + Customized view endpoint to update a poll. + """ + poll = self.get_object() + + partial = kwargs.get("partial", False) + serializer = self.get_serializer(poll, data=request.data, partial=partial) + serializer.is_valid(raise_exception=False) + + if poll.state != BasePoll.STATE_CREATED: + invalid_keys = set(serializer.validated_data.keys()) - set( + self.valid_update_keys + ) + if len(invalid_keys): + raise ValidationError( + { + "detail": dedent( + f""" + The poll is not in the created state. + You can only edit: {', '.join(self.valid_update_keys)}. + You provided the invalid keys: {', '.join(invalid_keys)}. + """ + ) + } + ) + + if "votes" in request.data: + self.handle_request_with_votes(request, poll) + return super().update(request, *args, **kwargs) + + def handle_request_with_votes(self, request, poll): + if poll.type != BasePoll.TYPE_ANALOG: + raise ValidationError( + {"detail": "You cannot enter votes for a non-analog poll."} + ) + + vote_data = request.data["votes"] + # convert user ids to option ids + self.convert_option_data(poll, vote_data) + + self.validate_vote_data(vote_data, poll, request.user) + self.handle_analog_vote(vote_data, poll, request.user) + + if request.data.get("publish_immediately"): + poll.state = BasePoll.STATE_PUBLISHED + elif ( + poll.state != BasePoll.STATE_PUBLISHED + ): # only set to finished if not already published + poll.state = BasePoll.STATE_FINISHED + poll.save() + + @detail_route(methods=["POST"]) + def start(self, request, pk): + poll = self.get_object() + if poll.state != BasePoll.STATE_CREATED: + raise ValidationError({"detail": "Wrong poll state"}) + poll.state = BasePoll.STATE_STARTED + + poll.save() + inform_changed_data(poll.get_votes()) + return Response() + + @detail_route(methods=["POST"]) + def stop(self, request, pk): + poll = self.get_object() + # Analog polls could not be stopped; they are stopped when + # the results are entered. + if poll.type == BasePoll.TYPE_ANALOG: + raise ValidationError( + {"detail": "Analog polls can not be stopped. Please enter votes."} + ) + + if poll.state != BasePoll.STATE_STARTED: + raise ValidationError({"detail": "Wrong poll state"}) + + poll.state = BasePoll.STATE_FINISHED + poll.save() + inform_changed_data(poll.get_votes()) + inform_changed_data(poll.get_options()) + return Response() + + @detail_route(methods=["POST"]) + def publish(self, request, pk): + poll = self.get_object() + if poll.state != BasePoll.STATE_FINISHED: + raise ValidationError({"detail": "Wrong poll state"}) + + poll.state = BasePoll.STATE_PUBLISHED + poll.save() + inform_changed_data(poll.get_votes()) + inform_changed_data(poll.get_options()) + return Response() + + @detail_route(methods=["POST"]) + def pseudoanonymize(self, request, pk): + poll = self.get_object() + + if poll.state not in (BasePoll.STATE_FINISHED, BasePoll.STATE_PUBLISHED): + raise ValidationError( + {"detail": "Pseudoanonmizing can only be done after a finished poll"} + ) + if poll.type != BasePoll.TYPE_NAMED: + raise ValidationError( + {"detail": "You can just pseudoanonymize named polls"} + ) + + poll.pseudoanonymize() + return Response() + + @detail_route(methods=["POST"]) + def reset(self, request, pk): + poll = self.get_object() + poll.reset() + return Response() + + @detail_route(methods=["POST"]) + def vote(self, request, pk): + """ + For motion polls: Just "Y", "N" or "A" (if pollmethod is "YNA") + """ + poll = self.get_object() + + if isinstance(request.user, AnonymousUser): + self.permission_denied(request) + + # check permissions based on poll type and handle requests + self.assert_can_vote(poll, request) + + data = request.data + self.validate_vote_data(data, poll, request.user) + + if poll.type == BasePoll.TYPE_ANALOG: + self.handle_analog_vote(data, poll, request.user) + if request.data.get("publish_immediately") == "1": + poll.state = BasePoll.STATE_PUBLISHED + else: + poll.state = BasePoll.STATE_FINISHED + poll.save() + + elif poll.type == BasePoll.TYPE_NAMED: + self.handle_named_vote(data, poll, request.user) + + elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: + self.handle_pseudoanonymous_vote(data, poll, request.user) + + inform_changed_data(poll) + + return Response() + + def assert_can_vote(self, poll, request): + """ + Raises a permission denied, if the user is not allowed to vote. + Analog: has to have manage permissions + Named & Pseudoanonymous: has to be in a poll group and present + Note: For pseudoanonymous it is *not* tested, if the user has already voted! + """ + if poll.type == BasePoll.TYPE_ANALOG: + if not self.has_manage_permissions(): + self.permission_denied(request) + else: + if poll.state != BasePoll.STATE_STARTED: + raise ValidationError("You can only vote on a started poll.") + if not request.user.is_present or not in_some_groups( + request.user.id, + list(poll.groups.values_list("pk", flat=True)), + exact=True, + ): + self.permission_denied(request) + + def parse_vote_value(self, obj, key): + """ Raises a ValidationError on incorrect values, including None """ + if key not in obj: + raise ValidationError({"detail": f"The field {key} is required"}) + field = DecimalField(min_value=-2, max_digits=15, decimal_places=6) + value = field.to_internal_value(obj[key]) + if value < 0 and value != -1 and value != -2: + raise ValidationError( + { + "detail": "No fractional negative values allowed, only the special values -1 and -2" + } + ) + return value + + def convert_option_data(self, poll, data): + """ + May be overwritten by subclass. Adjusts the option data based on the now existing poll + """ + pass + + def validate_vote_data(self, data, poll, user): + """ + To be implemented by subclass. Validates the data according to poll type and method and fields by validated versions. + Raises ValidationError on failure + """ + raise NotImplementedError() + + def handle_analog_vote(self, data, poll, user): + """ + To be implemented by subclass. Handles the analog vote. Assumes data is validated + """ + raise NotImplementedError() + + def handle_named_vote(self, data, poll, user): + """ + To be implemented by subclass. Handles the named vote. Assumes data is validated. + Needs to manage the voted-array per option. + """ + raise NotImplementedError() + + def handle_pseudoanonymous_vote(self, data, poll, user): + """ + To be implemented by subclass. Handles the pseudoanonymous vote. Assumes data + is validated. Needs to check, if the vote is allowed by the voted-array per poll. + Needs to add the user to the voted-array. + """ + raise NotImplementedError() + + +class BaseVoteViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + pass + + +class BaseOptionViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + pass diff --git a/openslides/topics/models.py b/openslides/topics/models.py index 73a511102..4d2216f25 100644 --- a/openslides/topics/models.py +++ b/openslides/topics/models.py @@ -1,24 +1,28 @@ from django.db import models +from openslides.utils.manager import BaseManager + from ..agenda.mixins import AgendaItemWithListOfSpeakersMixin from ..mediafiles.models import Mediafile from ..utils.models import RESTModelMixin from .access_permissions import TopicAccessPermissions -class TopicManager(models.Manager): +class TopicManager(BaseManager): """ - Customized model manager to support our get_full_queryset method. + Customized model manager to support our get_prefetched_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all topics. In the background all attachments and the related agenda item are prefetched from the database. """ - return self.get_queryset().prefetch_related( - "attachments", "lists_of_speakers", "agenda_items" + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .prefetch_related("attachments", "lists_of_speakers", "agenda_items") ) diff --git a/openslides/users/models.py b/openslides/users/models.py index 43e22e596..a1c05723a 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -17,6 +17,8 @@ from django.db.models import Prefetch from django.utils import timezone from jsonfield import JSONField +from openslides.utils.manager import BaseManager + from ..core.config import config from ..utils.auth import GROUP_ADMIN_PK from ..utils.models import CASCADE_AND_AUTOUPDATE, RESTModelMixin @@ -30,16 +32,19 @@ from .access_permissions import ( class UserManager(BaseUserManager): """ Customized manager that creates new users only with a password and a - username. It also supports our get_full_queryset method. + username. It also supports our get_prefetched_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, ids=None): """ Returns the normal queryset with all users. In the background all groups are prefetched from the database together with all permissions and content types. """ - return self.get_queryset().prefetch_related( + queryset = self.get_queryset() + if ids: + queryset = queryset.filter(pk__in=ids) + return queryset.prefetch_related( Prefetch( "groups", queryset=Group.objects.select_related("group_ptr").prefetch_related( @@ -91,7 +96,7 @@ class UserManager(BaseUserManager): base_name = first_name or last_name if not base_name: raise ValueError( - "Either 'first_name' or 'last_name' must not be " "empty." + "Either 'first_name' or 'last_name' must not be empty." ) if not self.filter(username=base_name).exists(): @@ -293,22 +298,21 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser): class GroupManager(_GroupManager): """ - Customized manager that supports our get_full_queryset method. + Customized manager that supports our get_prefetched_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, ids=None): """ Returns the normal queryset with all groups. In the background all permissions with the content types are prefetched from the database. """ - return ( - self.get_queryset() - .select_related("group_ptr") - .prefetch_related( - Prefetch( - "permissions", - queryset=Permission.objects.select_related("content_type"), - ) + queryset = self.get_queryset() + if ids: + queryset = queryset.filter(pk__in=ids) + return queryset.select_related("group_ptr").prefetch_related( + Prefetch( + "permissions", + queryset=Permission.objects.select_related("content_type"), ) ) @@ -325,17 +329,17 @@ class Group(RESTModelMixin, DjangoGroup): default_permissions = () -class PersonalNoteManager(models.Manager): +class PersonalNoteManager(BaseManager): """ - Customized model manager to support our get_full_queryset method. + Customized model manager to support our get_prefetched_queryset method. """ - def get_full_queryset(self): + def get_prefetched_queryset(self, *args, **kwargs): """ Returns the normal queryset with all personal notes. In the background all users are prefetched from the database. """ - return self.get_queryset().select_related("user") + return super().get_prefetched_queryset(*args, **kwargs).select_related("user") class PersonalNote(RESTModelMixin, models.Model): diff --git a/openslides/users/views.py b/openslides/users/views.py index 5853e7ac1..fdb8ab0d2 100644 --- a/openslides/users/views.py +++ b/openslides/users/views.py @@ -32,7 +32,7 @@ from ..utils.auth import ( anonymous_is_enabled, has_perm, ) -from ..utils.autoupdate import Element, inform_changed_data, inform_changed_elements +from ..utils.autoupdate import AutoupdateElement, inform_changed_data, inform_elements from ..utils.cache import element_cache from ..utils.rest_api import ( ModelViewSet, @@ -613,8 +613,7 @@ class GroupViewSet(ModelViewSet): """ Updates every users, if some permission changes. For this, every affected collection is fetched via the permission_change signal and every object of the collection passed - into the cache/autoupdate system. Also the personal (restrcited) cache of every affected - user (all users of the group) will be deleted, so it is rebuild after this permission change. + into the cache/autoupdate system. """ if isinstance(changed_permissions, Permission): changed_permissions = [changed_permissions] @@ -622,7 +621,7 @@ class GroupViewSet(ModelViewSet): if not changed_permissions: return # either None or empty list. - elements: List[Element] = [] + elements: List[AutoupdateElement] = [] signal_results = permission_change.send(None, permissions=changed_permissions) all_full_data = async_to_sync(element_cache.get_all_data_list)() for _, signal_collections in signal_results: @@ -631,14 +630,14 @@ class GroupViewSet(ModelViewSet): cachable.get_collection_string(), {} ): elements.append( - Element( + AutoupdateElement( id=full_data["id"], collection_string=cachable.get_collection_string(), full_data=full_data, disable_history=True, ) ) - inform_changed_elements(elements) + inform_elements(elements) class PersonalNoteViewSet(ModelViewSet): diff --git a/openslides/utils/access_permissions.py b/openslides/utils/access_permissions.py index 1fb547e13..f055da290 100644 --- a/openslides/utils/access_permissions.py +++ b/openslides/utils/access_permissions.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Dict, List, Set +from typing import Any, Callable, Coroutine, Dict, List, Set from asgiref.sync import async_to_sync @@ -59,7 +59,7 @@ class RequiredUsers: Helper class to find all users that are required by another element. """ - callables: Dict[str, Callable[[Dict[str, Any]], Set[int]]] = {} + callables: Dict[str, Callable[[Dict[str, Any]], Coroutine[Any, Any, Set[int]]]] = {} def get_collection_strings(self) -> Set[str]: """ @@ -68,7 +68,9 @@ class RequiredUsers: return set(self.callables.keys()) def add_collection_string( - self, collection_string: str, callable: Callable[[Dict[str, Any]], Set[int]] + self, + collection_string: str, + callable: Callable[[Dict[str, Any]], Coroutine[Any, Any, Set[int]]], ) -> None: """ Add a callable for a collection_string to get the required users of the @@ -94,7 +96,7 @@ class RequiredUsers: continue for element in collection_data.values(): - user_ids.update(get_user_ids(element)) + user_ids.update(await get_user_ids(element)) return user_ids diff --git a/openslides/utils/auth.py b/openslides/utils/auth.py index 7407370a0..5b27a350b 100644 --- a/openslides/utils/auth.py +++ b/openslides/utils/auth.py @@ -111,24 +111,32 @@ async def async_has_perm(user_id: int, perm: str) -> bool: return has_perm -def in_some_groups(user_id: int, groups: List[int]) -> bool: +# async code doesn't work well with QuerySets, so we have to give a list of ints for groups +def in_some_groups(user_id: int, groups: List[int], exact: bool = False) -> bool: """ Checks that user is in at least one given group. Groups can be given as a list - of ids or group instances. If the user is in the admin group (pk = 2) the result - is always true, even if no groups are given. + of ids or a QuerySet. + + If exact is false (default) and the user is in the admin group (pk = 2), + the result is always true, even if no groups are given. + + If exact is true, the user must be in one of the groups, ignoring the possible + superadmin-status of the user. user_id 0 means anonymous user. """ # Convert user to right type # TODO: Remove this and make use, that user has always the right type user_id = user_to_user_id(user_id) - return async_to_sync(async_in_some_groups)(user_id, groups) + return async_to_sync(async_in_some_groups)(user_id, groups, exact) -async def async_in_some_groups(user_id: int, groups: List[int]) -> bool: +async def async_in_some_groups( + user_id: int, groups: List[int], exact: bool = False +) -> bool: """ Checks that user is in at least one given group. Groups can be given as a list - of ids. If the user is in the admin group (pk = 2) the result + of ids or a QuerySet. If the user is in the admin group (pk = 2) the result is always true, even if no groups are given. user_id 0 means anonymous user. @@ -144,7 +152,7 @@ async def async_in_some_groups(user_id: int, groups: List[int]) -> bool: ) if user_data is None: raise UserDoesNotExist() - if GROUP_ADMIN_PK in user_data["groups_id"]: + if not exact and GROUP_ADMIN_PK in user_data["groups_id"]: # User in admin group (pk 2) grants all permissions. in_some_groups = True else: diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index 0529c7fc5..7701b1598 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -1,4 +1,5 @@ import threading +from collections import defaultdict from typing import Any, Dict, Iterable, List, Optional, Tuple, Union from asgiref.sync import async_to_sync @@ -8,204 +9,123 @@ from mypy_extensions import TypedDict from .cache import element_cache, get_element_id from .projector import get_projector_data -from .utils import get_model_from_collection_string +from .utils import get_model_from_collection_string, is_iterable -class ElementBase(TypedDict): +class AutoupdateElementBase(TypedDict): id: int collection_string: str - full_data: Optional[Dict[str, Any]] -class Element(ElementBase, total=False): +class AutoupdateElement(AutoupdateElementBase, total=False): """ Data container to handle one root rest element for the autoupdate, history and caching process. - The fields `id`, `collection_string` and `full_data` are required, the other - fields are optional. + The fields `id` and `collection_string` are required to identify the element. All + other fields are optional: - if full_data is None, it means, that the element was deleted. If reload is - True, full_data is ignored and reloaded from the database later in the - process. + full_data: If a value is given (dict or None), it won't be loaded from the DB. + If otherwise no value is given, the AutoupdateBundle will try to resolve the object + from the DB and serialize it into the full_data. + + information and user_id: These fields are for the history indicating what and who + made changes. + + disable_history: If this is True, the element (and the containing full_data) won't + be saved into the history. Information and user_id is then irrelevant. + + no_delete_on_restriction is a flag, which is saved into the models in the cache + as the _no_delete_on_restriction key. If this is true, there should neither be an + entry for one specific model in the changed *nor the deleted* part of the + autoupdate, if the model was restricted. """ information: List[str] - restricted: bool user_id: Optional[int] disable_history: bool - reload: bool + no_delete_on_restriction: bool + full_data: Optional[Dict[str, Any]] -AutoupdateFormat = TypedDict( - "AutoupdateFormat", - { - "changed": Dict[str, List[Dict[str, Any]]], - "deleted": Dict[str, List[int]], - "from_change_id": int, - "to_change_id": int, - "all_data": bool, - }, -) - - -def inform_changed_data( - instances: Union[Iterable[Model], Model], - information: List[str] = None, - user_id: Optional[int] = None, - restricted: bool = False, -) -> None: +class AutoupdateBundle: """ - Informs the autoupdate system and the caching system about the creation or - update of an element. - - The argument instances can be one instance or an iterable over instances. - - History creation is enabled. + Collects changed elements via inform*_data. After the collecting-step is finished, + the bundle releases all changes to the history and element cache via `.done()`. """ - if information is None: - information = [] - root_instances = set() - if not isinstance(instances, Iterable): - instances = (instances,) - for instance in instances: - try: - root_instances.add(instance.get_root_rest_element()) - except AttributeError: - # Instance has no method get_root_rest_element. Just ignore it. - pass - - elements: Dict[str, Element] = {} - for root_instance in root_instances: - key = root_instance.get_collection_string() + str(root_instance.get_rest_pk()) - elements[key] = Element( - id=root_instance.get_rest_pk(), - collection_string=root_instance.get_collection_string(), - full_data=root_instance.get_full_data(), - information=information, - restricted=restricted, - user_id=user_id, + def __init__(self) -> None: + self.autoupdate_elements: Dict[str, Dict[int, AutoupdateElement]] = defaultdict( + dict ) - bundle = autoupdate_bundle.get(threading.get_ident()) - if bundle is not None: - # Put all elements into the autoupdate_bundle. - bundle.update(elements) - else: - # Send autoupdate directly - handle_changed_elements(elements.values()) + def add(self, elements: Iterable[AutoupdateElement]) -> None: + """ Adds the elements to the bundle """ + for element in elements: + self.autoupdate_elements[element["collection_string"]][ + element["id"] + ] = element + def done(self) -> None: + """ + Finishes the bundle by resolving all missing data and passing it to + the history and element cache. + """ + if not self.autoupdate_elements: + return -def inform_deleted_data( - deleted_elements: Iterable[Tuple[str, int]], - information: List[str] = None, - user_id: Optional[int] = None, - restricted: bool = False, -) -> None: - """ - Informs the autoupdate system and the caching system about the deletion of - elements. + for collection, elements in self.autoupdate_elements.items(): + # Get all ids, that do not have a full_data key + # (element["full_data"]=None will not be resolved again!) + ids = [ + element["id"] + for element in elements.values() + if "full_data" not in element + ] + if ids: + # Get all missing models. If e.g. an id could not be found it + # means, it was deleted. Since there is not full_data entry + # for the element, the data will be interpreted as None, which + # is correct for deleted elements. + model_class = get_model_from_collection_string(collection) + for full_data in model_class.get_elements(ids): + elements[full_data["id"]]["full_data"] = full_data - History creation is enabled. - """ - if information is None: - information = [] - elements: Dict[str, Element] = {} - for deleted_element in deleted_elements: - key = deleted_element[0] + str(deleted_element[1]) - elements[key] = Element( - id=deleted_element[1], - collection_string=deleted_element[0], - full_data=None, - information=information, - restricted=restricted, - user_id=user_id, - ) + # Save histroy here using sync code. + save_history(self.elements) - bundle = autoupdate_bundle.get(threading.get_ident()) - if bundle is not None: - # Put all elements into the autoupdate_bundle. - bundle.update(elements) - else: - # Send autoupdate directly - handle_changed_elements(elements.values()) + # Update cache and send autoupdate using async code. + async_to_sync(self.async_handle_collection_elements)() + @property + def elements(self) -> Iterable[AutoupdateElement]: + """ Iterator for all elements in this bundle """ + for elements in self.autoupdate_elements.values(): + yield from elements.values() -def inform_changed_elements(changed_elements: Iterable[Element]) -> None: - """ - Informs the autoupdate system about some elements. This is used just to send - some data to all users. - - If you want to save history information, user id or disable history you - have to put information or flag inside the elements. - """ - elements = {} - for changed_element in changed_elements: - key = changed_element["collection_string"] + str(changed_element["id"]) - elements[key] = changed_element - - bundle = autoupdate_bundle.get(threading.get_ident()) - if bundle is not None: - # Put all collection elements into the autoupdate_bundle. - bundle.update(elements) - else: - # Send autoupdate directly - handle_changed_elements(elements.values()) - - -""" -Global container for autoupdate bundles -""" -autoupdate_bundle: Dict[int, Dict[str, Element]] = {} - - -class AutoupdateBundleMiddleware: - """ - Middleware to handle autoupdate bundling. - """ - - def __init__(self, get_response: Any) -> None: - self.get_response = get_response - # One-time configuration and initialization. - - def __call__(self, request: Any) -> Any: - thread_id = threading.get_ident() - autoupdate_bundle[thread_id] = {} - - response = self.get_response(request) - - bundle: Dict[str, Element] = autoupdate_bundle.pop(thread_id) - handle_changed_elements(bundle.values()) - return response - - -def handle_changed_elements(elements: Iterable[Element]) -> None: - """ - Helper function, that sends elements through a channel to the - autoupdate system and updates the cache. - - Does nothing if elements is empty. - """ - - async def update_cache(elements: Iterable[Element]) -> int: + async def update_cache(self) -> int: """ Async helper function to update the cache. Returns the change_id """ cache_elements: Dict[str, Optional[Dict[str, Any]]] = {} - for element in elements: + for element in self.elements: element_id = get_element_id(element["collection_string"], element["id"]) - cache_elements[element_id] = element["full_data"] + full_data = element.get("full_data") + if full_data: + full_data["_no_delete_on_restriction"] = element.get( + "no_delete_on_restriction", False + ) + cache_elements[element_id] = full_data return await element_cache.change_elements(cache_elements) - async def async_handle_collection_elements(elements: Iterable[Element]) -> None: + async def async_handle_collection_elements(self) -> None: """ Async helper function to update cache and send autoupdate. """ # Update cache - change_id = await update_cache(elements) + change_id = await self.update_cache() # Send autoupdate channel_layer = get_channel_layer() @@ -225,27 +145,115 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: }, ) - if elements: - for element in elements: - if element.get("reload"): - model = get_model_from_collection_string(element["collection_string"]) - try: - instance = model.objects.get(pk=element["id"]) - except model.DoesNotExist: - # The instance was deleted so we set full_data explicitly to None. - element["full_data"] = None - else: - element["full_data"] = instance.get_full_data() - # Save histroy here using sync code. - save_history(elements) +def inform_changed_data( + instances: Union[Iterable[Model], Model], + information: List[str] = None, + user_id: Optional[int] = None, + disable_history: bool = False, + no_delete_on_restriction: bool = False, +) -> None: + """ + Informs the autoupdate system and the caching system about the creation or + update of an element. - # Update cache and send autoupdate using async code. - async_to_sync(async_handle_collection_elements)(elements) + The argument instances can be one instance or an iterable over instances. + + History creation is enabled. + """ + if information is None: + information = [] + if not is_iterable(instances): + instances = (instances,) + + root_instances = set(instance.get_root_rest_element() for instance in instances) + elements = [ + AutoupdateElement( + id=root_instance.get_rest_pk(), + collection_string=root_instance.get_collection_string(), + disable_history=disable_history, + information=information, + user_id=user_id, + no_delete_on_restriction=no_delete_on_restriction, + ) + for root_instance in root_instances + ] + inform_elements(elements) -def save_history(elements: Iterable[Element]) -> Iterable: - # TODO: Try to write Iterable[History] here +def inform_deleted_data( + deleted_elements: Iterable[Tuple[str, int]], + information: List[str] = None, + user_id: Optional[int] = None, +) -> None: + """ + Informs the autoupdate system and the caching system about the deletion of + elements. + + History creation is enabled. + """ + if information is None: + information = [] + + elements = [ + AutoupdateElement( + id=deleted_element[1], + collection_string=deleted_element[0], + full_data=None, + information=information, + user_id=user_id, + ) + for deleted_element in deleted_elements + ] + inform_elements(elements) + + +def inform_elements(elements: Iterable[AutoupdateElement]) -> None: + """ + Informs the autoupdate system about some elements. This is used just to send + some data to all users. + + If you want to save history information, user id or disable history you + have to put information or flag inside the elements. + """ + bundle = autoupdate_bundle.get(threading.get_ident()) + if bundle is not None: + # Put all elements into the autoupdate_bundle. + bundle.add(elements) + else: + # Send autoupdate directly + bundle = AutoupdateBundle() + bundle.add(elements) + bundle.done() + + +""" +Global container for autoupdate bundles +""" +autoupdate_bundle: Dict[int, AutoupdateBundle] = {} + + +class AutoupdateBundleMiddleware: + """ + Middleware to handle autoupdate bundling. + """ + + def __init__(self, get_response: Any) -> None: + self.get_response = get_response + # One-time configuration and initialization. + + def __call__(self, request: Any) -> Any: + thread_id = threading.get_ident() + autoupdate_bundle[thread_id] = AutoupdateBundle() + + response = self.get_response(request) + + bundle: AutoupdateBundle = autoupdate_bundle.pop(thread_id) + bundle.done() + return response + + +def save_history(elements: Iterable[AutoupdateElement]) -> Iterable: """ Thin wrapper around the call of history saving manager method. diff --git a/openslides/utils/cache.py b/openslides/utils/cache.py index 59bd2d2d5..92cc0385a 100644 --- a/openslides/utils/cache.py +++ b/openslides/utils/cache.py @@ -204,7 +204,11 @@ class ElementCache: all_data: Dict[str, List[Dict[str, Any]]] = defaultdict(list) for element_id, data in (await self.cache_provider.get_all_data()).items(): collection_string, _ = split_element_id(element_id) - all_data[collection_string].append(json.loads(data.decode())) + element = json.loads(data.decode()) + element.pop( + "_no_delete_on_restriction", False + ) # remove special field for get_data_since + all_data[collection_string].append(element) if user_id is not None: for collection_string in all_data.keys(): @@ -226,7 +230,11 @@ class ElementCache: all_data: Dict[str, Dict[int, Dict[str, Any]]] = defaultdict(dict) for element_id, data in (await self.cache_provider.get_all_data()).items(): collection_string, id = split_element_id(element_id) - all_data[collection_string][id] = json.loads(data.decode()) + element = json.loads(data.decode()) + element.pop( + "_no_delete_on_restriction", False + ) # remove special field for get_data_since + all_data[collection_string][id] = element return dict(all_data) async def get_collection_data( @@ -241,6 +249,9 @@ class ElementCache: collection_data = {} for id in encoded_collection_data.keys(): collection_data[id] = json.loads(encoded_collection_data[id].decode()) + collection_data[id].pop( + "_no_delete_on_restriction", False + ) # remove special field for get_data_since return collection_data async def get_element_data( @@ -257,6 +268,9 @@ class ElementCache: if encoded_element is None: return None element = json.loads(encoded_element.decode()) # type: ignore + element.pop( + "_no_delete_on_restriction", False + ) # remove special field for get_data_since if user_id is not None: element = await self.restrict_element_data( @@ -315,10 +329,24 @@ class ElementCache: for collection_string, value_list in raw_changed_elements.items() } - if user_id is not None: + if user_id is None: + for elements in changed_elements.values(): + for element in elements: + element.pop("_no_delete_on_restriction", False) + else: # the list(...) is important, because `changed_elements` will be # altered during iteration and restricting data for collection_string, elements in list(changed_elements.items()): + # Remove the _no_delete_on_restriction from each element. Collect all ids, where + # this field is absent or False. + unrestricted_ids = set() + for element in elements: + no_delete_on_restriction = element.pop( + "_no_delete_on_restriction", False + ) + if not no_delete_on_restriction: + unrestricted_ids.add(element["id"]) + cacheable = self.cachables[collection_string] restricted_elements = await cacheable.restrict_elements( user_id, elements @@ -327,11 +355,12 @@ class ElementCache: # If the model is personalized, it must not be deleted for other users if not cacheable.personalized_model: # Add removed objects (through restricter) to deleted elements. - element_ids = set([element["id"] for element in elements]) restricted_element_ids = set( [element["id"] for element in restricted_elements] ) - for id in element_ids - restricted_element_ids: + # Delete all ids, that are allowed to be deleted (see unrestricted_ids) and are + # not present after restricting the data. + for id in unrestricted_ids - restricted_element_ids: deleted_elements.append(get_element_id(collection_string, id)) if not restricted_elements: diff --git a/openslides/utils/consumers.py b/openslides/utils/consumers.py index 610044dc6..1073f831d 100644 --- a/openslides/utils/consumers.py +++ b/openslides/utils/consumers.py @@ -4,11 +4,11 @@ from typing import Any, Dict, List, Optional, cast from urllib.parse import parse_qs from channels.generic.websocket import AsyncWebsocketConsumer +from mypy_extensions import TypedDict from ..utils.websocket import WEBSOCKET_CHANGE_ID_TOO_HIGH from . import logging from .auth import UserDoesNotExist, async_anonymous_is_enabled -from .autoupdate import AutoupdateFormat from .cache import ChangeIdTooLowError, element_cache, split_element_id from .utils import get_worker_id from .websocket import ProtocollAsyncJsonWebsocketConsumer @@ -16,6 +16,17 @@ from .websocket import ProtocollAsyncJsonWebsocketConsumer logger = logging.getLogger("openslides.websocket") +AutoupdateFormat = TypedDict( + "AutoupdateFormat", + { + "changed": Dict[str, List[Dict[str, Any]]], + "deleted": Dict[str, List[int]], + "from_change_id": int, + "to_change_id": int, + "all_data": bool, + }, +) + class SiteConsumer(ProtocollAsyncJsonWebsocketConsumer): """ diff --git a/openslides/utils/manager.py b/openslides/utils/manager.py new file mode 100644 index 000000000..76b108365 --- /dev/null +++ b/openslides/utils/manager.py @@ -0,0 +1,20 @@ +from typing import Any, List, Optional + +from django.db.models import Manager, QuerySet + + +class BaseManager(Manager): + """ + A base manager for all REST-models. + Provides a base implementation for `get_prefetched_queryset` and + allows filtering of the queryset by ids. + """ + + def get_queryset(self, ids: Optional[List[int]] = None) -> QuerySet: + queryset = super().get_queryset() + if ids: + queryset = queryset.filter(pk__in=ids) + return queryset + + def get_prefetched_queryset(self, *args: Any, **kwargs: Any) -> QuerySet: + return self.get_queryset(*args, **kwargs) diff --git a/openslides/utils/models.py b/openslides/utils/models.py index f9c9dea7b..8a531ac17 100644 --- a/openslides/utils/models.py +++ b/openslides/utils/models.py @@ -6,9 +6,9 @@ from django.db import models from . import logging from .access_permissions import BaseAccessPermissions -from .autoupdate import Element, inform_changed_data, inform_changed_elements +from .autoupdate import AutoupdateElement, inform_changed_data, inform_elements from .rest_api import model_serializer_classes -from .utils import convert_camel_case_to_pseudo_snake_case +from .utils import convert_camel_case_to_pseudo_snake_case, get_element_id logger = logging.getLogger(__name__) @@ -90,7 +90,16 @@ class RESTModelMixin: """ return self.pk # type: ignore - def save(self, skip_autoupdate: bool = False, *args: Any, **kwargs: Any) -> Any: + def get_element_id(self) -> str: + return get_element_id(self.get_collection_string(), self.get_rest_pk()) + + def save( + self, + skip_autoupdate: bool = False, + no_delete_on_restriction: bool = False, + *args: Any, + **kwargs: Any, + ) -> Any: """ Calls Django's save() method and afterwards hits the autoupdate system. @@ -104,7 +113,10 @@ class RESTModelMixin: return_value = super().save(*args, **kwargs) # type: ignore if not skip_autoupdate: - inform_changed_data(self.get_root_rest_element()) + inform_changed_data( + self.get_root_rest_element(), + no_delete_on_restriction=no_delete_on_restriction, + ) return return_value def delete(self, skip_autoupdate: bool = False, *args: Any, **kwargs: Any) -> Any: @@ -130,18 +142,23 @@ class RESTModelMixin: return return_value @classmethod - def get_elements(cls) -> List[Dict[str, Any]]: + def get_elements(cls, ids: Optional[List[int]] = None) -> List[Dict[str, Any]]: """ Returns all elements as full_data. """ - logger.info(f"Loading {cls.get_collection_string()}") + do_logging = not bool(ids) + + if do_logging: + logger.info(f"Loading {cls.get_collection_string()}") # Get the query to receive all data from the database. try: - query = cls.objects.get_full_queryset() # type: ignore + query = cls.objects.get_prefetched_queryset(ids=ids) # type: ignore except AttributeError: - # If the model des not have to method get_full_queryset(), then use + # If the model des not have to method get_prefetched_queryset(), then use # the default queryset from django. query = cls.objects # type: ignore + if ids: + query = query.filter(pk__in=ids) # Build a dict from the instance id to the full_data instances = query.all() @@ -153,11 +170,12 @@ class RESTModelMixin: for i, instance in enumerate(instances): # Append full data from this instance full_data.append(instance.get_full_data()) - # log progress every 5 seconds - current_time = time.time() - if current_time > last_time + 5: - last_time = current_time - logger.info(f"\t{i+1}/{instances_length}...") + if do_logging: + # log progress every 5 seconds + current_time = time.time() + if current_time > last_time + 5: + last_time = current_time + logger.info(f"\t{i+1}/{instances_length}...") return full_data @classmethod @@ -211,12 +229,10 @@ def CASCADE_AND_AUTOUPDATE( for sub_obj in sub_objs: root_rest_element = sub_obj.get_root_rest_element() elements.append( - Element( + AutoupdateElement( collection_string=root_rest_element.get_collection_string(), - id=root_rest_element.pk, - full_data=None, - reload=True, + id=root_rest_element.get_rest_pk(), ) ) - inform_changed_elements(elements) + inform_elements(elements) models.CASCADE(collector, field, sub_objs, using) diff --git a/openslides/utils/projector.py b/openslides/utils/projector.py index 98ba26702..9e20fddcd 100644 --- a/openslides/utils/projector.py +++ b/openslides/utils/projector.py @@ -100,3 +100,27 @@ async def get_config(all_data: AllData, key: str) -> Any: config_id = (await config.async_get_key_to_id())[key] return all_data[config.get_collection_string()][config_id]["value"] + + +def get_model(all_data: AllData, collection: str, id: Any) -> Dict[str, Any]: + """ + Tries to get the model identified by the collection and id. + If the id is invalid or the model not found, ProjectorElementExceptions will be raised. + """ + if id is None: + raise ProjectorElementException(f"id is required for {collection} slide") + + try: + model = all_data[collection][id] + except KeyError: + raise ProjectorElementException(f"{collection} with id {id} does not exist") + return model + + +def get_models( + all_data: AllData, collection: str, ids: List[Any] +) -> List[Dict[str, Any]]: + """ + Tries to fetch all given models. Models are required to be all of the collection `collection`. + """ + return [get_model(all_data, collection, id) for id in ids] diff --git a/openslides/utils/settings.py.tpl b/openslides/utils/settings.py.tpl index 949bb244b..4d23ce567 100644 --- a/openslides/utils/settings.py.tpl +++ b/openslides/utils/settings.py.tpl @@ -127,6 +127,10 @@ if ENABLE_SAML: INSTALLED_APPS += ['openslides.saml'] +# Controls if electronic voting (means non-analog polls) are enabled. +ENABLE_ELECTRONIC_VOTING = False + + # Internationalization # https://docs.djangoproject.com/en/1.10/topics/i18n/ diff --git a/openslides/utils/test.py b/openslides/utils/test.py deleted file mode 100644 index b5d321d4d..000000000 --- a/openslides/utils/test.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.test import TestCase as _TestCase - - -class TestCase(_TestCase): - """ - Does currently nothing. - """ diff --git a/openslides/utils/utils.py b/openslides/utils/utils.py index e88ed9da6..f068730be 100644 --- a/openslides/utils/utils.py +++ b/openslides/utils/utils.py @@ -1,7 +1,7 @@ import random import re import string -from typing import Dict, Generator, Optional, Tuple, Type, Union +from typing import Any, Dict, Generator, Optional, Tuple, Type, Union import roman from django.apps import apps @@ -64,6 +64,28 @@ def str_dict_to_bytes(str_dict: Dict[str, str]) -> Dict[bytes, bytes]: return out +def is_int(obj: Any) -> bool: + try: + int(obj) + return True + except (ValueError, TypeError): + return False + + +def is_iterable(obj: Any) -> bool: + """ + Do not rely on `isinstance(obj, Iterable` with `Iterable` being imperted + from typing. This breaks at proxyobjects, like SimpleLazyObjects from Django. + Instead try to get the iterable from the object. THis fails on non-iterable + proxyobjects. + """ + try: + iter(obj) + return True + except TypeError: + return False + + _models_to_collection_string: Dict[str, Type[Model]] = {} diff --git a/requirements/production.txt b/requirements/production.txt index 365f7194d..58beedfb2 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -15,4 +15,4 @@ PyPDF2>=1.26,<1.27 roman>=2.0,<3.2 setuptools>=29.0,<42.0 typing_extensions>=3.6.6,<3.8 -websockets>=8.0,<9.0 \ No newline at end of file +websockets>=8.0,<9.0 diff --git a/tests/count_queries.py b/tests/count_queries.py new file mode 100644 index 000000000..7a4a5938a --- /dev/null +++ b/tests/count_queries.py @@ -0,0 +1,46 @@ +from typing import Callable + +from django.db import DEFAULT_DB_ALIAS, connections +from django.test.utils import CaptureQueriesContext + + +def count_queries(func, verbose=False) -> Callable[..., int]: + def wrapper(*args, **kwargs) -> int: + context = CaptureQueriesContext(connections[DEFAULT_DB_ALIAS]) + with context: + func(*args, **kwargs) + + if verbose: + print(get_verbose_queries(context)) + + return len(context) + + return wrapper + + +class AssertNumQueriesContext(CaptureQueriesContext): + def __init__(self, test_case, num, verbose): + self.test_case = test_case + self.num = num + self.verbose = verbose + super().__init__(connections[DEFAULT_DB_ALIAS]) + + def __exit__(self, exc_type, exc_value, traceback): + super().__exit__(exc_type, exc_value, traceback) + if exc_type is not None: + return + executed = len(self) + verbose_queries = get_verbose_queries(self) + if self.verbose: + print(verbose_queries) + self.test_case.assertEqual(executed, self.num) + else: + self.test_case.assertEqual(executed, self.num, verbose_queries) + + +def get_verbose_queries(context): + queries = "\n".join( + f"{i}. {query['sql']}" + for i, query in enumerate(context.captured_queries, start=1) + ) + return f"{len(context)} queries executed\nCaptured queries were:\n{queries}" diff --git a/tests/integration/agenda/test_models.py b/tests/integration/agenda/test_models.py index 417b9c25f..0f5356a0d 100644 --- a/tests/integration/agenda/test_models.py +++ b/tests/integration/agenda/test_models.py @@ -1,6 +1,6 @@ from openslides.agenda.models import Item from openslides.topics.models import Topic -from openslides.utils.test import TestCase +from tests.test_case import TestCase class TestItemManager(TestCase): diff --git a/tests/integration/agenda/test_viewset.py b/tests/integration/agenda/test_viewset.py index e07f80757..18ec5dc99 100644 --- a/tests/integration/agenda/test_viewset.py +++ b/tests/integration/agenda/test_viewset.py @@ -12,14 +12,64 @@ from openslides.assignments.models import Assignment from openslides.core.config import config from openslides.core.models import Countdown from openslides.mediafiles.models import Mediafile -from openslides.motions.models import Motion +from openslides.motions.models import Motion, MotionBlock from openslides.topics.models import Topic from openslides.users.models import Group from openslides.utils.autoupdate import inform_changed_data -from openslides.utils.test import TestCase +from tests.count_queries import count_queries +from tests.test_case import TestCase from ...common_groups import GROUP_DEFAULT_PK -from ..helpers import count_queries + + +@pytest.mark.django_db(transaction=False) +def test_agenda_item_db_queries(): + """ + Tests that only the following db queries are done: + * 1 request to get the list of all agenda items, + * 1 request to get all assignments, + * 1 request to get all motions, + * 1 request to get all topics, + * 1 request to get all motion blocks and + * 1 request to get all parents + """ + parent = Topic.objects.create(title="parent").agenda_item + for index in range(10): + item = Topic.objects.create(title=f"topic{index}").agenda_item + item.parent = parent + item.save() + Motion.objects.create(title="motion1") + Motion.objects.create(title="motion2") + Assignment.objects.create(title="assignment1", open_posts=5) + Assignment.objects.create(title="assignment2", open_posts=5) + MotionBlock.objects.create(title="block1") + MotionBlock.objects.create(title="block1") + + assert count_queries(Item.get_elements)() == 6 + + +@pytest.mark.django_db(transaction=False) +def test_list_of_speakers_db_queries(): + """ + Tests that only the following db queries are done: + * 1 requests to get the list of all lists of speakers + * 1 request to get all speakers + * 4 requests to get the assignments, motions, topics and mediafiles and + """ + for index in range(10): + Topic.objects.create(title=f"topic{index}") + parent = Topic.objects.create(title="parent").agenda_item + child = Topic.objects.create(title="child").agenda_item + child.parent = parent + child.save() + Motion.objects.create(title="motion1") + Motion.objects.create(title="motion2") + Assignment.objects.create(title="assignment", open_posts=5) + Mediafile.objects.create( + title=f"mediafile", mediafile=SimpleUploadedFile(f"some_file", b"some content.") + ) + + assert count_queries(ListOfSpeakers.get_elements)() == 6 class ContentObjects(TestCase): @@ -233,76 +283,16 @@ class RetrieveListOfSpeakers(TestCase): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) -@pytest.mark.django_db(transaction=False) -def test_agenda_item_db_queries(): - """ - Tests that only the following db queries are done: - * 1 requests to get the list of all agenda items, - * 3 requests to get the assignments, motions and topics and - * 1 request to get an agenda item (why?) - TODO: The last three request are a bug. - """ - for index in range(10): - Topic.objects.create(title=f"topic{index}") - parent = Topic.objects.create(title="parent").agenda_item - child = Topic.objects.create(title="child").agenda_item - child.parent = parent - child.save() - Motion.objects.create(title="motion1") - Motion.objects.create(title="motion2") - Assignment.objects.create(title="assignment", open_posts=5) - - assert count_queries(Item.get_elements) == 5 - - -@pytest.mark.django_db(transaction=False) -def test_list_of_speakers_db_queries(): - """ - Tests that only the following db queries are done: - * 1 requests to get the list of all lists of speakers - * 1 request to get all speakers - * 4 requests to get the assignments, motions, topics and mediafiles and - """ - for index in range(10): - Topic.objects.create(title=f"topic{index}") - parent = Topic.objects.create(title="parent").agenda_item - child = Topic.objects.create(title="child").agenda_item - child.parent = parent - child.save() - Motion.objects.create(title="motion1") - Motion.objects.create(title="motion2") - Assignment.objects.create(title="assignment", open_posts=5) - Mediafile.objects.create( - title=f"mediafile", mediafile=SimpleUploadedFile(f"some_file", b"some content.") - ) - - assert count_queries(ListOfSpeakers.get_elements) == 6 - - class ManageSpeaker(TestCase): """ Tests managing speakers. """ - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - + def advancedSetUp(self): self.list_of_speakers = Topic.objects.create( title="test_title_aZaedij4gohn5eeQu8fe" ).list_of_speakers - self.user = get_user_model().objects.create_user( - username="test_user_jooSaex1bo5ooPhuphae", - password="test_password_e6paev4zeeh9n", - ) - - def revoke_admin_rights(self): - admin = get_user_model().objects.get(username="admin") - group_admin = admin.groups.get(name="Admin") - group_delegates = type(group_admin).objects.get(name="Delegates") - admin.groups.add(group_delegates) - admin.groups.remove(group_admin) - inform_changed_data(admin) + self.user, _ = self.create_user() def test_add_oneself_once(self): response = self.client.post( @@ -383,7 +373,7 @@ class ManageSpeaker(TestCase): self.assertEqual(response.status_code, 400) def test_add_someone_else_non_admin(self): - self.revoke_admin_rights() + self.make_admin_delegate() response = self.client.post( reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), @@ -419,7 +409,7 @@ class ManageSpeaker(TestCase): self.assertEqual(response.status_code, 200) def test_remove_someone_else_non_admin(self): - self.revoke_admin_rights() + self.make_admin_delegate() speaker = Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.delete( @@ -433,14 +423,13 @@ class ManageSpeaker(TestCase): response = self.client.patch( reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), {"user": self.user.pk, "marked": True}, - format="json", ) self.assertEqual(response.status_code, 200) self.assertTrue(Speaker.objects.get().marked) def test_mark_speaker_non_admin(self): - self.revoke_admin_rights() + self.make_admin_delegate() Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.patch( @@ -515,7 +504,7 @@ class ManageSpeaker(TestCase): def test_readd_last_speaker_no_admin(self): self.util_add_user_as_last_speaker() - self.revoke_admin_rights() + self.make_admin_delegate() response = self.client.post( reverse( diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py new file mode 100644 index 000000000..f2116ddec --- /dev/null +++ b/tests/integration/assignments/test_polls.py @@ -0,0 +1,2193 @@ +import random +from decimal import Decimal +from typing import Any + +import pytest +from django.conf import settings +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from openslides.assignments.models import ( + Assignment, + AssignmentOption, + AssignmentPoll, + AssignmentVote, +) +from openslides.poll.models import BasePoll +from openslides.utils.auth import get_group_model +from openslides.utils.autoupdate import inform_changed_data +from tests.common_groups import GROUP_ADMIN_PK, GROUP_DELEGATE_PK +from tests.count_queries import count_queries +from tests.test_case import TestCase + + +@pytest.mark.django_db(transaction=False) +def test_assignment_poll_db_queries(): + """ + Tests that only the following db queries are done: + * 1 request to get the polls, + * 1 request to get all options for all polls, + * 1 request to get all users for all options (candidates), + * 1 request to get all votes for all options, + * 1 request to get all users for all votes, + * 1 request to get all poll groups, + = 6 queries + """ + create_assignment_polls() + assert count_queries(AssignmentPoll.get_elements)() == 6 + + +@pytest.mark.django_db(transaction=False) +def test_assignment_vote_db_queries(): + """ + Tests that only 1 query is done when fetching AssignmentVotes + """ + create_assignment_polls() + assert count_queries(AssignmentVote.get_elements)() == 1 + + +@pytest.mark.django_db(transaction=False) +def test_assignment_option_db_queries(): + """ + Tests that only the following db queries are done: + * 1 request to get the options, + * 1 request to get all votes for all options, + = 2 queries + """ + create_assignment_polls() + assert count_queries(AssignmentOption.get_elements)() == 2 + + +def create_assignment_polls(): + """ + Creates 1 assignment with 3 candidates which has 5 polls in which each candidate got a random amount of votes between 0 and 10 from 3 users + """ + assignment = Assignment(title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1) + assignment.save(skip_autoupdate=True) + + group1 = get_group_model().objects.get(pk=1) + group2 = get_group_model().objects.get(pk=2) + for i in range(3): + user = get_user_model().objects.create_user( + username=f"test_username_{i}", password="test_password_UOrnlCZMD0lmxFGwEj54" + ) + assignment.add_candidate(user) + + for i in range(5): + poll = AssignmentPoll( + assignment=assignment, + title="test_title_UnMiGzEHmwqplmVBPNEZ", + pollmethod=AssignmentPoll.POLLMETHOD_YN, + type=AssignmentPoll.TYPE_NAMED, + ) + poll.save(skip_autoupdate=True) + poll.create_options(skip_autoupdate=True) + poll.groups.add(group1) + poll.groups.add(group2) + + for j in range(3): + user = get_user_model().objects.create_user( + username=f"test_username_{i}{j}", + password="test_password_kbzj5L8ZtVxBllZzoW6D", + ) + poll.voted.add(user) + for option in poll.options.all(): + weight = random.randint(0, 10) + if weight > 0: + AssignmentVote.objects.create( + user=user, option=option, value="Y", weight=Decimal(weight) + ) + + +class CreateAssignmentPoll(TestCase): + def advancedSetUp(self): + self.assignment = Assignment.objects.create( + title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1, + ) + self.assignment.add_candidate(self.admin) + + def test_simple(self): + with self.assertNumQueries(40): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_ailai4toogh3eefaa2Vo", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + self.assertTrue(AssignmentPoll.objects.exists()) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo") + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_YNA) + self.assertEqual(poll.type, "named") + # Check defaults + self.assertTrue(poll.global_no) + self.assertTrue(poll.global_abstain) + self.assertEqual(poll.amount_global_no, None) + self.assertEqual(poll.amount_global_abstain, None) + self.assertFalse(poll.allow_multiple_votes_per_candidate) + self.assertEqual(poll.votes_amount, 1) + self.assertEqual(poll.assignment.id, self.assignment.id) + self.assertEqual(poll.description, "") + self.assertTrue(poll.options.exists()) + option = AssignmentOption.objects.get() + self.assertTrue(option.user.id, self.admin.id) + + def test_all_fields(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_ahThai4pae1pi4xoogoo", + "pollmethod": AssignmentPoll.POLLMETHOD_YN, + "type": "pseudoanonymous", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_THREE_QUARTERS, + "global_no": False, + "global_abstain": False, + "allow_multiple_votes_per_candidate": True, + "votes_amount": 5, + "description": "test_description_ieM8ThuasoSh8aecai8p", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + self.assertTrue(AssignmentPoll.objects.exists()) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.title, "test_title_ahThai4pae1pi4xoogoo") + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_YN) + self.assertEqual(poll.type, "pseudoanonymous") + self.assertFalse(poll.global_no) + self.assertFalse(poll.global_abstain) + self.assertTrue(poll.allow_multiple_votes_per_candidate) + self.assertEqual(poll.votes_amount, 5) + self.assertEqual(poll.description, "test_description_ieM8ThuasoSh8aecai8p") + + def test_no_candidates(self): + self.assignment.remove_candidate(self.admin) + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_eing5eipue5cha2Iefai", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_missing_keys(self): + complete_request_data = { + "title": "test_title_keugh8Iu9ciyooGaevoh", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + } + for key in complete_request_data.keys(): + request_data = { + _key: value + for _key, value in complete_request_data.items() + if _key != key + } + response = self.client.post(reverse("assignmentpoll-list"), request_data) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_with_groups(self): + group1 = get_group_model().objects.get(pk=1) + group2 = get_group_model().objects.get(pk=2) + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + "groups_id": [1, 2], + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertTrue(group1 in poll.groups.all()) + self.assertTrue(group2 in poll.groups.all()) + + def test_with_empty_groups(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + "groups_id": [], + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertFalse(poll.groups.exists()) + + def test_not_supported_type(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_yaiyeighoh0Iraet3Ahc", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "not_existing", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_not_allowed_type(self): + setattr(settings, "ENABLE_ELECTRONIC_VOTING", False) + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_yaiyeighoh0Iraet3Ahc", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": AssignmentPoll.TYPE_NAMED, + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + setattr(settings, "ENABLE_ELECTRONIC_VOTING", True) + + def test_not_supported_pollmethod(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_SeVaiteYeiNgie5Xoov8", + "pollmethod": "not_existing", + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_not_supported_onehundred_percent_base(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": "invalid base", + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_not_supported_majority_method(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": "invalid majority method", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_wrong_pollmethod_onehundred_percent_base_combination_1(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_VOTES, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_YNA) + + def test_wrong_pollmethod_onehundred_percent_base_combination_2(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YN, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_VOTES, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_YN) + + def test_wrong_pollmethod_onehundred_percent_base_combination_3(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual( + poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_VOTES + ) + + def test_create_with_votes(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_dKbv5tV47IzY1oGHXdSz", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "type": AssignmentPoll.TYPE_ANALOG, + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + "votes": { + "options": {"1": {"Y": 1}}, + "votesvalid": "-2", + "votesinvalid": "-2", + "votescast": "-2", + }, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.state, AssignmentPoll.STATE_FINISHED) + self.assertTrue(AssignmentVote.objects.exists()) + + def test_create_with_votes2(self): + user, _ = self.create_user() + self.assignment.add_candidate(user) + self.assignment.remove_candidate(self.admin) + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_dKbv5tV47IzY1oGHXdSz", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "type": AssignmentPoll.TYPE_ANALOG, + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + "votes": { + "options": {"2": {"Y": 1}}, + "votesvalid": "-2", + "votesinvalid": "-2", + "votescast": "-2", + }, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.state, AssignmentPoll.STATE_FINISHED) + self.assertTrue(AssignmentVote.objects.exists()) + + def test_create_with_votes_publish_immediately(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_dKbv5tV47IzY1oGHXdSz", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "type": AssignmentPoll.TYPE_ANALOG, + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + "votes": { + "options": {"1": {"Y": 1}}, + "votesvalid": "-2", + "votesinvalid": "-2", + "votescast": "-2", + }, + "publish_immediately": "1", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.state, AssignmentPoll.STATE_PUBLISHED) + self.assertTrue(AssignmentVote.objects.exists()) + + def test_create_with_invalid_votes(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_dKbv5tV47IzY1oGHXdSz", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "type": AssignmentPoll.TYPE_ANALOG, + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + "votes": { + "options": {"1": {"Y": 1}}, + "votesvalid": "-2", + "votesinvalid": "-2", + }, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_create_with_votes_wrong_type(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_dKbv5tV47IzY1oGHXdSz", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "type": AssignmentPoll.TYPE_NAMED, + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + "votes": { + "options": {"1": {"Y": 1}}, + "votesvalid": "-2", + "votesinvalid": "-2", + "votescast": "-2", + }, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + self.assertFalse(AssignmentVote.objects.exists()) + + +class UpdateAssignmentPoll(TestCase): + """ + Tests updating polls of assignments. + """ + + def advancedSetUp(self): + self.assignment = Assignment.objects.create( + title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1 + ) + self.assignment.add_candidate(self.admin) + self.group = get_group_model().objects.get(pk=1) + self.poll = AssignmentPoll.objects.create( + assignment=self.assignment, + title="test_title_beeFaihuNae1vej2ai8m", + pollmethod=AssignmentPoll.POLLMETHOD_VOTES, + type=BasePoll.TYPE_NAMED, + onehundred_percent_base=AssignmentPoll.PERCENT_BASE_VOTES, + majority_method=AssignmentPoll.MAJORITY_SIMPLE, + ) + self.poll.create_options() + self.poll.groups.add(self.group) + + def test_patch_title(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"title": "test_title_Aishohh1ohd0aiSut7gi"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.title, "test_title_Aishohh1ohd0aiSut7gi") + + def test_prevent_patching_assignment(self): + assignment = Assignment(title="test_title_phohdah8quukooHeetuz", open_posts=1) + assignment.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"assignment_id": assignment.id}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.assignment.id, self.assignment.id) # unchanged + + def test_patch_pollmethod(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"pollmethod": AssignmentPoll.POLLMETHOD_YNA}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_YNA) + self.assertEqual(poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_YNA) + + def test_patch_invalid_pollmethod(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"pollmethod": "invalid"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_VOTES) + + def test_patch_type(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), {"type": "analog"} + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.type, "analog") + + def test_patch_invalid_type(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), {"type": "invalid"} + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.type, "named") + + def test_patch_not_allowed_type(self): + setattr(settings, "ENABLE_ELECTRONIC_VOTING", False) + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"type": BasePoll.TYPE_NAMED}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.type, BasePoll.TYPE_NAMED) + setattr(settings, "ENABLE_ELECTRONIC_VOTING", True) + + def test_patch_groups_to_empty(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), {"groups_id": []}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertFalse(poll.groups.exists()) + + def test_patch_groups(self): + group2 = get_group_model().objects.get(pk=2) + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"groups_id": [group2.id]}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.groups.count(), 1) + self.assertEqual(poll.groups.get(), group2) + + def test_patch_title_started(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"title": "test_title_Oophah8EaLaequu3toh8"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.title, "test_title_Oophah8EaLaequu3toh8") + + def test_patch_wrong_state(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"type": BasePoll.TYPE_NAMED}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.type, BasePoll.TYPE_NAMED) + + def test_patch_100_percent_base(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "cast"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "cast") + + def test_patch_wrong_100_percent_base(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "invalid"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual( + poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_VOTES + ) + + def test_patch_majority_method(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.majority_method, AssignmentPoll.MAJORITY_TWO_THIRDS) + + def test_patch_wrong_majority_method(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"majority_method": "invalid majority method"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.majority_method, AssignmentPoll.MAJORITY_SIMPLE) + + def test_patch_multiple_fields(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + { + "title": "test_title_ees6Tho8ahheen4cieja", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "global_no": True, + "global_abstain": False, + "allow_multiple_votes_per_candidate": True, + "votes_amount": 42, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.title, "test_title_ees6Tho8ahheen4cieja") + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_VOTES) + self.assertTrue(poll.global_no) + self.assertFalse(poll.global_abstain) + self.assertEqual(poll.amount_global_no, Decimal("0")) + self.assertEqual(poll.amount_global_abstain, None) + self.assertTrue(poll.allow_multiple_votes_per_candidate) + self.assertEqual(poll.votes_amount, 42) + + def test_patch_majority_method_state_not_created(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"majority_method": "two_thirds"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.majority_method, "two_thirds") + + def test_patch_100_percent_base_state_not_created(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "cast"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "cast") + + def test_patch_wrong_100_percent_base_state_not_created(self): + self.poll.state = 2 + self.poll.pollmethod = AssignmentPoll.POLLMETHOD_YN + self.poll.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "YN") + + +class VoteAssignmentPollBaseTestClass(TestCase): + def advancedSetUp(self): + self.assignment = Assignment.objects.create( + title="test_assignment_tcLT59bmXrXif424Qw7K", open_posts=1 + ) + self.assignment.add_candidate(self.admin) + self.poll = self.create_poll() + self.admin.is_present = True + self.admin.save() + self.poll.groups.add(GROUP_ADMIN_PK) + self.poll.create_options() + inform_changed_data(self.poll) + + def create_poll(self): + # has to be implemented by subclasses + raise NotImplementedError() + + def start_poll(self): + self.poll.state = AssignmentPoll.STATE_STARTED + self.poll.save() + + def add_candidate(self): + user, _ = self.create_user() + AssignmentOption.objects.create(user=user, poll=self.poll) + + +class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): + def create_poll(self): + return AssignmentPoll.objects.create( + assignment=self.assignment, + title="test_title_04k0y4TwPLpJKaSvIGm1", + pollmethod=AssignmentPoll.POLLMETHOD_YNA, + type=BasePoll.TYPE_ANALOG, + ) + + def test_start_poll(self): + response = self.client.post( + reverse("assignmentpoll-start", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) + self.assertEqual(poll.votesvalid, None) + self.assertEqual(poll.votesinvalid, None) + self.assertEqual(poll.votescast, None) + self.assertFalse(poll.get_votes().exists()) + + def test_stop_poll(self): + self.start_poll() + response = self.client.post(reverse("assignmentpoll-stop", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(self.poll.state, AssignmentPoll.STATE_STARTED) + + def test_vote(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + { + "options": { + "1": {"Y": "1", "N": "2.35", "A": "-1"}, + "2": {"Y": "30", "N": "-2", "A": "8.93"}, + }, + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "-2", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertEqual(AssignmentVote.objects.count(), 6) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("4.64")) + self.assertEqual(poll.votesinvalid, Decimal("-2")) + self.assertEqual(poll.votescast, Decimal("-2")) + self.assertEqual(poll.state, AssignmentPoll.STATE_FINISHED) + option1 = poll.options.get(pk=1) + option2 = poll.options.get(pk=2) + self.assertEqual(option1.yes, Decimal("1")) + self.assertEqual(option1.no, Decimal("2.35")) + self.assertEqual(option1.abstain, Decimal("-1")) + self.assertEqual(option2.yes, Decimal("30")) + self.assertEqual(option2.no, Decimal("-2")) + self.assertEqual(option2.abstain, Decimal("8.93")) + + def test_vote_fractional_negative_values(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + { + "options": {"1": {"Y": "1", "N": "1", "A": "1"}}, + "votesvalid": "-1.5", + "votesinvalid": "-2", + "votescast": "-2", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_too_many_options(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + { + "options": { + "1": {"Y": "1", "N": "2.35", "A": "-1"}, + "2": {"Y": "1", "N": "2.35", "A": "-1"}, + } + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_too_few_options(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"options": {"1": {"Y": "1", "N": "2.35", "A": "-1"}}}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_options(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + { + "options": { + "1": {"Y": "1", "N": "2.35", "A": "-1"}, + "3": {"Y": "1", "N": "2.35", "A": "-1"}, + } + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_no_permissions(self): + self.start_poll() + self.make_admin_delegate() + response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_state(self): + response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_missing_data(self): + self.start_poll() + response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_data_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), [1, 2, 5], + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_option_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"options": [1, "string"]}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_option_id_type(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"options": {"string": "some_other_string"}}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_vote_data(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"options": {"1": [None]}}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_missing_vote_value(self): + self.start_poll() + for value in "YNA": + data = {"options": {"1": {"Y": "1", "N": "3", "A": "-1"}}} + del data["options"]["1"][value] + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), data + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_vote_state_finished(self): + self.start_poll() + self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + { + "options": {"1": {"Y": 5, "N": 0, "A": 1}}, + "votesvalid": "-2", + "votesinvalid": "1", + "votescast": "-1", + }, + ) + self.poll.state = 3 + self.poll.save() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + { + "options": {"1": {"Y": 2, "N": 2, "A": 2}}, + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "3", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("4.64")) + self.assertEqual(poll.votesinvalid, Decimal("-2")) + self.assertEqual(poll.votescast, Decimal("3")) + self.assertEqual(poll.get_votes().count(), 3) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("2")) + self.assertEqual(option.no, Decimal("2")) + self.assertEqual(option.abstain, Decimal("2")) + + +class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): + def create_poll(self): + return AssignmentPoll.objects.create( + assignment=self.assignment, + title="test_title_OkHAIvOSIcpFnCxbaL6v", + pollmethod=AssignmentPoll.POLLMETHOD_YNA, + type=BasePoll.TYPE_NAMED, + ) + + def test_start_poll(self): + response = self.client.post( + reverse("assignmentpoll-start", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) + self.assertEqual(poll.votesvalid, Decimal("0")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("0")) + self.assertFalse(poll.get_votes().exists()) + + def test_vote(self): + self.add_candidate() + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y", "2": "N", "3": "A"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertEqual(AssignmentVote.objects.count(), 3) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("1")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("1")) + self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) + option1 = poll.options.get(pk=1) + option2 = poll.options.get(pk=2) + option3 = poll.options.get(pk=3) + self.assertEqual(option1.yes, Decimal("1")) + self.assertEqual(option1.no, Decimal("0")) + self.assertEqual(option1.abstain, Decimal("0")) + self.assertEqual(option2.yes, Decimal("0")) + self.assertEqual(option2.no, Decimal("1")) + self.assertEqual(option2.abstain, Decimal("0")) + self.assertEqual(option3.yes, Decimal("0")) + self.assertEqual(option3.no, Decimal("0")) + self.assertEqual(option3.abstain, Decimal("1")) + + def test_change_vote(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "N"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(AssignmentVote.objects.count(), 1) + vote = AssignmentVote.objects.get() + self.assertEqual(vote.value, "Y") + + def test_too_many_options(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y", "2": "N"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_partial_vote(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertTrue(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_options(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y", "3": "N"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_no_permissions(self): + self.start_poll() + self.make_admin_delegate() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_anonymous(self): + self.start_poll() + gclient = self.create_guest_client() + response = gclient.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_vote_not_present(self): + self.start_poll() + self.admin.is_present = False + self.admin.save() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_state(self): + response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_missing_data(self): + self.start_poll() + response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose( + response, status.HTTP_200_OK + ) # new "feature" because of partial requests: empty requests work! + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_data_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + [1, 2, 5], + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_option_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "string"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_option_id_type(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"id": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_vote_data(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": [None]}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + +class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): + def create_poll(self): + return AssignmentPoll.objects.create( + assignment=self.assignment, + title="test_title_Zrvh146QAdq7t6iSDwZk", + pollmethod=AssignmentPoll.POLLMETHOD_VOTES, + type=BasePoll.TYPE_NAMED, + ) + + def setup_for_multiple_votes(self): + self.poll.allow_multiple_votes_per_candidate = True + self.poll.votes_amount = 3 + self.poll.save() + self.add_candidate() + + def test_start_poll(self): + response = self.client.post( + reverse("assignmentpoll-start", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) + self.assertEqual(poll.votesvalid, Decimal("0")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("0")) + self.assertFalse(poll.get_votes().exists()) + + def test_vote(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 1, "2": 0}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertEqual(AssignmentVote.objects.count(), 1) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("1")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("1")) + self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) + option1 = poll.options.get(pk=1) + option2 = poll.options.get(pk=2) + self.assertEqual(option1.yes, Decimal("1")) + self.assertEqual(option1.no, Decimal("0")) + self.assertEqual(option1.abstain, Decimal("0")) + self.assertEqual(option2.yes, Decimal("0")) + self.assertEqual(option2.no, Decimal("0")) + self.assertEqual(option2.abstain, Decimal("0")) + + def test_change_vote(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 1, "2": 0}, + format="json", + ) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 0, "2": 1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + option1 = poll.options.get(pk=1) + option2 = poll.options.get(pk=2) + self.assertEqual(option1.yes, Decimal("1")) + self.assertEqual(option1.no, Decimal("0")) + self.assertEqual(option1.abstain, Decimal("0")) + self.assertEqual(option2.yes, Decimal("0")) + self.assertEqual(option2.no, Decimal("0")) + self.assertEqual(option2.abstain, Decimal("0")) + + def test_global_no(self): + self.poll.votes_amount = 2 + self.poll.save() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), "N" + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + option = poll.options.get(pk=1) + self.assertEqual(option.yes, Decimal("0")) + self.assertEqual(option.no, Decimal("1")) + self.assertEqual(option.abstain, Decimal("0")) + self.assertEqual(poll.amount_global_no, Decimal("1")) + self.assertEqual(poll.amount_global_abstain, Decimal("0")) + + def test_global_no_forbidden(self): + self.poll.global_no = False + self.poll.save() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), "N" + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + self.assertEqual(AssignmentPoll.objects.get().amount_global_no, None) + + def test_global_abstain(self): + self.poll.votes_amount = 2 + self.poll.save() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), "A" + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + option = poll.options.get(pk=1) + self.assertEqual(option.yes, Decimal("0")) + self.assertEqual(option.no, Decimal("0")) + self.assertEqual(option.abstain, Decimal("1")) + self.assertEqual(poll.amount_global_no, Decimal("0")) + self.assertEqual(poll.amount_global_abstain, Decimal("1")) + + def test_global_abstain_forbidden(self): + self.poll.global_abstain = False + self.poll.save() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), "A" + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + self.assertEqual(AssignmentPoll.objects.get().amount_global_abstain, None) + + def test_negative_vote(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": -1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_multiple_votes(self): + self.setup_for_multiple_votes() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 2, "2": 1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + option1 = poll.options.get(pk=1) + option2 = poll.options.get(pk=2) + self.assertEqual(option1.yes, Decimal("2")) + self.assertEqual(option1.no, Decimal("0")) + self.assertEqual(option1.abstain, Decimal("0")) + self.assertEqual(option2.yes, Decimal("1")) + self.assertEqual(option2.no, Decimal("0")) + self.assertEqual(option2.abstain, Decimal("0")) + + def test_multiple_votes_wrong_amount(self): + self.setup_for_multiple_votes() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 2, "2": 2}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_too_many_options(self): + self.setup_for_multiple_votes() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 1, "2": 1, "3": 1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_options(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_no_permissions(self): + self.start_poll() + self.make_admin_delegate() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_anonymous(self): + self.start_poll() + gclient = self.create_guest_client() + response = gclient.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_vote_not_present(self): + self.start_poll() + self.admin.is_present = False + self.admin.save() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_state(self): + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_missing_data(self): + self.start_poll() + response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_data_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + [1, 2, 5], + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_option_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "string"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_option_id_type(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"id": 1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_vote_data(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": [None]}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + +class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): + def create_poll(self): + return AssignmentPoll.objects.create( + assignment=self.assignment, + title="test_title_OkHAIvOSIcpFnCxbaL6v", + pollmethod=AssignmentPoll.POLLMETHOD_YNA, + type=BasePoll.TYPE_PSEUDOANONYMOUS, + ) + + def test_start_poll(self): + response = self.client.post( + reverse("assignmentpoll-start", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) + self.assertEqual(poll.votesvalid, Decimal("0")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("0")) + self.assertFalse(poll.get_votes().exists()) + + def test_vote(self): + self.add_candidate() + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y", "2": "N", "3": "A"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertEqual(AssignmentVote.objects.count(), 3) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("1")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("1")) + self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) + option1 = poll.options.get(pk=1) + option2 = poll.options.get(pk=2) + option3 = poll.options.get(pk=3) + self.assertEqual(option1.yes, Decimal("1")) + self.assertEqual(option1.no, Decimal("0")) + self.assertEqual(option1.abstain, Decimal("0")) + self.assertEqual(option2.yes, Decimal("0")) + self.assertEqual(option2.no, Decimal("1")) + self.assertEqual(option2.abstain, Decimal("0")) + self.assertEqual(option3.yes, Decimal("0")) + self.assertEqual(option3.no, Decimal("0")) + self.assertEqual(option3.abstain, Decimal("1")) + + def test_change_vote(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "N"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + option1 = poll.options.get(pk=1) + self.assertEqual(option1.yes, Decimal("1")) + self.assertEqual(option1.no, Decimal("0")) + self.assertEqual(option1.abstain, Decimal("0")) + + def test_too_many_options(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y", "2": "N"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_partial_vote(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertTrue(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_options(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y", "3": "N"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_no_permissions(self): + self.start_poll() + self.make_admin_delegate() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_anonymous(self): + self.start_poll() + gclient = self.create_guest_client() + response = gclient.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_vote_not_present(self): + self.start_poll() + self.admin.is_present = False + self.admin.save() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_state(self): + response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_missing_data(self): + self.start_poll() + response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_data_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + [1, 2, 5], + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_option_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "string"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_option_id_type(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"id": "Y"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_vote_data(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": [None]}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + +class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): + def create_poll(self): + return AssignmentPoll.objects.create( + assignment=self.assignment, + title="test_title_Zrvh146QAdq7t6iSDwZk", + pollmethod=AssignmentPoll.POLLMETHOD_VOTES, + type=BasePoll.TYPE_PSEUDOANONYMOUS, + ) + + def setup_for_multiple_votes(self): + self.poll.allow_multiple_votes_per_candidate = True + self.poll.votes_amount = 3 + self.poll.save() + self.add_candidate() + + def test_start_poll(self): + response = self.client.post( + reverse("assignmentpoll-start", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) + self.assertEqual(poll.votesvalid, Decimal("0")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("0")) + self.assertFalse(poll.get_votes().exists()) + + def test_vote(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 1, "2": 0}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertEqual(AssignmentVote.objects.count(), 1) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("1")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("1")) + self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) + option1 = poll.options.get(pk=1) + option2 = poll.options.get(pk=2) + self.assertEqual(option1.yes, Decimal("1")) + self.assertEqual(option1.no, Decimal("0")) + self.assertEqual(option1.abstain, Decimal("0")) + self.assertEqual(option2.yes, Decimal("0")) + self.assertEqual(option2.no, Decimal("0")) + self.assertEqual(option2.abstain, Decimal("0")) + + def test_change_vote(self): + self.add_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 1, "2": 0}, + format="json", + ) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 0, "2": 1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + option1 = poll.options.get(pk=1) + option2 = poll.options.get(pk=2) + self.assertEqual(option1.yes, Decimal("1")) + self.assertEqual(option1.no, Decimal("0")) + self.assertEqual(option1.abstain, Decimal("0")) + self.assertEqual(option2.yes, Decimal("0")) + self.assertEqual(option2.no, Decimal("0")) + self.assertEqual(option2.abstain, Decimal("0")) + + def test_negative_vote(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": -1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_multiple_votes(self): + self.setup_for_multiple_votes() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 2, "2": 1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + option1 = poll.options.get(pk=1) + option2 = poll.options.get(pk=2) + self.assertEqual(option1.yes, Decimal("2")) + self.assertEqual(option1.no, Decimal("0")) + self.assertEqual(option1.abstain, Decimal("0")) + self.assertEqual(option2.yes, Decimal("1")) + self.assertEqual(option2.no, Decimal("0")) + self.assertEqual(option2.abstain, Decimal("0")) + + def test_multiple_votes_wrong_amount(self): + self.setup_for_multiple_votes() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 2, "2": 2}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_too_many_options(self): + self.setup_for_multiple_votes() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 1, "2": 1, "3": 1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_options(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_no_permissions(self): + self.start_poll() + self.make_admin_delegate() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_anonymous(self): + self.start_poll() + gclient = self.create_guest_client() + response = gclient.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_vote_not_present(self): + self.start_poll() + self.admin.is_present = False + self.admin.save() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_state(self): + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_missing_data(self): + self.start_poll() + response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_data_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + [1, 2, 5], + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_option_format(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": "string"}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_option_id_type(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"id": 1}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_wrong_vote_data(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": [None]}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) + + +# test autoupdates +class VoteAssignmentPollAutoupdatesBaseClass(TestCase): + poll_type = "" # set by subclass, defines which poll type we use + + """ + 3 important users: + self.admin: manager, has can_see, can_manage, can_manage_polls (in admin group) + self.user: votes, has can_see perms and in in delegate group + self.other_user: Just has can_see perms and is NOT in the delegate group. + """ + + def advancedSetUp(self): + self.delegate_group = get_group_model().objects.get(pk=GROUP_DELEGATE_PK) + self.other_user, _ = self.create_user() + inform_changed_data(self.other_user) + + self.user, user_password = self.create_user() + self.user.groups.add(self.delegate_group) + self.user.is_present = True + self.user.save() + self.user_client = APIClient() + self.user_client.login(username=self.user.username, password=user_password) + + self.assignment = Assignment.objects.create( + title="test_assignment_" + self._get_random_string(), open_posts=1 + ) + self.assignment.add_candidate(self.admin) + self.description = "test_description_paiquei5ahpie1wu8ohW" + self.poll = AssignmentPoll.objects.create( + assignment=self.assignment, + title="test_title_" + self._get_random_string(), + pollmethod=AssignmentPoll.POLLMETHOD_YNA, + type=self.poll_type, + state=AssignmentPoll.STATE_STARTED, + onehundred_percent_base=AssignmentPoll.PERCENT_BASE_CAST, + majority_method=AssignmentPoll.MAJORITY_TWO_THIRDS, + description=self.description, + ) + self.poll.create_options() + self.poll.groups.add(self.delegate_group) + + +class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass): + poll_type = AssignmentPoll.TYPE_NAMED + + def test_vote(self): + response = self.user_client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": "A"} + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + vote = AssignmentVote.objects.get() + + # Expect the admin to see the full data in the autoupdate + autoupdate = self.get_last_autoupdate(user=self.admin) + self.assertEqual( + autoupdate[0], + { + "assignments/assignment-poll:1": { + "allow_multiple_votes_per_candidate": False, + "assignment_id": 1, + "global_abstain": True, + "global_no": True, + "amount_global_abstain": None, + "amount_global_no": None, + "groups_id": [GROUP_DELEGATE_PK], + "id": 1, + "options_id": [1], + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "state": AssignmentPoll.STATE_STARTED, + "title": self.poll.title, + "description": self.description, + "type": AssignmentPoll.TYPE_NAMED, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, + "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, + "votes_amount": 1, + "votescast": "1.000000", + "votesinvalid": "0.000000", + "votesvalid": "1.000000", + "user_has_voted": False, + "voted_id": [self.user.id], + }, + "assignments/assignment-option:1": { + "abstain": "1.000000", + "id": 1, + "no": "0.000000", + "poll_id": 1, + "pollstate": AssignmentPoll.STATE_STARTED, + "yes": "0.000000", + "user_id": 1, + "weight": 1, + }, + "assignments/assignment-vote:1": { + "id": 1, + "option_id": 1, + "pollstate": AssignmentPoll.STATE_STARTED, + "user_id": self.user.id, + "value": "A", + "weight": "1.000000", + }, + }, + ) + self.assertEqual(autoupdate[1], []) + + # Expect user to receive his vote + autoupdate = self.get_last_autoupdate(user=self.user) + self.assertEqual( + autoupdate[0]["assignments/assignment-vote:1"], + { + "id": 1, + "option_id": 1, + "pollstate": AssignmentPoll.STATE_STARTED, + "user_id": self.user.id, + "value": "A", + "weight": "1.000000", + }, + ) + self.assertEqual(autoupdate[1], []) + + # Expect non-admins to get a restricted poll update + for user in (self.user, self.other_user): + self.assertAutoupdate(poll, user=user) + autoupdate = self.get_last_autoupdate(user=user) + self.assertEqual( + autoupdate[0]["assignments/assignment-poll:1"], + { + "allow_multiple_votes_per_candidate": False, + "assignment_id": 1, + "global_abstain": True, + "global_no": True, + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "state": AssignmentPoll.STATE_STARTED, + "type": AssignmentPoll.TYPE_NAMED, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, + "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, + "title": self.poll.title, + "description": self.description, + "groups_id": [GROUP_DELEGATE_PK], + "options_id": [1], + "id": 1, + "votes_amount": 1, + "user_has_voted": user == self.user, + }, + ) + + # Other users should not get a vote autoupdate + self.assertNoAutoupdate(vote, user=self.other_user) + self.assertNoDeletedAutoupdate(vote, user=self.other_user) + + def test_publish(self): + option = self.poll.options.get() + vote = AssignmentVote.objects.create(user=self.user, option=option) + vote.value = "A" + vote.weight = Decimal("1") + vote.save(no_delete_on_restriction=True, skip_autoupdate=True) + self.poll.voted.add(self.user.id) + self.poll.state = AssignmentPoll.STATE_FINISHED + self.poll.save(skip_autoupdate=True) + response = self.client.post( + reverse("assignmentpoll-publish", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + vote = AssignmentVote.objects.get() + + # Everyone should get the whole data + for user in ( + self.admin, + self.user, + self.other_user, + ): + self.assertAutoupdate(poll, user=user) + autoupdate = self.get_last_autoupdate(user=user) + self.assertEqual( + autoupdate[0], + { + "assignments/assignment-poll:1": { + "allow_multiple_votes_per_candidate": False, + "amount_global_abstain": None, + "amount_global_no": None, + "assignment_id": 1, + "description": "test_description_paiquei5ahpie1wu8ohW", + "global_abstain": True, + "global_no": True, + "groups_id": [GROUP_DELEGATE_PK], + "id": 1, + "majority_method": "two_thirds", + "onehundred_percent_base": "cast", + "options_id": [1], + "pollmethod": "YNA", + "state": 4, + "title": self.poll.title, + "type": "named", + "votes_amount": 1, + "votescast": "1.000000", + "votesinvalid": "0.000000", + "votesvalid": "1.000000", + "user_has_voted": user == self.user, + "voted_id": [self.user.id], + }, + "assignments/assignment-vote:1": { + "pollstate": AssignmentPoll.STATE_PUBLISHED, + "id": 1, + "weight": "1.000000", + "value": "A", + "user_id": 3, + "option_id": 1, + }, + "assignments/assignment-option:1": { + "abstain": "1.000000", + "id": 1, + "no": "0.000000", + "poll_id": 1, + "pollstate": AssignmentPoll.STATE_PUBLISHED, + "yes": "0.000000", + "user_id": 1, + "weight": 1, + }, + }, + ) + + +class VoteAssignmentPollPseudoanonymousAutoupdates( + VoteAssignmentPollAutoupdatesBaseClass +): + poll_type = AssignmentPoll.TYPE_PSEUDOANONYMOUS + + def test_vote(self): + response = self.user_client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": "A"} + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + vote = AssignmentVote.objects.get() + + # Expect the admin to see the full data in the autoupdate + autoupdate = self.get_last_autoupdate(user=self.admin) + # TODO: mypy complains without the Any type; check why and fix it + should_be: Any = { + "assignments/assignment-poll:1": { + "allow_multiple_votes_per_candidate": False, + "assignment_id": 1, + "global_abstain": True, + "global_no": True, + "amount_global_abstain": None, + "amount_global_no": None, + "groups_id": [GROUP_DELEGATE_PK], + "id": 1, + "options_id": [1], + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "state": AssignmentPoll.STATE_STARTED, + "title": self.poll.title, + "description": self.description, + "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, + "user_has_voted": False, + "voted_id": [self.user.id], + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, + "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, + "votes_amount": 1, + "votescast": "1.000000", + "votesinvalid": "0.000000", + "votesvalid": "1.000000", + }, + "assignments/assignment-option:1": { + "abstain": "1.000000", + "id": 1, + "no": "0.000000", + "poll_id": 1, + "pollstate": AssignmentPoll.STATE_STARTED, + "yes": "0.000000", + "user_id": 1, + "weight": 1, + }, + "assignments/assignment-vote:1": { + "id": 1, + "option_id": 1, + "pollstate": AssignmentPoll.STATE_STARTED, + "user_id": None, + "value": "A", + "weight": "1.000000", + }, + } + self.assertEqual(autoupdate[0], should_be) + self.assertEqual(autoupdate[1], []) + + # Expect non-admins to get a restricted poll update and no autoupdate + # for a changed vote nor a deleted one + for user in (self.user, self.other_user): + self.assertAutoupdate(poll, user=user) + autoupdate = self.get_last_autoupdate(user=user) + self.assertEqual( + autoupdate[0]["assignments/assignment-poll:1"], + { + "allow_multiple_votes_per_candidate": False, + "assignment_id": 1, + "global_abstain": True, + "global_no": True, + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "state": AssignmentPoll.STATE_STARTED, + "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, + "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, + "title": self.poll.title, + "description": self.description, + "groups_id": [GROUP_DELEGATE_PK], + "options_id": [1], + "id": 1, + "votes_amount": 1, + "user_has_voted": user == self.user, + }, + ) + + self.assertNoAutoupdate(vote, user=user) + self.assertNoDeletedAutoupdate(vote, user=user) + + def test_publish(self): + option = self.poll.options.get() + vote = AssignmentVote.objects.create(option=option) + vote.value = "A" + vote.weight = Decimal("1") + vote.save(no_delete_on_restriction=True, skip_autoupdate=True) + self.poll.voted.add(self.user.id) + self.poll.state = AssignmentPoll.STATE_FINISHED + self.poll.save(skip_autoupdate=True) + response = self.client.post( + reverse("assignmentpoll-publish", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + vote = AssignmentVote.objects.get() + + # Everyone should get the whole data + for user in ( + self.admin, + self.user, + self.other_user, + ): + self.assertAutoupdate(poll, user=user) + autoupdate = self.get_last_autoupdate(user=user) + self.assertEqual( + autoupdate[0], + { + "assignments/assignment-poll:1": { + "allow_multiple_votes_per_candidate": False, + "amount_global_abstain": None, + "amount_global_no": None, + "assignment_id": 1, + "description": "test_description_paiquei5ahpie1wu8ohW", + "global_abstain": True, + "global_no": True, + "groups_id": [GROUP_DELEGATE_PK], + "id": 1, + "majority_method": "two_thirds", + "onehundred_percent_base": "cast", + "options_id": [1], + "pollmethod": "YNA", + "state": 4, + "title": self.poll.title, + "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, + "votes_amount": 1, + "votescast": "1.000000", + "votesinvalid": "0.000000", + "votesvalid": "1.000000", + "user_has_voted": user == self.user, + "voted_id": [self.user.id], + }, + "assignments/assignment-vote:1": { + "pollstate": AssignmentPoll.STATE_PUBLISHED, + "id": 1, + "weight": "1.000000", + "value": "A", + "user_id": None, + "option_id": 1, + }, + "assignments/assignment-option:1": { + "abstain": "1.000000", + "id": 1, + "no": "0.000000", + "poll_id": 1, + "pollstate": AssignmentPoll.STATE_PUBLISHED, + "yes": "0.000000", + "user_id": 1, + "weight": 1, + }, + }, + ) diff --git a/tests/integration/assignments/test_viewset.py b/tests/integration/assignments/test_viewset.py index 7862cdb18..71579a7a1 100644 --- a/tests/integration/assignments/test_viewset.py +++ b/tests/integration/assignments/test_viewset.py @@ -5,13 +5,12 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from openslides.assignments.models import Assignment +from openslides.assignments.models import Assignment, AssignmentPoll from openslides.core.models import Tag from openslides.mediafiles.models import Mediafile from openslides.utils.autoupdate import inform_changed_data -from openslides.utils.test import TestCase - -from ..helpers import count_queries +from tests.count_queries import count_queries +from tests.test_case import TestCase @pytest.mark.django_db(transaction=False) @@ -22,18 +21,22 @@ def test_assignment_db_queries(): * 1 request to get all related users, * 1 request to get the agenda item, * 1 request to get the list of speakers, - * 1 request to get the polls, * 1 request to get the tags, * 1 request to get the attachments and - - * 10 request to fetch each related user again. - - TODO: The last requests are a bug. + * 1 Request to get the polls of the assignment + * 1 Request to get the options of these polls """ for index in range(10): - Assignment.objects.create(title=f"assignment{index}", open_posts=1) + assignment = Assignment.objects.create(title=f"assignment{index}", open_posts=1) + for i in range(2): + AssignmentPoll.objects.create( + assignment=assignment, + title="test_title_nah5Ahh6IkeeM8rah3ai", + pollmethod=AssignmentPoll.POLLMETHOD_YN, + type=AssignmentPoll.TYPE_NAMED, + ) - assert count_queries(Assignment.get_elements) == 17 + assert count_queries(Assignment.get_elements)() == 8 class CreateAssignment(TestCase): @@ -41,10 +44,6 @@ class CreateAssignment(TestCase): Tests basic creation of assignments. """ - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - def test_simple(self): response = self.client.post( reverse("assignment-list"), @@ -53,6 +52,7 @@ class CreateAssignment(TestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) assignment = Assignment.objects.get() self.assertEqual(assignment.title, "test_title_ef3jpF)M329f30m)f82") + self.assertEqual(assignment.number_poll_candidates, False) def test_with_tags_and_mediafiles(self): Tag.objects.create(name="test_tag") @@ -74,8 +74,21 @@ class CreateAssignment(TestCase): self.assertTrue(assignment.tags.exists()) self.assertTrue(assignment.attachments.exists()) + def test_number_poll_candidates(self): + response = self.client.post( + reverse("assignment-list"), + { + "title": "test_title_EFBhGQkQciwZtjSc7BVy", + "open_posts": 1, + "number_poll_candidates": True, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + assignment = Assignment.objects.get() + self.assertEqual(assignment.number_poll_candidates, True) -class CanidatureSelf(TestCase): + +class CandidatureSelf(TestCase): """ Tests self candidation view. """ @@ -99,7 +112,7 @@ class CanidatureSelf(TestCase): ) def test_nominate_self_twice(self): - self.assignment.set_candidate(get_user_model().objects.get(username="admin")) + self.assignment.add_candidate(get_user_model().objects.get(username="admin")) response = self.client.post( reverse("assignment-candidature-self", args=[self.assignment.pk]) @@ -152,7 +165,7 @@ class CanidatureSelf(TestCase): self.assertEqual(response.status_code, 403) def test_withdraw_self(self): - self.assignment.set_candidate(get_user_model().objects.get(username="admin")) + self.assignment.add_candidate(get_user_model().objects.get(username="admin")) response = self.client.delete( reverse("assignment-candidature-self", args=[self.assignment.pk]) @@ -173,7 +186,7 @@ class CanidatureSelf(TestCase): self.assertEqual(response.status_code, 400) def test_withdraw_self_when_finished(self): - self.assignment.set_candidate(get_user_model().objects.get(username="admin")) + self.assignment.add_candidate(get_user_model().objects.get(username="admin")) self.assignment.set_phase(Assignment.PHASE_FINISHED) self.assignment.save() @@ -184,7 +197,7 @@ class CanidatureSelf(TestCase): self.assertEqual(response.status_code, 400) def test_withdraw_self_during_voting(self): - self.assignment.set_candidate(get_user_model().objects.get(username="admin")) + self.assignment.add_candidate(get_user_model().objects.get(username="admin")) self.assignment.set_phase(Assignment.PHASE_VOTING) self.assignment.save() @@ -198,7 +211,7 @@ class CanidatureSelf(TestCase): ) def test_withdraw_self_during_voting_non_admin(self): - self.assignment.set_candidate(get_user_model().objects.get(username="admin")) + self.assignment.add_candidate(get_user_model().objects.get(username="admin")) self.assignment.set_phase(Assignment.PHASE_VOTING) self.assignment.save() admin = get_user_model().objects.get(username="admin") @@ -267,7 +280,7 @@ class CandidatureOther(TestCase): ) def test_nominate_other_twice(self): - self.assignment.set_candidate( + self.assignment.add_candidate( get_user_model().objects.get(username="test_user_eeheekai4Phue6cahtho") ) response = self.client.post( @@ -321,7 +334,7 @@ class CandidatureOther(TestCase): self.assertEqual(response.status_code, 403) def test_delete_other(self): - self.assignment.set_candidate(self.user) + self.assignment.add_candidate(self.user) response = self.client.delete( reverse("assignment-candidature-other", args=[self.assignment.pk]), {"user": self.user.pk}, @@ -343,7 +356,7 @@ class CandidatureOther(TestCase): self.assertEqual(response.status_code, 400) def test_delete_other_when_finished(self): - self.assignment.set_candidate(self.user) + self.assignment.add_candidate(self.user) self.assignment.set_phase(Assignment.PHASE_FINISHED) self.assignment.save() @@ -355,7 +368,7 @@ class CandidatureOther(TestCase): self.assertEqual(response.status_code, 400) def test_delete_other_during_voting(self): - self.assignment.set_candidate(self.user) + self.assignment.add_candidate(self.user) self.assignment.set_phase(Assignment.PHASE_VOTING) self.assignment.save() @@ -372,7 +385,7 @@ class CandidatureOther(TestCase): ) def test_delete_other_during_voting_non_admin(self): - self.assignment.set_candidate(self.user) + self.assignment.add_candidate(self.user) self.assignment.set_phase(Assignment.PHASE_VOTING) self.assignment.save() admin = get_user_model().objects.get(username="admin") @@ -388,95 +401,3 @@ class CandidatureOther(TestCase): ) self.assertEqual(response.status_code, 403) - - -class MarkElectedOtherUser(TestCase): - """ - Tests marking an elected user. We use an extra user here to show that - admin can not only mark himself but also other users. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - self.assignment = Assignment.objects.create( - title="test_assignment_Ierohsh8rahshofiejai", open_posts=1 - ) - self.user = get_user_model().objects.create_user( - username="test_user_Oonei3rahji5jugh1eev", - password="test_password_aiphahb5Nah0cie4iP7o", - ) - - def test_mark_elected(self): - self.assignment.set_candidate( - get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev") - ) - response = self.client.post( - reverse("assignment-mark-elected", args=[self.assignment.pk]), - {"user": self.user.pk}, - ) - - self.assertEqual(response.status_code, 200) - self.assertTrue( - Assignment.objects.get(pk=self.assignment.pk) - .elected.filter(username="test_user_Oonei3rahji5jugh1eev") - .exists() - ) - - def test_mark_unelected(self): - user = get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev") - self.assignment.set_elected(user) - response = self.client.delete( - reverse("assignment-mark-elected", args=[self.assignment.pk]), - {"user": self.user.pk}, - ) - - self.assertEqual(response.status_code, 200) - self.assertFalse( - Assignment.objects.get(pk=self.assignment.pk) - .elected.filter(username="test_user_Oonei3rahji5jugh1eev") - .exists() - ) - - -class UpdateAssignmentPoll(TestCase): - """ - Tests updating polls of assignments. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - self.assignment = Assignment.objects.create( - title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1 - ) - self.assignment.set_candidate(get_user_model().objects.get(username="admin")) - self.poll = self.assignment.create_poll() - - def test_invalid_votesvalid_value(self): - response = self.client.put( - reverse("assignmentpoll-detail", args=[self.poll.pk]), - {"assignment_id": self.assignment.pk, "votesvalid": "-3"}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_invalid_votesinvalid_value(self): - response = self.client.put( - reverse("assignmentpoll-detail", args=[self.poll.pk]), - {"assignment_id": self.assignment.pk, "votesinvalid": "-3"}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_invalid_votescast_value(self): - response = self.client.put( - reverse("assignmentpoll-detail", args=[self.poll.pk]), - {"assignment_id": self.assignment.pk, "votescast": "-3"}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_empty_value_for_votesvalid(self): - response = self.client.put( - reverse("assignmentpoll-detail", args=[self.poll.pk]), - {"assignment_id": self.assignment.pk, "votesvalid": ""}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/tests/integration/core/test_views.py b/tests/integration/core/test_views.py index b6c982907..c8af38d08 100644 --- a/tests/integration/core/test_views.py +++ b/tests/integration/core/test_views.py @@ -9,7 +9,7 @@ from openslides import __license__ as license, __url__ as url, __version__ as ve from openslides.core.config import ConfigVariable, config from openslides.core.models import Projector from openslides.utils.rest_api import ValidationError -from openslides.utils.test import TestCase +from tests.test_case import TestCase @pytest.mark.django_db(transaction=False) diff --git a/tests/integration/core/test_viewset.py b/tests/integration/core/test_viewset.py index a8fe1d537..500066713 100644 --- a/tests/integration/core/test_viewset.py +++ b/tests/integration/core/test_viewset.py @@ -13,10 +13,9 @@ 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 - -from ..helpers import count_queries +from tests.count_queries import count_queries +from tests.test_case import TestCase @pytest.mark.django_db(transaction=False) @@ -29,7 +28,7 @@ def test_projector_db_queries(): for index in range(10): Projector.objects.create(name=f"Projector{index}") - assert count_queries(Projector.get_elements) == 2 + assert count_queries(Projector.get_elements)() == 2 @pytest.mark.django_db(transaction=False) @@ -41,7 +40,7 @@ def test_tag_db_queries(): for index in range(10): Tag.objects.create(name=f"tag{index}") - assert count_queries(Tag.get_elements) == 1 + assert count_queries(Tag.get_elements)() == 1 @pytest.mark.django_db(transaction=False) @@ -52,7 +51,7 @@ def test_config_db_queries(): """ config.save_default_values() - assert count_queries(Tag.get_elements) == 1 + assert count_queries(Tag.get_elements)() == 1 class ProjectorViewSet(TestCase): @@ -107,7 +106,6 @@ class Projection(TestCase): response = self.client.post( reverse("projector-project", args=[self.projector.pk]), {"elements": elements}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.projector = Projector.objects.get(pk=1) @@ -117,9 +115,7 @@ class Projection(TestCase): def test_add_element_without_name(self): response = self.client.post( - reverse("projector-project", args=[self.projector.pk]), - {"elements": [{}]}, - format="json", + reverse("projector-project", args=[self.projector.pk]), {"elements": [{}]} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.projector = Projector.objects.get(pk=1) @@ -134,7 +130,7 @@ class Projection(TestCase): inform_changed_data(admin) response = self.client.post( - reverse("projector-project", args=[self.projector.pk]), {}, format="json" + reverse("projector-project", args=[self.projector.pk]), {} ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -142,9 +138,7 @@ class Projection(TestCase): self.projector.elements = [{"name": "core/clock"}] self.projector.save() response = self.client.post( - reverse("projector-project", args=[self.projector.pk]), - {"elements": []}, - format="json", + reverse("projector-project", args=[self.projector.pk]), {"elements": []} ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.projector = Projector.objects.get(pk=1) @@ -157,7 +151,6 @@ class Projection(TestCase): response = self.client.post( reverse("projector-project", args=[self.projector.pk]), {"append_to_history": element}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.projector = Projector.objects.get(pk=1) @@ -173,7 +166,6 @@ class Projection(TestCase): response = self.client.post( reverse("projector-project", args=[self.projector.pk]), {"delete_last_history_element": True}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.projector = Projector.objects.get(pk=1) @@ -186,7 +178,6 @@ class Projection(TestCase): response = self.client.post( reverse("projector-project", args=[self.projector.pk]), {"preview": elements}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.projector = Projector.objects.get(pk=1) @@ -265,7 +256,6 @@ class ConfigViewSet(TestCase): 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) @@ -277,7 +267,6 @@ class ConfigViewSet(TestCase): 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) @@ -316,9 +305,7 @@ class ConfigViewSet(TestCase): 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", + reverse("config-detail", args=[self.logo_config_key]), {"value": value} ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(config[self.logo_config_key], value) @@ -332,7 +319,6 @@ class ConfigViewSet(TestCase): {"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"], {}) @@ -345,7 +331,6 @@ class ConfigViewSet(TestCase): 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") @@ -355,7 +340,6 @@ class ConfigViewSet(TestCase): 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") @@ -363,16 +347,14 @@ class ConfigViewSet(TestCase): 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" + reverse("config-bulk-update"), [{"value": string_value}] ) 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", + reverse("config-bulk-update"), [{"key": self.string_config_key}] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(config[self.string_config_key], "OpenSlides") @@ -384,7 +366,7 @@ class ConfigViewSet(TestCase): "motions_preamble" ] = "test_preamble_2390jvwohjwo1oigefoq" # Group motions response = self.client.post( - reverse("config-reset-groups"), ["General", "Agenda"], format="json" + reverse("config-reset-groups"), ["General", "Agenda"] ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(config["general_event_name"], "OpenSlides") @@ -394,15 +376,11 @@ class ConfigViewSet(TestCase): ) def test_reset_group_wrong_format_1(self): - response = self.client.post( - reverse("config-reset-groups"), {"wrong": "format"}, format="json" - ) + response = self.client.post(reverse("config-reset-groups"), {"wrong": "format"}) 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", + reverse("config-reset-groups"), ["some_string", {"wrong": "format"}] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index d961f9a73..f83011d6e 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1,8 +1,5 @@ from typing import Any, Dict, List -from django.db import DEFAULT_DB_ALIAS, connections -from django.test.utils import CaptureQueriesContext - from openslides.core.config import config from openslides.core.models import Projector from openslides.users.models import User @@ -118,20 +115,6 @@ register_projector_slide("test/slide1", slide1) register_projector_slide("test/slide2", slide2) -def count_queries(func, *args, **kwargs) -> int: - context = CaptureQueriesContext(connections[DEFAULT_DB_ALIAS]) - with context: - func(*args, **kwargs) - - queries = "\n".join( - f"{i}. {query['sql']}" - for i, query in enumerate(context.captured_queries, start=1) - ) - - print(f"{len(context)} queries executed\nCaptured queries were:\n{queries}") - return len(context) - - def all_data_config() -> AllData: return { TConfig().get_collection_string(): { diff --git a/tests/integration/mediafiles/test_viewset.py b/tests/integration/mediafiles/test_viewset.py index 5620986a7..100bd0946 100644 --- a/tests/integration/mediafiles/test_viewset.py +++ b/tests/integration/mediafiles/test_viewset.py @@ -7,9 +7,8 @@ from rest_framework import status from rest_framework.test import APIClient from openslides.mediafiles.models import Mediafile -from openslides.utils.test import TestCase - -from ..helpers import count_queries +from tests.count_queries import count_queries +from tests.test_case import TestCase @pytest.mark.django_db(transaction=False) @@ -28,7 +27,7 @@ def test_mediafiles_db_queries(): mediafile=SimpleUploadedFile(f"some_file{index}", b"some content."), ) - assert count_queries(Mediafile.get_elements) == 4 + assert count_queries(Mediafile.get_elements)() == 4 class TestCreation(TestCase): @@ -41,6 +40,7 @@ class TestCreation(TestCase): response = self.client.post( reverse("mediafile-list"), {"title": "test_title_ahyo1uifoo9Aiph2av5a", "mediafile": self.file}, + format="multipart", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) mediafile = Mediafile.objects.get() @@ -70,6 +70,7 @@ class TestCreation(TestCase): "is_directory": True, "mediafile": self.file, }, + format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(Mediafile.objects.exists()) @@ -79,6 +80,7 @@ class TestCreation(TestCase): response = self.client.post( reverse("mediafile-list"), {"title": "test_title_vai8oDogohheideedie4", "mediafile": file}, + format="multipart", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) mediafile = Mediafile.objects.get() @@ -90,6 +92,7 @@ class TestCreation(TestCase): response = self.client.post( reverse("mediafile-list"), {"title": "test_title_Zeicheipeequie3ohfid", "mediafile": file1}, + format="multipart", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) mediafile = Mediafile.objects.get() @@ -98,6 +101,7 @@ class TestCreation(TestCase): response = self.client.post( reverse("mediafile-list"), {"title": "test_title_aiChaetohs0quicee9eb", "mediafile": file2}, + format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Mediafile.objects.count(), 1) @@ -170,8 +174,8 @@ class TestCreation(TestCase): reverse("mediafile-list"), { "title": "test_title_dggjwevBnUngelkdviom", - "mediafile": self.file, - "access_groups_id": json.dumps([2, 4]), + "is_directory": True, + "access_groups_id": [2, 4], }, ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -180,8 +184,9 @@ class TestCreation(TestCase): self.assertEqual( sorted([group.id for group in mediafile.access_groups.all()]), [2, 4] ) - self.assertTrue(mediafile.mediafile.name) - self.assertEqual(mediafile.path, mediafile.original_filename) + self.assertEqual(mediafile.mediafile.name, "") + self.assertEqual(mediafile.original_filename, "") + self.assertEqual(mediafile.path, mediafile.title + "/") def test_with_access_groups_wrong_json(self): response = self.client.post( @@ -268,7 +273,6 @@ class TestUpdate(TestCase): response = self.client.put( reverse("mediafile-detail", args=[self.mediafileA.pk]), {"title": self.mediafileA.title, "parent_id": None}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) mediafile = Mediafile.objects.get(pk=self.mediafileA.pk) diff --git a/tests/integration/motions/test_motions.py b/tests/integration/motions/test_motions.py new file mode 100644 index 000000000..79f430e0d --- /dev/null +++ b/tests/integration/motions/test_motions.py @@ -0,0 +1,1023 @@ +import json + +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from openslides.core.config import config +from openslides.core.models import Tag +from openslides.motions.models import ( + Category, + Motion, + MotionBlock, + MotionChangeRecommendation, + MotionComment, + MotionCommentSection, + MotionPoll, + Submitter, + Workflow, +) +from openslides.poll.models import BasePoll +from openslides.utils.auth import get_group_model +from openslides.utils.autoupdate import inform_changed_data +from tests.common_groups import GROUP_ADMIN_PK, GROUP_DEFAULT_PK, GROUP_DELEGATE_PK +from tests.count_queries import count_queries +from tests.test_case import TestCase + + +@pytest.mark.django_db(transaction=False) +def test_motion_db_queries(): + """ + Tests that only the following db queries are done: + * 1 requests to get the list of all motions, + * 1 request to get the associated workflow + * 1 request for all motion comments + * 1 request for all motion comment sections required for the comments + * 1 request for all users required for the read_groups of the sections + * 1 request to get the agenda item, + * 1 request to get the list of speakers, + * 1 request to get the attachments, + * 1 request to get the tags, + * 2 requests to get the submitters and supporters, + * 1 request for change_recommendations. + + Two comment sections are created and for each motions two comments. + """ + section1 = MotionCommentSection.objects.create(name="test_section") + section2 = MotionCommentSection.objects.create(name="test_section") + + user1 = get_user_model().objects.create_user( + username="test_username_Iena7vahyaiphaangeaV", + password="test_password_oomie4jahNgook1ooDee", + ) + user2 = get_user_model().objects.create_user( + username="test_username_ohj4eiN3ejali9ahng6e", + password="test_password_Coo3ong1cheeveiD3sho", + ) + user3 = get_user_model().objects.create_user( + username="test_username_oe2Yei9Tho8see1Reija", + password="test_password_faij5aeBingaec5Jeila", + ) + + for index in range(10): + motion = Motion.objects.create(title=f"motion{index}") + + motion.supporters.add(user1, user2) + Submitter.objects.add(user2, motion) + Submitter.objects.add(user3, motion) + + MotionComment.objects.create( + comment="test_comment", motion=motion, section=section1 + ) + MotionComment.objects.create( + comment="test_comment2", motion=motion, section=section2 + ) + + block = MotionBlock.objects.create(title=f"block_{index}") + motion.motion_block = block + category = Category.objects.create(name=f"category_{index}") + motion.category = category + motion.save() + + # Create a poll: + poll = MotionPoll.objects.create( + motion=motion, + title="test_title_XeejaeFez3chahpei9qu", + pollmethod="YNA", + type=BasePoll.TYPE_NAMED, + ) + poll.create_options() + + assert count_queries(Motion.get_elements)() == 12 + + +class CreateMotion(TestCase): + """ + Tests motion creation. + """ + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + + def test_simple(self): + """ + Tests that a motion is created with a specific title and text. + + The created motion should have an identifier and the admin user should + be the submitter. + """ + with self.assertNumQueries(51): + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_OoCoo3MeiT9li5Iengu9", + "text": "test_text_thuoz0iecheiheereiCi", + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + changed_autoupdate, deleted_autoupdate = self.get_last_autoupdate() + del changed_autoupdate["motions/motion:1"]["created"] + del changed_autoupdate["motions/motion:1"]["last_modified"] + self.assertEqual( + changed_autoupdate, + { + "agenda/list-of-speakers:1": { + "id": 1, + "title_information": { + "title": "test_title_OoCoo3MeiT9li5Iengu9", + "identifier": "1", + }, + "speakers": [], + "closed": False, + "content_object": {"collection": "motions/motion", "id": 1}, + }, + "motions/motion:1": { + "id": 1, + "identifier": "1", + "title": "test_title_OoCoo3MeiT9li5Iengu9", + "text": "test_text_thuoz0iecheiheereiCi", + "amendment_paragraphs": None, + "modified_final_version": "", + "reason": "", + "parent_id": None, + "category_id": None, + "category_weight": 10000, + "comments": [], + "motion_block_id": None, + "origin": "", + "submitters": [ + {"id": 1, "user_id": 1, "motion_id": 1, "weight": 1} + ], + "supporters_id": [], + "state_id": 1, + "state_extension": None, + "state_restriction": [], + "statute_paragraph_id": None, + "workflow_id": 1, + "recommendation_id": None, + "recommendation_extension": None, + "tags_id": [], + "attachments_id": [], + "agenda_item_id": 1, + "list_of_speakers_id": 1, + "sort_parent_id": None, + "weight": 10000, + "change_recommendations_id": [], + }, + "agenda/item:1": { + "id": 1, + "item_number": "", + "title_information": { + "title": "test_title_OoCoo3MeiT9li5Iengu9", + "identifier": "1", + }, + "comment": None, + "closed": False, + "type": 3, + "is_internal": False, + "is_hidden": True, + "duration": None, + "content_object": {"collection": "motions/motion", "id": 1}, + "weight": 10000, + "parent_id": None, + "level": 0, + }, + }, + ) + self.assertEqual(deleted_autoupdate, []) + self.assertEqual(motion.title, "test_title_OoCoo3MeiT9li5Iengu9") + self.assertEqual(motion.identifier, "1") + self.assertTrue(motion.submitters.exists()) + self.assertEqual(motion.submitters.get().user.username, "admin") + + def test_with_reason(self): + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_saib4hiHaifo9ohp9yie", + "text": "test_text_shahhie8Ej4mohvoorie", + "reason": "test_reason_Ou8GivahYivoh3phoh9c", + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + Motion.objects.get().reason, "test_reason_Ou8GivahYivoh3phoh9c" + ) + + def test_without_data(self): + response = self.client.post(reverse("motion-list"), {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertTrue("title" in response.data) + + def test_without_text(self): + response = self.client.post( + reverse("motion-list"), {"title": "test_title_dlofp23m9O(ZD2d1lwHG"} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + str(response.data["detail"][0]), "The text field may not be blank." + ) + + def test_with_category(self): + category = Category.objects.create( + name="test_category_name_CiengahzooH4ohxietha", + prefix="TEST_PREFIX_la0eadaewuec3seoxeiN", + ) + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_Air0bahchaiph1ietoo2", + "text": "test_text_chaeF9wosh8OowazaiVu", + "category_id": category.pk, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual(motion.category, category) + self.assertEqual(motion.identifier, "TEST_PREFIX_la0eadaewuec3seoxeiN1") + + def test_with_submitters(self): + submitter_1 = get_user_model().objects.create_user( + username="test_username_ooFe6aebei9ieQui2poo", + password="test_password_vie9saiQu5Aengoo9ku0", + ) + submitter_2 = get_user_model().objects.create_user( + username="test_username_eeciengoc4aihie5eeSh", + password="test_password_peik2Eihu5oTh7siequi", + ) + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_pha7moPh7quoth4paina", + "text": "test_text_YooGhae6tiangung5Rie", + "submitters_id": [submitter_1.pk, submitter_2.pk], + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual(motion.submitters.count(), 2) + + def test_with_one_supporter(self): + supporter = get_user_model().objects.create_user( + username="test_username_ahGhi4Quohyee7ohngie", + password="test_password_Nei6aeh8OhY8Aegh1ohX", + ) + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_Oecee4Da2Mu9EY6Ui4mu", + "text": "test_text_FbhgnTFgkbjdmvcjbffg", + "supporters_id": [supporter.pk], + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual( + motion.supporters.get().username, "test_username_ahGhi4Quohyee7ohngie" + ) + + def test_with_tag(self): + tag = Tag.objects.create(name="test_tag_iRee3kiecoos4rorohth") + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_Hahke4loos4eiduNiid9", + "text": "test_text_johcho0Ucaibiehieghe", + "tags_id": [tag.pk], + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual(motion.tags.get().name, "test_tag_iRee3kiecoos4rorohth") + + def test_with_workflow(self): + """ + Test to create a motion with a specific workflow. + """ + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_eemuR5hoo4ru2ahgh5EJ", + "text": "test_text_ohviePopahPhoili7yee", + "workflow_id": "2", + }, + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Motion.objects.get().state.workflow_id, 2) + + def test_non_admin(self): + """ + Test to create a motion by a delegate, non staff user. + """ + self.admin = get_user_model().objects.get(username="admin") + self.admin.groups.add(GROUP_DELEGATE_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) + + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_peiJozae0luew9EeL8bo", + "text": "test_text_eHohS8ohr5ahshoah8Oh", + }, + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_amendment_motion(self): + """ + Test to create a motion with a parent motion as staff user. + """ + parent_motion = self.create_parent_motion() + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_doe93Jsjd2sW20dkSl20", + "text": "test_text_feS20SksD8D25skmwD25", + "parent_id": parent_motion.id, + }, + ) + created_motion = Motion.objects.get(pk=int(response.data["id"])) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(created_motion.parent, parent_motion) + + def test_amendment_motion_parent_not_exist(self): + """ + Test to create an amendment motion with a non existing parent. + """ + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_gEjdkW93Wj23KS2s8dSe", + "text": "test_text_lfwLIC&AjfsaoijOEusa", + "parent_id": 100, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {"detail": "The parent motion does not exist."}) + + def test_amendment_motion_non_admin(self): + """ + Test to create an amendment motion by a delegate. The parents + category should be also set on the new motion. + """ + parent_motion = self.create_parent_motion() + category = Category.objects.create( + name="test_category_name_Dslk3Fj8s8Ps36S3Kskw", + prefix="TEST_PREFIX_L23skfmlq3kslamslS39", + ) + parent_motion.category = category + parent_motion.save() + + self.admin = get_user_model().objects.get(username="admin") + self.admin.groups.add(GROUP_DELEGATE_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) + + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_fk3a0slalms47KSewnWG", + "text": "test_text_al3FMwSCNM31WOmw9ezx", + "parent_id": parent_motion.id, + }, + ) + created_motion = Motion.objects.get(pk=int(response.data["id"])) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(created_motion.parent, parent_motion) + self.assertEqual(created_motion.category, category) + + def create_parent_motion(self): + """ + Returns a new created motion used for testing amendments. + """ + response = self.client.post( + reverse("motion-list"), + { + "title": "test_title_3leoeo2qac7830c92j9s", + "text": "test_text_9dm3ks9gDuW20Al38L9w", + }, + ) + return Motion.objects.get(pk=int(response.data["id"])) + + +class RetrieveMotion(TestCase): + """ + Tests retrieving a motion + """ + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_uj5eeSiedohSh3ohyaaj", + text="test_text_ithohchaeThohmae5aug", + ) + self.motion.save() + for index in range(10): + get_user_model().objects.create_user( + username=f"user_{index}", password="password" + ) + + def test_guest_state_with_restriction(self): + config["general_system_enable_anonymous"] = True + guest_client = APIClient() + state = self.motion.state + state.restriction = ["motions.can_manage"] + state.save() + # The cache has to be cleared, see: + # https://github.com/OpenSlides/OpenSlides/issues/3396 + inform_changed_data(self.motion) + + response = guest_client.get(reverse("motion-detail", args=[self.motion.pk])) + self.assertEqual(response.status_code, 404) + + def test_admin_state_with_restriction(self): + state = self.motion.state + state.restriction = ["motions.can_manage"] + state.save() + response = self.client.get(reverse("motion-detail", args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_submitter_state_with_restriction(self): + state = self.motion.state + state.restriction = ["is_submitter"] + state.save() + user = get_user_model().objects.create_user( + username="username_ohS2opheikaSa5theijo", + password="password_kau4eequaisheeBateef", + ) + Submitter.objects.add(user, self.motion) + submitter_client = APIClient() + submitter_client.force_login(user) + response = submitter_client.get(reverse("motion-detail", args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_user_without_can_see_user_permission_to_see_motion_and_submitter_data( + self, + ): + admin = get_user_model().objects.get(username="admin") + Submitter.objects.add(admin, self.motion) + group = get_group_model().objects.get( + pk=GROUP_DEFAULT_PK + ) # Group with pk 1 is for anonymous and default users. + permission_string = "users.can_see_name" + app_label, codename = permission_string.split(".") + permission = group.permissions.get( + content_type__app_label=app_label, codename=codename + ) + group.permissions.remove(permission) + config["general_system_enable_anonymous"] = True + guest_client = APIClient() + inform_changed_data(group) + inform_changed_data(self.motion) + + response_1 = guest_client.get(reverse("motion-detail", args=[self.motion.pk])) + self.assertEqual(response_1.status_code, status.HTTP_200_OK) + submitter_id = response_1.data["submitters"][0]["user_id"] + response_2 = guest_client.get(reverse("user-detail", args=[submitter_id])) + self.assertEqual(response_2.status_code, status.HTTP_200_OK) + + extra_user = get_user_model().objects.create_user( + username="username_wequePhieFoom0hai3wa", + password="password_ooth7taechai5Oocieya", + ) + + response_3 = guest_client.get(reverse("user-detail", args=[extra_user.pk])) + self.assertEqual(response_3.status_code, 404) + + +class UpdateMotion(TestCase): + """ + Tests updating motions. + """ + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_aeng7ahChie3waiR8xoh", + text="test_text_xeigheeha7thopubeu4U", + ) + self.motion.save() + + def test_simple_patch(self): + response = self.client.patch( + reverse("motion-detail", args=[self.motion.pk]), + {"identifier": "test_identifier_jieseghohj7OoSah1Ko9"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + motion = Motion.objects.get() + self.assertAutoupdate(motion) + self.assertAutoupdate(motion.agenda_item) + self.assertAutoupdate(motion.list_of_speakers) + self.assertEqual(motion.title, "test_title_aeng7ahChie3waiR8xoh") + self.assertEqual(motion.identifier, "test_identifier_jieseghohj7OoSah1Ko9") + + def test_patch_as_anonymous_without_manage_perms(self): + config["general_system_enable_anonymous"] = True + guest_client = APIClient() + response = guest_client.patch( + reverse("motion-detail", args=[self.motion.pk]), + {"identifier": "test_identifier_4g2jgj1wrnmvvIRhtqqPO84WD"}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + motion = Motion.objects.get() + self.assertEqual(motion.identifier, "1") + + def test_patch_empty_text(self): + response = self.client.patch( + reverse("motion-detail", args=[self.motion.pk]), {"text": ""} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + motion = Motion.objects.get() + self.assertEqual(motion.text, "test_text_xeigheeha7thopubeu4U") + + def test_patch_amendment_paragraphs_no_manage_perms(self): + admin = get_user_model().objects.get(username="admin") + admin.groups.remove(GROUP_ADMIN_PK) + admin.groups.add(GROUP_DELEGATE_PK) + Submitter.objects.add(admin, self.motion) + self.motion.state.allow_submitter_edit = True + self.motion.state.save() + inform_changed_data(admin) + + response = self.client.patch( + reverse("motion-detail", args=[self.motion.pk]), + {"amendment_paragraphs": ["test_paragraph_39fo8qcpcaFMmjfaD2Lb"]}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + motion = Motion.objects.get() + self.assertTrue(isinstance(motion.amendment_paragraphs, list)) + self.assertEqual(len(motion.amendment_paragraphs), 1) + self.assertEqual( + motion.amendment_paragraphs[0], "test_paragraph_39fo8qcpcaFMmjfaD2Lb" + ) + self.assertEqual(motion.text, "") + + def test_patch_workflow(self): + """ + Tests to only update the workflow of a motion. + """ + response = self.client.patch( + reverse("motion-detail", args=[self.motion.pk]), {"workflow_id": "2"} + ) + + motion = Motion.objects.get() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(motion.title, "test_title_aeng7ahChie3waiR8xoh") + self.assertEqual(motion.workflow_id, 2) + + def test_patch_category(self): + """ + Tests to only update the category of a motion. Expects the + category_weight to be resetted. + """ + category = Category.objects.create( + name="test_category_name_FE3jO(Fm83doqqlwcvlv", + prefix="test_prefix_w3ofg2mv79UGFqjk3f8h", + ) + self.motion.category_weight = 1 + self.motion.save() + response = self.client.patch( + reverse("motion-detail", args=[self.motion.pk]), + {"category_id": category.pk}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + motion = Motion.objects.get() + self.assertEqual(motion.category, category) + self.assertEqual(motion.category_weight, 10000) + + def test_patch_supporters(self): + supporter = get_user_model().objects.create_user( + username="test_username_ieB9eicah0uqu6Phoovo", + password="test_password_XaeTe3aesh8ohg6Cohwo", + ) + response = self.client.patch( + reverse("motion-detail", args=[self.motion.pk]), + json.dumps({"supporters_id": [supporter.pk]}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + motion = Motion.objects.get() + self.assertEqual(motion.title, "test_title_aeng7ahChie3waiR8xoh") + self.assertEqual( + motion.supporters.get().username, "test_username_ieB9eicah0uqu6Phoovo" + ) + + def test_patch_supporters_non_manager(self): + non_admin = get_user_model().objects.create_user( + username="test_username_uqu6PhoovieB9eicah0o", + password="test_password_Xaesh8ohg6CoheTe3awo", + ) + self.client.login( + username="test_username_uqu6PhoovieB9eicah0o", + password="test_password_Xaesh8ohg6CoheTe3awo", + ) + motion = Motion.objects.get() + Submitter.objects.add(non_admin, self.motion) + motion.supporters.clear() + response = self.client.patch( + reverse("motion-detail", args=[self.motion.pk]), + json.dumps({"supporters_id": [1]}), + content_type="application/json", + ) + # Forbidden because of changed workflow state. + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_removal_of_supporters(self): + # No cache used here. + admin = get_user_model().objects.get(username="admin") + group_admin = admin.groups.get(name="Admin") + admin.groups.remove(group_admin) + Submitter.objects.add(admin, self.motion) + supporter = get_user_model().objects.create_user( + username="test_username_ahshi4oZin0OoSh9chee", + password="test_password_Sia8ahgeenixu5cei2Ib", + ) + self.motion.supporters.add(supporter) + config["motions_remove_supporters"] = True + self.assertEqual(self.motion.supporters.count(), 1) + inform_changed_data((admin, self.motion)) + + response = self.client.patch( + reverse("motion-detail", args=[self.motion.pk]), + {"title": "new_title_ohph1aedie5Du8sai2ye"}, + ) + + # Forbidden because of changed workflow state. + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class DeleteMotion(TestCase): + """ + Tests deleting motions. + """ + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.admin = get_user_model().objects.get(username="admin") + self.motion = Motion( + title="test_title_acle3fa93l11lwlkcc31", + text="test_text_f390sjfyycj29ss56sro", + ) + self.motion.save() + + def test_simple_delete(self): + response = self.client.delete(reverse("motion-detail", args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + motions = Motion.objects.count() + self.assertEqual(motions, 0) + + def make_admin_delegate(self): + self.admin.groups.remove(GROUP_ADMIN_PK) + self.admin.groups.add(GROUP_DELEGATE_PK) + inform_changed_data(self.admin) + + def put_motion_in_complex_workflow(self): + workflow = Workflow.objects.get(name="Complex Workflow") + self.motion.reset_state(workflow=workflow) + self.motion.save() + + def test_delete_foreign_motion_as_delegate(self): + self.make_admin_delegate() + self.put_motion_in_complex_workflow() + + response = self.client.delete(reverse("motion-detail", args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_own_motion_as_delegate(self): + self.make_admin_delegate() + self.put_motion_in_complex_workflow() + Submitter.objects.add(self.admin, self.motion) + + response = self.client.delete(reverse("motion-detail", args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + motions = Motion.objects.count() + self.assertEqual(motions, 0) + + def test_delete_with_two_change_recommendations(self): + self.cr1 = MotionChangeRecommendation.objects.create( + motion=self.motion, internal=False, line_from=1, line_to=1 + ) + self.cr2 = MotionChangeRecommendation.objects.create( + motion=self.motion, internal=False, line_from=2, line_to=2 + ) + response = self.client.delete(reverse("motion-detail", args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + motions = Motion.objects.count() + self.assertEqual(motions, 0) + + +class ManageMultipleSubmitters(TestCase): + """ + Tests adding and removing of submitters. + """ + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + + self.admin = get_user_model().objects.get() + self.motion1 = Motion( + title="test_title_SlqfMw(waso0saWMPqcZ", + text="test_text_f30skclqS9wWF=xdfaSL", + ) + self.motion1.save() + self.motion2 = Motion( + title="test_title_f>FLEim38MC2m9PFp2jG", + text="test_text_kg39KFGm,ao)22FK9lLu", + ) + self.motion2.save() + + def test_set_submitters(self): + response = self.client.post( + reverse("motion-manage-multiple-submitters"), + json.dumps( + { + "motions": [ + {"id": self.motion1.id, "submitters": [self.admin.pk]}, + {"id": self.motion2.id, "submitters": [self.admin.pk]}, + ] + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.motion1.submitters.count(), 1) + self.assertEqual(self.motion2.submitters.count(), 1) + self.assertEqual( + self.motion1.submitters.get().user.pk, self.motion2.submitters.get().user.pk + ) + + def test_non_existing_user(self): + response = self.client.post( + reverse("motion-manage-multiple-submitters"), + {"motions": [{"id": self.motion1.id, "submitters": [1337]}]}, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(self.motion1.submitters.count(), 0) + + def test_add_user_no_data(self): + response = self.client.post(reverse("motion-manage-multiple-submitters")) + self.assertEqual(response.status_code, 400) + self.assertEqual(self.motion1.submitters.count(), 0) + self.assertEqual(self.motion2.submitters.count(), 0) + + def test_add_user_invalid_data(self): + response = self.client.post( + reverse("motion-manage-multiple-submitters"), {"motions": ["invalid_str"]} + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(self.motion1.submitters.count(), 0) + self.assertEqual(self.motion2.submitters.count(), 0) + + def test_add_without_permission(self): + admin = get_user_model().objects.get(username="admin") + admin.groups.add(GROUP_DELEGATE_PK) + admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(admin) + + response = self.client.post( + reverse("motion-manage-multiple-submitters"), + {"motions": [{"id": self.motion1.id, "submitters": [self.admin.pk]}]}, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(self.motion1.submitters.count(), 0) + self.assertEqual(self.motion2.submitters.count(), 0) + + +class SupportMotion(TestCase): + """ + Tests supporting a motion. + """ + + def setUp(self): + self.admin = get_user_model().objects.get(username="admin") + self.admin.groups.add(GROUP_DELEGATE_PK) + inform_changed_data(self.admin) + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_chee7ahCha6bingaew4e", + text="test_text_birah1theL9ooseeFaip", + ) + self.motion.save() + + def test_support(self): + config["motions_min_supporters"] = 1 + + response = self.client.post(reverse("motion-support", args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, {"detail": "You have supported this motion successfully."} + ) + + def test_unsupport(self): + config["motions_min_supporters"] = 1 + self.motion.supporters.add(self.admin) + response = self.client.delete(reverse("motion-support", args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, {"detail": "You have unsupported this motion successfully."} + ) + + +class SetState(TestCase): + """ + Tests setting a state. + """ + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_iac4ohquie9Ku6othieC", + text="test_text_Xohphei6Oobee0Evooyu", + ) + self.motion.save() + self.state_id_accepted = 2 # This should be the id of the state 'accepted'. + + def test_set_state(self): + response = self.client.put( + reverse("motion-set-state", args=[self.motion.pk]), + {"state": self.state_id_accepted}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, {"detail": "The state of the motion was set to accepted."} + ) + self.assertEqual(Motion.objects.get(pk=self.motion.pk).state.name, "accepted") + + def test_set_state_with_string(self): + # Using a string is not allowed even if it is the correct name of the state. + response = self.client.put( + reverse("motion-set-state", args=[self.motion.pk]), {"state": "accepted"} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"detail": "Invalid data. State must be an integer."} + ) + + def test_set_unknown_state(self): + invalid_state_id = 0 + response = self.client.put( + reverse("motion-set-state", args=[self.motion.pk]), + {"state": invalid_state_id}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "detail": "You can not set the state to {0}.", + "args": [str(invalid_state_id)], + }, + ) + + def test_reset(self): + self.motion.set_state(self.state_id_accepted) + self.motion.save() + response = self.client.put(reverse("motion-set-state", args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, {"detail": "The state of the motion was set to submitted."} + ) + self.assertEqual(Motion.objects.get(pk=self.motion.pk).state.name, "submitted") + + +class SetRecommendation(TestCase): + """ + Tests setting a recommendation. + """ + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_ahfooT5leilahcohJ2uz", + text="test_text_enoogh7OhPoo6eohoCus", + ) + self.motion.save() + self.state_id_accepted = 2 # This should be the id of the state 'accepted'. + + def test_set_recommendation(self): + response = self.client.put( + reverse("motion-set-recommendation", args=[self.motion.pk]), + {"recommendation": self.state_id_accepted}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + { + "detail": "The recommendation of the motion was set to {0}.", + "args": ["Acceptance"], + }, + ) + self.assertEqual( + Motion.objects.get(pk=self.motion.pk).recommendation.name, "accepted" + ) + + def test_set_state_with_string(self): + # Using a string is not allowed even if it is the correct name of the state. + response = self.client.put( + reverse("motion-set-recommendation", args=[self.motion.pk]), + {"recommendation": "accepted"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + {"detail": "Invalid data. Recommendation must be an integer."}, + ) + + def test_set_unknown_recommendation(self): + invalid_state_id = 0 + response = self.client.put( + reverse("motion-set-recommendation", args=[self.motion.pk]), + {"recommendation": invalid_state_id}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "detail": "You can not set the recommendation to {0}.", + "args": [str(invalid_state_id)], + }, + ) + + def test_set_invalid_recommendation(self): + # This is a valid state id, but this state is not recommendable because it belongs to a different workflow. + invalid_state_id = 6 # State 'permitted' + response = self.client.put( + reverse("motion-set-recommendation", args=[self.motion.pk]), + {"recommendation": invalid_state_id}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "detail": "You can not set the recommendation to {0}.", + "args": [str(invalid_state_id)], + }, + ) + + def test_set_invalid_recommendation_2(self): + # This is a valid state id, but this state is not recommendable because it has not recommendation label + invalid_state_id = 1 # State 'submitted' + self.motion.set_state(self.state_id_accepted) + self.motion.save() + response = self.client.put( + reverse("motion-set-recommendation", args=[self.motion.pk]), + {"recommendation": invalid_state_id}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "detail": "You can not set the recommendation to {0}.", + "args": [str(invalid_state_id)], + }, + ) + + def test_reset(self): + self.motion.set_recommendation(self.state_id_accepted) + self.motion.save() + response = self.client.put( + reverse("motion-set-recommendation", args=[self.motion.pk]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + { + "detail": "The recommendation of the motion was set to {0}.", + "args": ["None"], + }, + ) + self.assertTrue(Motion.objects.get(pk=self.motion.pk).recommendation is None) + + def test_set_recommendation_to_current_state(self): + self.motion.set_state(self.state_id_accepted) + self.motion.save() + response = self.client.put( + reverse("motion-set-recommendation", args=[self.motion.pk]), + {"recommendation": self.state_id_accepted}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + { + "detail": "The recommendation of the motion was set to {0}.", + "args": ["Acceptance"], + }, + ) + self.assertEqual( + Motion.objects.get(pk=self.motion.pk).recommendation.name, "accepted" + ) diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py new file mode 100644 index 000000000..788e2eb6a --- /dev/null +++ b/tests/integration/motions/test_polls.py @@ -0,0 +1,1467 @@ +from decimal import Decimal + +import pytest +from django.conf import settings +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from openslides.core.config import config +from openslides.motions.models import Motion, MotionOption, MotionPoll, MotionVote +from openslides.poll.models import BasePoll +from openslides.utils.auth import get_group_model +from openslides.utils.autoupdate import inform_changed_data +from tests.common_groups import GROUP_ADMIN_PK, GROUP_DEFAULT_PK, GROUP_DELEGATE_PK +from tests.count_queries import count_queries +from tests.test_case import TestCase + + +@pytest.mark.django_db(transaction=False) +def test_motion_poll_db_queries(): + """ + Tests that only the following db queries are done: + * 1 request to get the polls, + * 1 request to get all options for all polls, + * 1 request to get all votes for all options, + * 1 request to get all users for all votes, + * 1 request to get all poll groups, + = 5 queries + """ + create_motion_polls() + assert count_queries(MotionPoll.get_elements)() == 5 + + +@pytest.mark.django_db(transaction=False) +def test_motion_vote_db_queries(): + """ + Tests that only 1 query is done when fetching MotionVotes + """ + create_motion_polls() + assert count_queries(MotionVote.get_elements)() == 1 + + +@pytest.mark.django_db(transaction=False) +def test_motion_option_db_queries(): + """ + Tests that only the following db queries are done: + * 1 request to get the options, + * 1 request to get all votes for all options, + = 2 queries + """ + create_motion_polls() + assert count_queries(MotionOption.get_elements)() == 2 + + +def create_motion_polls(): + """ + Creates 1 Motion with 5 polls with 5 options each which have 2 votes each + """ + motion = Motion.objects.create(title="test_motion_wfLrsjEHXBmPplbvQ65N") + group1 = get_group_model().objects.get(pk=1) + group2 = get_group_model().objects.get(pk=2) + + for index in range(5): + poll = MotionPoll.objects.create( + motion=motion, title=f"test_title_{index}", pollmethod="YN", type="named" + ) + poll.groups.add(group1) + poll.groups.add(group2) + + for j in range(5): + option = MotionOption.objects.create(poll=poll) + + for k in range(2): + user = get_user_model().objects.create_user( + username=f"test_username_{index}{j}{k}", + password="test_password_kbzj5L8ZtVxBllZzoW6D", + ) + MotionVote.objects.create( + user=user, + option=option, + value=("Y" if k == 0 else "N"), + weight=Decimal(1), + ) + poll.voted.add(user) + + +class CreateMotionPoll(TestCase): + """ + Tests creating polls of motions. + """ + + def advancedSetUp(self): + self.motion = Motion( + title="test_title_Aiqueigh2dae9phabiqu", + text="test_text_Neekoh3zou6li5rue8iL", + ) + self.motion.save() + + def test_simple(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_ailai4toogh3eefaa2Vo", + "pollmethod": "YNA", + "type": "named", + "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + self.assertTrue(MotionPoll.objects.exists()) + poll = MotionPoll.objects.get() + self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo") + self.assertEqual(poll.pollmethod, "YNA") + self.assertEqual(poll.type, "named") + self.assertEqual(poll.motion.id, self.motion.id) + self.assertTrue(poll.options.exists()) + + def test_default_method(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_ailai4toogh3eefaa2Vo", + "type": "named", + "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + self.assertTrue(MotionPoll.objects.exists()) + poll = MotionPoll.objects.get() + self.assertEqual(poll.pollmethod, "YNA") + + def test_autoupdate(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_9Ce8OsdB8YWTVm5YOzqH", + "pollmethod": "YNA", + "type": "named", + "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + + autoupdate = self.get_last_autoupdate(user=self.admin) + self.assertEqual( + autoupdate[0]["motions/motion-poll:1"], + { + "motion_id": 1, + "pollmethod": MotionPoll.POLLMETHOD_YNA, + "state": MotionPoll.STATE_CREATED, + "type": MotionPoll.TYPE_NAMED, + "title": "test_title_9Ce8OsdB8YWTVm5YOzqH", + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YN, + "majority_method": MotionPoll.MAJORITY_SIMPLE, + "groups_id": [], + "votesvalid": "0.000000", + "votesinvalid": "0.000000", + "votescast": "0.000000", + "options_id": [1], + "id": 1, + "voted_id": [], + "user_has_voted": False, + }, + ) + self.assertEqual(autoupdate[1], []) + + def test_missing_keys(self): + complete_request_data = { + "title": "test_title_OoCh9aitaeyaeth8nom1", + "type": "named", + "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", + } + for key in complete_request_data.keys(): + request_data = { + _key: value + for _key, value in complete_request_data.items() + if _key != key + } + response = self.client.post(reverse("motionpoll-list"), request_data) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + + def test_with_groups(self): + group1 = get_group_model().objects.get(pk=1) + group2 = get_group_model().objects.get(pk=2) + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": "YNA", + "type": "named", + "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", + "groups_id": [1, 2], + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = MotionPoll.objects.get() + self.assertTrue(group1 in poll.groups.all()) + self.assertTrue(group2 in poll.groups.all()) + + def test_with_empty_groups(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": MotionPoll.POLLMETHOD_YNA, + "type": MotionPoll.TYPE_NAMED, + "motion_id": self.motion.id, + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YN, + "majority_method": MotionPoll.MAJORITY_SIMPLE, + "groups_id": [], + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = MotionPoll.objects.get() + self.assertFalse(poll.groups.exists()) + + def test_not_supported_type(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_yaiyeighoh0Iraet3Ahc", + "pollmethod": MotionPoll.POLLMETHOD_YNA, + "type": "not_existing", + "motion_id": self.motion.id, + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YN, + "majority_method": MotionPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + + def test_not_allowed_type(self): + setattr(settings, "ENABLE_ELECTRONIC_VOTING", False) + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_3jdWIXbKBa7ZXutf3RYf", + "pollmethod": MotionPoll.POLLMETHOD_YN, + "type": MotionPoll.TYPE_NAMED, + "motion_id": self.motion.id, + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YN, + "majority_method": MotionPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + setattr(settings, "ENABLE_ELECTRONIC_VOTING", True) + + def test_not_supported_pollmethod(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_SeVaiteYeiNgie5Xoov8", + "pollmethod": "not_existing", + "type": "named", + "motion_id": self.motion.id, + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YN, + "majority_method": MotionPoll.MAJORITY_SIMPLE, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + + def test_create_with_votes(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_0X5LifVkKiSh8OPGQM8e", + "pollmethod": MotionPoll.POLLMETHOD_YN, + "type": MotionPoll.TYPE_ANALOG, + "motion_id": self.motion.id, + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YNA, + "majority_method": MotionPoll.MAJORITY_SIMPLE, + "votes": { + "Y": 1, + "N": 2, + "votesvalid": "-2", + "votesinvalid": "-2", + "votescast": "-2", + }, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = MotionPoll.objects.get() + self.assertEqual(poll.state, MotionPoll.STATE_FINISHED) + self.assertTrue(MotionVote.objects.exists()) + + def test_create_with_votes_publish_immediately(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_iXhJX0jmNl3Nvadsi8JO", + "pollmethod": MotionPoll.POLLMETHOD_YN, + "type": MotionPoll.TYPE_ANALOG, + "motion_id": self.motion.id, + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YNA, + "majority_method": MotionPoll.MAJORITY_SIMPLE, + "votes": { + "Y": 1, + "N": 2, + "votesvalid": "-2", + "votesinvalid": "-2", + "votescast": "-2", + }, + "publish_immediately": "1", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + poll = MotionPoll.objects.get() + self.assertEqual(poll.state, MotionPoll.STATE_PUBLISHED) + self.assertTrue(MotionVote.objects.exists()) + + def test_create_with_invalid_votes(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_phSl1IALPIoDyM9uI2Kq", + "pollmethod": MotionPoll.POLLMETHOD_YN, + "type": MotionPoll.TYPE_ANALOG, + "motion_id": self.motion.id, + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YNA, + "majority_method": MotionPoll.MAJORITY_SIMPLE, + "votes": {"Y": 1, "N": 2, "votesvalid": "-2", "votesinvalid": "-2"}, + "publish_immediately": "1", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + self.assertFalse(MotionVote.objects.exists()) + + def test_create_with_votes_wrong_type(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_PgvqRIvuKuVImEpQJAMZ", + "pollmethod": MotionPoll.POLLMETHOD_YN, + "type": MotionPoll.TYPE_NAMED, + "motion_id": self.motion.id, + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YNA, + "majority_method": MotionPoll.MAJORITY_SIMPLE, + "votes": {"Y": 1, "N": 2, "votesvalid": "-2", "votesinvalid": "-2"}, + "publish_immediately": "1", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + self.assertFalse(MotionVote.objects.exists()) + + +class UpdateMotionPoll(TestCase): + """ + Tests updating polls of motions. + """ + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_Aiqueigh2dae9phabiqu", + text="test_text_Neekoh3zou6li5rue8iL", + ) + self.motion.save() + self.group = get_group_model().objects.get(pk=1) + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_beeFaihuNae1vej2ai8m", + pollmethod="YNA", + type="named", + onehundred_percent_base="YN", + majority_method="simple", + ) + self.poll.create_options() + self.poll.groups.add(self.group) + + def test_patch_title(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"title": "test_title_Aishohh1ohd0aiSut7gi"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.title, "test_title_Aishohh1ohd0aiSut7gi") + + def test_prevent_patching_motion(self): + motion = Motion( + title="test_title_phohdah8quukooHeetuz", + text="test_text_ue2yeisaech1ahBohhoo", + ) + motion.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), {"motion_id": motion.id} + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.motion.id, self.motion.id) # unchanged + + def test_patch_pollmethod(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), {"pollmethod": "YN"} + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.pollmethod, "YN") + self.assertEqual(poll.onehundred_percent_base, "YN") + + def test_patch_invalid_pollmethod(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), {"pollmethod": "invalid"} + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.pollmethod, "YNA") + + def test_patch_type(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), {"type": "analog"} + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.type, "analog") + + def test_patch_invalid_type(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), {"type": "invalid"} + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.type, "named") + + def test_patch_not_allowed_type(self): + setattr(settings, "ENABLE_ELECTRONIC_VOTING", False) + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"type": BasePoll.TYPE_NAMED}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.type, BasePoll.TYPE_NAMED) + setattr(settings, "ENABLE_ELECTRONIC_VOTING", True) + + def test_patch_100_percent_base(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "cast"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "cast") + + def test_patch_wrong_100_percent_base(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "invalid"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "YN") + + def test_patch_majority_method(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"majority_method": "two_thirds"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.majority_method, "two_thirds") + + def test_patch_wrong_majority_method(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"majority_method": "invalid majority method"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.majority_method, "simple") + + def test_patch_groups_to_empty(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), {"groups_id": []}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertFalse(poll.groups.exists()) + + def test_patch_groups(self): + group2 = get_group_model().objects.get(pk=2) + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"groups_id": [group2.id]}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.groups.count(), 1) + self.assertEqual(poll.groups.get(), group2) + + def test_patch_title_started(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"title": "test_title_1FjLGeQqsi9GgNzPp73S"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.title, "test_title_1FjLGeQqsi9GgNzPp73S") + + def test_patch_wrong_state(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"type": BasePoll.TYPE_NAMED}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.type, BasePoll.TYPE_NAMED) + + def test_patch_majority_method_state_not_created(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"majority_method": "two_thirds"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.majority_method, "two_thirds") + + def test_patch_100_percent_base_state_not_created(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "cast"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "cast") + + def test_patch_wrong_100_percent_base_state_not_created(self): + self.poll.state = 2 + self.poll.pollmethod = MotionPoll.POLLMETHOD_YN + self.poll.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": MotionPoll.PERCENT_BASE_YNA}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "YN") + + +class VoteMotionPollAnalog(TestCase): + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_OoK9IeChe2Jeib9Deeji", + text="test_text_eichui1oobiSeit9aifo", + ) + self.motion.save() + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_tho8PhiePh8upaex6phi", + pollmethod="YNA", + type=BasePoll.TYPE_ANALOG, + ) + self.poll.create_options() + + def start_poll(self): + self.poll.state = MotionPoll.STATE_STARTED + self.poll.save() + + def make_admin_delegate(self): + admin = get_user_model().objects.get(username="admin") + admin.groups.add(GROUP_DELEGATE_PK) + admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(admin) + + def test_start_poll(self): + response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.state, MotionPoll.STATE_STARTED) + self.assertEqual(poll.votesvalid, None) + self.assertEqual(poll.votesinvalid, None) + self.assertEqual(poll.votescast, None) + self.assertFalse(poll.get_votes().exists()) + + def test_stop_poll(self): + self.start_poll() + response = self.client.post(reverse("motionpoll-stop", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(self.poll.state, MotionPoll.STATE_STARTED) + + def test_vote(self): + self.start_poll() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + { + "Y": "1", + "N": "2.35", + "A": "-1", + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "-2", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("4.64")) + self.assertEqual(poll.votesinvalid, Decimal("-2")) + self.assertEqual(poll.votescast, Decimal("-2")) + self.assertEqual(poll.get_votes().count(), 3) + self.assertEqual(poll.state, MotionPoll.STATE_FINISHED) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("1")) + self.assertEqual(option.no, Decimal("2.35")) + self.assertEqual(option.abstain, Decimal("-1")) + self.assertAutoupdate(poll) + + def test_vote_no_permissions(self): + self.start_poll() + self.make_admin_delegate() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_missing_data(self): + self.start_poll() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"Y": "4", "N": "22.6"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_wrong_data_format(self): + self.start_poll() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_wrong_vote_data(self): + self.start_poll() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + {"Y": "some string", "N": "-2", "A": "3"}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_state_finished(self): + self.start_poll() + self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + { + "Y": "3", + "N": "1", + "A": "5", + "votesvalid": "-2", + "votesinvalid": "1", + "votescast": "-1", + }, + ) + self.poll.state = 3 + self.poll.save() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + { + "Y": "1", + "N": "2.35", + "A": "-1", + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "3", + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("4.64")) + self.assertEqual(poll.votesinvalid, Decimal("-2")) + self.assertEqual(poll.votescast, Decimal("3")) + self.assertEqual(poll.get_votes().count(), 3) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("1")) + self.assertEqual(option.no, Decimal("2.35")) + self.assertEqual(option.abstain, Decimal("-1")) + + +class VoteMotionPollNamed(TestCase): + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_OoK9IeChe2Jeib9Deeji", + text="test_text_eichui1oobiSeit9aifo", + ) + self.motion.save() + self.group = get_group_model().objects.get(pk=GROUP_DELEGATE_PK) + self.admin = get_user_model().objects.get(username="admin") + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_tho8PhiePh8upaex6phi", + pollmethod="YNA", + type=BasePoll.TYPE_NAMED, + ) + self.poll.create_options() + self.poll.groups.add(self.group) + + def start_poll(self): + self.poll.state = MotionPoll.STATE_STARTED + self.poll.save() + + def make_admin_delegate(self): + self.admin.groups.add(GROUP_DELEGATE_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) + + def make_admin_present(self): + self.admin.is_present = True + self.admin.save() + + def test_start_poll(self): + response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.state, MotionPoll.STATE_STARTED) + self.assertEqual(poll.votesvalid, Decimal("0")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("0")) + self.assertFalse(poll.get_votes().exists()) + + def test_vote(self): + self.start_poll() + self.make_admin_delegate() + self.make_admin_present() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "N" + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("1")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("1")) + self.assertEqual(poll.get_votes().count(), 1) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("0")) + self.assertEqual(option.no, Decimal("1")) + self.assertEqual(option.abstain, Decimal("0")) + vote = option.votes.get() + self.assertEqual(vote.user, self.admin) + + def test_change_vote(self): + self.start_poll() + self.make_admin_delegate() + self.make_admin_present() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "N" + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "A" + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("1")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("1")) + self.assertEqual(poll.get_votes().count(), 1) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("0")) + self.assertEqual(option.no, Decimal("0")) + self.assertEqual(option.abstain, Decimal("1")) + vote = option.votes.get() + self.assertEqual(vote.user, self.admin) + + def test_vote_anonymous(self): + self.poll.groups.add(GROUP_DEFAULT_PK) + self.start_poll() + config["general_system_enable_anonymous"] = True + guest_client = APIClient() + response = guest_client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "Y" + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + # TODO: Move to unit tests + def test_not_set_vote_values(self): + with self.assertRaises(ValueError): + self.poll.votesvalid = Decimal("1") + with self.assertRaises(ValueError): + self.poll.votesinvalid = Decimal("1") + with self.assertRaises(ValueError): + self.poll.votescast = Decimal("1") + + def test_vote_wrong_state(self): + self.make_admin_present() + self.make_admin_delegate() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_wrong_group(self): + self.start_poll() + self.make_admin_present() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_not_present(self): + self.start_poll() + self.make_admin_delegate() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_missing_data(self): + self.start_poll() + self.make_admin_delegate() + self.make_admin_present() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_wrong_data_format(self): + self.start_poll() + self.make_admin_delegate() + self.make_admin_present() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + +class VoteMotionPollNamedAutoupdates(TestCase): + """ 3 important users: + self.admin: manager, has can_see, can_manage, can_manage_polls (in admin group) + self.user1: votes, has can_see perms and in in delegate group + self.other_user: Just has can_see perms and is NOT in the delegate group. + """ + + def advancedSetUp(self): + self.motion = Motion( + title="test_title_OoK9IeChe2Jeib9Deeji", + text="test_text_eichui1oobiSeit9aifo", + ) + self.motion.save() + self.delegate_group = get_group_model().objects.get(pk=GROUP_DELEGATE_PK) + self.other_user, _ = self.create_user() + inform_changed_data(self.other_user) + + self.user, user_password = self.create_user() + self.user.groups.add(self.delegate_group) + self.user.is_present = True + self.user.save() + self.user_client = APIClient() + self.user_client.login(username=self.user.username, password=user_password) + + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_tho8PhiePh8upaex6phi", + pollmethod="YNA", + type=BasePoll.TYPE_NAMED, + state=MotionPoll.STATE_STARTED, + onehundred_percent_base="YN", + majority_method="simple", + ) + self.poll.create_options() + self.poll.groups.add(self.delegate_group) + + def test_vote(self): + response = self.user_client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "A" + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + vote = MotionVote.objects.get() + + # Expect the admin to see the full data in the autoupdate + autoupdate = self.get_last_autoupdate(user=self.admin) + self.assertEqual( + autoupdate[0], + { + "motions/motion-poll:1": { + "motion_id": 1, + "pollmethod": "YNA", + "state": 2, + "type": "named", + "title": "test_title_tho8PhiePh8upaex6phi", + "onehundred_percent_base": "YN", + "majority_method": "simple", + "groups_id": [GROUP_DELEGATE_PK], + "votesvalid": "1.000000", + "votesinvalid": "0.000000", + "votescast": "1.000000", + "options_id": [1], + "id": 1, + "user_has_voted": False, + "voted_id": [self.user.id], + }, + "motions/motion-vote:1": { + "pollstate": 2, + "id": 1, + "weight": "1.000000", + "value": "A", + "user_id": self.user.id, + "option_id": 1, + }, + "motions/motion-option:1": { + "abstain": "1.000000", + "id": 1, + "no": "0.000000", + "poll_id": 1, + "pollstate": 2, + "yes": "0.000000", + }, + }, + ) + self.assertEqual(autoupdate[1], []) + + # Expect user1 to receive his vote + autoupdate = self.get_last_autoupdate(user=self.user) + self.assertEqual( + autoupdate[0]["motions/motion-vote:1"], + { + "pollstate": 2, + "option_id": 1, + "id": 1, + "weight": "1.000000", + "value": "A", + "user_id": self.user.id, + }, + ) + self.assertEqual( + autoupdate[0]["motions/motion-option:1"], + {"id": 1, "poll_id": 1, "pollstate": 2}, + ) + self.assertEqual(autoupdate[1], []) + + # Expect non-admins to get a restricted poll update + for user in (self.user, self.other_user): + self.assertAutoupdate(poll, user=user) + autoupdate = self.get_last_autoupdate(user=user) + self.assertEqual( + autoupdate[0]["motions/motion-poll:1"], + { + "motion_id": 1, + "pollmethod": "YNA", + "state": 2, + "type": "named", + "title": "test_title_tho8PhiePh8upaex6phi", + "onehundred_percent_base": "YN", + "majority_method": "simple", + "groups_id": [GROUP_DELEGATE_PK], + "options_id": [1], + "id": 1, + "user_has_voted": user == self.user, + }, + ) + self.assertEqual( + autoupdate[0]["motions/motion-option:1"], + { + "id": 1, + "poll_id": 1, + "pollstate": 2, + }, # noqa black and flake are no friends :( + ) + + # Other users should not get a vote autoupdate + self.assertNoAutoupdate(vote, user=self.other_user) + self.assertNoDeletedAutoupdate(vote, user=self.other_user) + + +class VoteMotionPollPseudoanonymousAutoupdates(TestCase): + """ 3 important users: + self.admin: manager, has can_see, can_manage, can_manage_polls (in admin group) + self.user: votes, has can_see perms and in in delegate group + self.other_user: Just has can_see perms and is NOT in the delegate group. + """ + + def advancedSetUp(self): + self.motion = Motion( + title="test_title_OoK9IeChe2Jeib9Deeji", + text="test_text_eichui1oobiSeit9aifo", + ) + self.motion.save() + self.delegate_group = get_group_model().objects.get(pk=GROUP_DELEGATE_PK) + self.other_user, _ = self.create_user() + inform_changed_data(self.other_user) + + self.user, user_password = self.create_user() + self.user.groups.add(self.delegate_group) + self.user.is_present = True + self.user.save() + self.user_client = APIClient() + self.user_client.login(username=self.user.username, password=user_password) + + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_cahP1umooteehah2jeey", + pollmethod="YNA", + type=BasePoll.TYPE_PSEUDOANONYMOUS, + state=MotionPoll.STATE_STARTED, + onehundred_percent_base="YN", + majority_method="simple", + ) + self.poll.create_options() + self.poll.groups.add(self.delegate_group) + + def test_vote(self): + response = self.user_client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "A" + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + vote = MotionVote.objects.get() + + # Expect the admin to see the full data in the autoupdate + autoupdate = self.get_last_autoupdate(user=self.admin) + self.assertEqual( + autoupdate[0], + { + "motions/motion-poll:1": { + "motion_id": 1, + "pollmethod": "YNA", + "state": 2, + "type": "pseudoanonymous", + "title": "test_title_cahP1umooteehah2jeey", + "onehundred_percent_base": "YN", + "majority_method": "simple", + "groups_id": [GROUP_DELEGATE_PK], + "votesvalid": "1.000000", + "votesinvalid": "0.000000", + "votescast": "1.000000", + "options_id": [1], + "id": 1, + "user_has_voted": False, + "voted_id": [self.user.id], + }, + "motions/motion-vote:1": { + "pollstate": 2, + "option_id": 1, + "id": 1, + "weight": "1.000000", + "value": "A", + "user_id": None, + }, + "motions/motion-option:1": { + "abstain": "1.000000", + "id": 1, + "no": "0.000000", + "poll_id": 1, + "pollstate": 2, + "yes": "0.000000", + }, + }, + ) + self.assertEqual(autoupdate[1], []) + + # Expect non-admins to get a restricted poll update and no autoupdate + # for a changed vote nor a deleted one + for user in (self.user, self.other_user): + self.assertAutoupdate(poll, user=user) + autoupdate = self.get_last_autoupdate(user=user) + self.assertEqual( + autoupdate[0]["motions/motion-poll:1"], + { + "motion_id": 1, + "pollmethod": "YNA", + "state": 2, + "type": "pseudoanonymous", + "title": "test_title_cahP1umooteehah2jeey", + "onehundred_percent_base": "YN", + "majority_method": "simple", + "groups_id": [GROUP_DELEGATE_PK], + "options_id": [1], + "id": 1, + "user_has_voted": user == self.user, + }, + ) + + self.assertNoAutoupdate(vote, user=user) + self.assertNoDeletedAutoupdate(vote, user=user) + + +class VoteMotionPollPseudoanonymous(TestCase): + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_Chaebaenges1aebe8iev", + text="test_text_cah2aigh6ahc8OhNguQu", + ) + self.motion.save() + self.group = get_group_model().objects.get(pk=GROUP_DELEGATE_PK) + self.admin = get_user_model().objects.get(username="admin") + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_yohphei9Iegohqu9ki7m", + pollmethod="YNA", + type=BasePoll.TYPE_PSEUDOANONYMOUS, + ) + self.poll.create_options() + self.poll.groups.add(self.group) + + def start_poll(self): + self.poll.state = MotionPoll.STATE_STARTED + self.poll.save() + + def make_admin_delegate(self): + self.admin.groups.add(GROUP_DELEGATE_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) + + def make_admin_present(self): + self.admin.is_present = True + self.admin.save() + + def test_start_poll(self): + response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.state, MotionPoll.STATE_STARTED) + self.assertEqual(poll.votesvalid, Decimal("0")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("0")) + self.assertFalse(poll.get_votes().exists()) + + def test_vote(self): + self.start_poll() + self.make_admin_delegate() + self.make_admin_present() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "N" + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("1")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("1")) + self.assertEqual(poll.get_votes().count(), 1) + self.assertEqual(poll.amount_users_voted(), 1) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("0")) + self.assertEqual(option.no, Decimal("1")) + self.assertEqual(option.abstain, Decimal("0")) + self.assertTrue(self.admin in poll.voted.all()) + vote = option.votes.get() + self.assertEqual(vote.user, None) + + def test_change_vote(self): + self.start_poll() + self.make_admin_delegate() + self.make_admin_present() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "N" + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "A" + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + option = MotionPoll.objects.get().options.get() + self.assertEqual(option.yes, Decimal("0")) + self.assertEqual(option.no, Decimal("1")) + self.assertEqual(option.abstain, Decimal("0")) + vote = option.votes.get() + self.assertEqual(vote.user, None) + + def test_vote_anonymous(self): + self.poll.groups.add(GROUP_DEFAULT_PK) + self.start_poll() + config["general_system_enable_anonymous"] = True + guest_client = APIClient() + response = guest_client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "Y" + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_wrong_state(self): + self.make_admin_present() + self.make_admin_delegate() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_wrong_group(self): + self.start_poll() + self.make_admin_present() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_not_present(self): + self.start_poll() + self.make_admin_delegate() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_missing_data(self): + self.start_poll() + self.make_admin_delegate() + self.make_admin_present() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_wrong_data_format(self): + self.start_poll() + self.make_admin_delegate() + self.make_admin_present() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + +class StopMotionPoll(TestCase): + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_eiri4iipeemaeGhahkae", + text="test_text_eegh7quoochaiNgiyeix", + ) + self.motion.save() + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_Hu9Miebopaighee3EDie", + pollmethod="YNA", + type=BasePoll.TYPE_NAMED, + ) + self.poll.create_options() + + def test_stop_poll(self): + self.poll.state = MotionPoll.STATE_STARTED + self.poll.save() + response = self.client.post(reverse("motionpoll-stop", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertEqual(MotionPoll.objects.get().state, MotionPoll.STATE_FINISHED) + + def test_stop_wrong_state(self): + response = self.client.post(reverse("motionpoll-stop", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(MotionPoll.objects.get().state, MotionPoll.STATE_CREATED) + + +class PublishMotionPoll(TestCase): + def advancedSetUp(self): + self.motion = Motion( + title="test_title_lai8Ho5gai9aijahRasu", + text="test_text_KieGhosh8ahWiguHeu2D", + ) + self.motion.save() + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_Nufae0iew7Iorox2thoo", + pollmethod="YNA", + type=BasePoll.TYPE_PSEUDOANONYMOUS, + onehundred_percent_base="YN", + majority_method="simple", + ) + self.poll.create_options() + option = self.poll.options.get() + self.user, _ = self.create_user() + self.vote = MotionVote.objects.create( + option=option, user=None, weight=Decimal(2), value="N" + ) + + def test_publish_poll(self): + self.poll.state = MotionPoll.STATE_FINISHED + self.poll.save() + response = self.client.post(reverse("motionpoll-publish", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertEqual(MotionPoll.objects.get().state, MotionPoll.STATE_PUBLISHED) + + # Test autoupdates: Every user should get the full data + for user in (self.admin, self.user): + autoupdate = self.get_last_autoupdate(user=user) + self.assertEqual( + autoupdate[0], + { + "motions/motion-poll:1": { + "motion_id": 1, + "pollmethod": "YNA", + "state": 4, + "type": "pseudoanonymous", + "title": "test_title_Nufae0iew7Iorox2thoo", + "onehundred_percent_base": "YN", + "majority_method": "simple", + "groups_id": [], + "votesvalid": "0.000000", + "votesinvalid": "0.000000", + "votescast": "0.000000", + "options_id": [1], + "id": 1, + "user_has_voted": False, + "voted_id": [], + }, + "motions/motion-vote:1": { + "pollstate": 4, + "option_id": 1, + "id": 1, + "weight": "2.000000", + "value": "N", + "user_id": None, + }, + "motions/motion-option:1": { + "abstain": "0.000000", + "id": 1, + "no": "2.000000", + "poll_id": 1, + "pollstate": 4, + "yes": "0.000000", + }, + }, + ) + self.assertEqual(autoupdate[1], []) + + def test_publish_wrong_state(self): + response = self.client.post(reverse("motionpoll-publish", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(MotionPoll.objects.get().state, MotionPoll.STATE_CREATED) + + +class PseudoanonymizeMotionPoll(TestCase): + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.motion = Motion( + title="test_title_lai8Ho5gai9aijahRasu", + text="test_text_KieGhosh8ahWiguHeu2D", + ) + self.motion.save() + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_Nufae0iew7Iorox2thoo", + pollmethod="YNA", + type=BasePoll.TYPE_NAMED, + state=MotionPoll.STATE_FINISHED, + ) + self.poll.create_options() + self.option = self.poll.options.get() + self.user1, _ = self.create_user() + self.vote1 = MotionVote.objects.create( + user=self.user1, option=self.option, value="Y", weight=Decimal(1) + ) + self.poll.voted.add(self.user1) + self.user2, _ = self.create_user() + self.vote2 = MotionVote.objects.create( + user=self.user2, option=self.option, value="N", weight=Decimal(1) + ) + self.poll.voted.add(self.user2) + + def test_pseudoanonymize_poll(self): + response = self.client.post( + reverse("motionpoll-pseudoanonymize", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.get_votes().count(), 2) + self.assertEqual(poll.amount_users_voted(), 2) + self.assertEqual(poll.votesvalid, Decimal("2")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("2")) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("1")) + self.assertEqual(option.no, Decimal("1")) + self.assertEqual(option.abstain, Decimal("0")) + self.assertTrue(self.user1 in poll.voted.all()) + self.assertTrue(self.user2 in poll.voted.all()) + for vote in poll.get_votes().all(): + self.assertTrue(vote.user is None) + + def test_pseudoanonymize_wrong_state(self): + self.poll.state = MotionPoll.STATE_CREATED + self.poll.save() + response = self.client.post( + reverse("motionpoll-pseudoanonymize", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertTrue(poll.get_votes().filter(user=self.user1).exists()) + self.assertTrue(poll.get_votes().filter(user=self.user2).exists()) + + def test_pseudoanonymize_wrong_type(self): + self.poll.type = MotionPoll.TYPE_ANALOG + self.poll.save() + response = self.client.post( + reverse("motionpoll-pseudoanonymize", args=[self.poll.pk]) + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertTrue(poll.get_votes().filter(user=self.user1).exists()) + self.assertTrue(poll.get_votes().filter(user=self.user2).exists()) + + +class ResetMotionPoll(TestCase): + def advancedSetUp(self): + self.motion = Motion( + title="test_title_cheiJ1ieph5ohng9queu", + text="test_text_yahng6fiegaL7mooZ2of", + ) + self.motion.save() + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_oozie2Ui9xie0chaghie", + pollmethod="YNA", + type=BasePoll.TYPE_ANALOG, + state=MotionPoll.STATE_FINISHED, + ) + self.poll.create_options() + self.option = self.poll.options.get() + self.user1, _ = self.create_user() + self.vote1 = MotionVote.objects.create( + user=self.user1, option=self.option, value="Y", weight=Decimal(1) + ) + self.poll.voted.add(self.user1) + self.user2, _ = self.create_user() + self.vote2 = MotionVote.objects.create( + user=self.user2, option=self.option, value="N", weight=Decimal(1) + ) + self.poll.voted.add(self.user2) + + def test_reset_poll(self): + response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.get_votes().count(), 0) + self.assertEqual(poll.amount_users_voted(), 0) + self.assertEqual(poll.votesvalid, None) + self.assertEqual(poll.votesinvalid, None) + self.assertEqual(poll.votescast, None) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("0")) + self.assertEqual(option.no, Decimal("0")) + self.assertEqual(option.abstain, Decimal("0")) + self.assertFalse(option.votes.exists()) + + def test_deleted_autoupdate(self): + response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + option = poll.options.get() + self.assertAutoupdate(option, self.admin) + for user in (self.admin, self.user1, self.user2): + self.assertDeletedAutoupdate(self.vote1, user=user) + self.assertDeletedAutoupdate(self.vote2, user=user) diff --git a/tests/integration/motions/test_views.py b/tests/integration/motions/test_views.py index 9897b11e8..995b86102 100644 --- a/tests/integration/motions/test_views.py +++ b/tests/integration/motions/test_views.py @@ -2,7 +2,7 @@ from django.test.client import Client from openslides.core.config import config from openslides.motions.models import Motion -from openslides.utils.test import TestCase +from tests.test_case import TestCase class AnonymousRequests(TestCase): diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py index e3e48c63c..b311ba86c 100644 --- a/tests/integration/motions/test_viewset.py +++ b/tests/integration/motions/test_viewset.py @@ -1,5 +1,3 @@ -import json - import pytest from django.contrib.auth import get_user_model from django.urls import reverse @@ -7,7 +5,6 @@ from rest_framework import status from rest_framework.test import APIClient from openslides.core.config import config -from openslides.core.models import Tag from openslides.motions.models import ( Category, Motion, @@ -17,60 +14,13 @@ from openslides.motions.models import ( MotionCommentSection, State, StatuteParagraph, - Submitter, Workflow, ) 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_DEFAULT_PK, - GROUP_DELEGATE_PK, - GROUP_STAFF_PK, -) - -from ..helpers import count_queries - - -@pytest.mark.django_db(transaction=False) -def test_motion_db_queries(): - """ - Tests that only the following db queries are done: - * 1 requests to get the list of all motions, - * 1 request to get the associated workflow - * 1 request for all motion comments - * 1 request for all motion comment sections required for the comments - * 1 request for all users required for the read_groups of the sections - * 1 request to get the agenda item, - * 1 request to get the list of speakers, - * 1 request to get the polls, - * 1 request to get the attachments, - * 1 request to get the tags, - * 2 requests to get the submitters and supporters, - * 1 request for change_recommendations. - - Two comment sections are created and for each motions two comments. - """ - section1 = MotionCommentSection.objects.create(name="test_section") - section2 = MotionCommentSection.objects.create(name="test_section") - - for index in range(10): - motion = Motion.objects.create(title=f"motion{index}") - - MotionComment.objects.create( - comment="test_comment", motion=motion, section=section1 - ) - MotionComment.objects.create( - comment="test_comment2", motion=motion, section=section2 - ) - - get_user_model().objects.create_user( - username=f"user_{index}", password="password" - ) - # TODO: Create some polls etc. - - assert count_queries(Motion.get_elements) == 13 +from tests.common_groups import GROUP_ADMIN_PK, GROUP_DELEGATE_PK, GROUP_STAFF_PK +from tests.count_queries import count_queries +from tests.test_case import TestCase @pytest.mark.django_db(transaction=False) @@ -82,7 +32,7 @@ def test_category_db_queries(): for index in range(10): Category.objects.create(name=f"category{index}") - assert count_queries(Category.get_elements) == 1 + assert count_queries(Category.get_elements)() == 1 @pytest.mark.django_db(transaction=False) @@ -96,19 +46,18 @@ def test_statute_paragraph_db_queries(): title=f"statute_paragraph{index}", text=f"text{index}" ) - assert count_queries(StatuteParagraph.get_elements) == 1 + assert count_queries(StatuteParagraph.get_elements)() == 1 @pytest.mark.django_db(transaction=False) def test_workflow_db_queries(): """ Tests that only the following db queries are done: - * 1 requests to get the list of all workflows, - * 1 request to get all states and - * 1 request to get the next states of all states. + * 1 requests to get the list of all workflows and + * 1 request to get all states. """ - assert count_queries(Workflow.get_elements) == 3 + assert count_queries(Workflow.get_elements)() == 2 class TestStatuteParagraphs(TestCase): @@ -220,637 +169,6 @@ class TestStatuteParagraphs(TestCase): self.assertEqual(StatuteParagraph.objects.count(), 1) -class CreateMotion(TestCase): - """ - Tests motion creation. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - - def test_simple(self): - """ - Tests that a motion is created with a specific title and text. - - The created motion should have an identifier and the admin user should - be the submitter. - """ - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_OoCoo3MeiT9li5Iengu9", - "text": "test_text_thuoz0iecheiheereiCi", - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - motion = Motion.objects.get() - self.assertEqual(motion.title, "test_title_OoCoo3MeiT9li5Iengu9") - self.assertEqual(motion.identifier, "1") - self.assertTrue(motion.submitters.exists()) - self.assertEqual(motion.submitters.get().user.username, "admin") - - def test_with_reason(self): - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_saib4hiHaifo9ohp9yie", - "text": "test_text_shahhie8Ej4mohvoorie", - "reason": "test_reason_Ou8GivahYivoh3phoh9c", - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual( - Motion.objects.get().reason, "test_reason_Ou8GivahYivoh3phoh9c" - ) - - def test_without_data(self): - response = self.client.post(reverse("motion-list"), {}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertTrue("title" in response.data) - - def test_without_text(self): - response = self.client.post( - reverse("motion-list"), {"title": "test_title_dlofp23m9O(ZD2d1lwHG"} - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - str(response.data["detail"][0]), "The text field may not be blank." - ) - - def test_with_category(self): - category = Category.objects.create( - name="test_category_name_CiengahzooH4ohxietha", - prefix="TEST_PREFIX_la0eadaewuec3seoxeiN", - ) - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_Air0bahchaiph1ietoo2", - "text": "test_text_chaeF9wosh8OowazaiVu", - "category_id": category.pk, - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - motion = Motion.objects.get() - self.assertEqual(motion.category, category) - self.assertEqual(motion.identifier, "TEST_PREFIX_la0eadaewuec3seoxeiN1") - - def test_with_submitters(self): - submitter_1 = get_user_model().objects.create_user( - username="test_username_ooFe6aebei9ieQui2poo", - password="test_password_vie9saiQu5Aengoo9ku0", - ) - submitter_2 = get_user_model().objects.create_user( - username="test_username_eeciengoc4aihie5eeSh", - password="test_password_peik2Eihu5oTh7siequi", - ) - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_pha7moPh7quoth4paina", - "text": "test_text_YooGhae6tiangung5Rie", - "submitters_id": [submitter_1.pk, submitter_2.pk], - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - motion = Motion.objects.get() - self.assertEqual(motion.submitters.count(), 2) - - def test_with_one_supporter(self): - supporter = get_user_model().objects.create_user( - username="test_username_ahGhi4Quohyee7ohngie", - password="test_password_Nei6aeh8OhY8Aegh1ohX", - ) - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_Oecee4Da2Mu9EY6Ui4mu", - "text": "test_text_FbhgnTFgkbjdmvcjbffg", - "supporters_id": [supporter.pk], - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - motion = Motion.objects.get() - self.assertEqual( - motion.supporters.get().username, "test_username_ahGhi4Quohyee7ohngie" - ) - - def test_with_tag(self): - tag = Tag.objects.create(name="test_tag_iRee3kiecoos4rorohth") - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_Hahke4loos4eiduNiid9", - "text": "test_text_johcho0Ucaibiehieghe", - "tags_id": [tag.pk], - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - motion = Motion.objects.get() - self.assertEqual(motion.tags.get().name, "test_tag_iRee3kiecoos4rorohth") - - def test_with_workflow(self): - """ - Test to create a motion with a specific workflow. - """ - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_eemuR5hoo4ru2ahgh5EJ", - "text": "test_text_ohviePopahPhoili7yee", - "workflow_id": "2", - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Motion.objects.get().state.workflow_id, 2) - - def test_non_admin(self): - """ - Test to create a motion by a delegate, non staff user. - """ - self.admin = get_user_model().objects.get(username="admin") - self.admin.groups.add(GROUP_DELEGATE_PK) - self.admin.groups.remove(GROUP_ADMIN_PK) - inform_changed_data(self.admin) - - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_peiJozae0luew9EeL8bo", - "text": "test_text_eHohS8ohr5ahshoah8Oh", - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_amendment_motion(self): - """ - Test to create a motion with a parent motion as staff user. - """ - parent_motion = self.create_parent_motion() - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_doe93Jsjd2sW20dkSl20", - "text": "test_text_feS20SksD8D25skmwD25", - "parent_id": parent_motion.id, - }, - ) - created_motion = Motion.objects.get(pk=int(response.data["id"])) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(created_motion.parent, parent_motion) - - def test_amendment_motion_parent_not_exist(self): - """ - Test to create an amendment motion with a non existing parent. - """ - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_gEjdkW93Wj23KS2s8dSe", - "text": "test_text_lfwLIC&AjfsaoijOEusa", - "parent_id": 100, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {"detail": "The parent motion does not exist."}) - - def test_amendment_motion_non_admin(self): - """ - Test to create an amendment motion by a delegate. The parents - category should be also set on the new motion. - """ - parent_motion = self.create_parent_motion() - category = Category.objects.create( - name="test_category_name_Dslk3Fj8s8Ps36S3Kskw", - prefix="TEST_PREFIX_L23skfmlq3kslamslS39", - ) - parent_motion.category = category - parent_motion.save() - - self.admin = get_user_model().objects.get(username="admin") - self.admin.groups.add(GROUP_DELEGATE_PK) - self.admin.groups.remove(GROUP_ADMIN_PK) - inform_changed_data(self.admin) - - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_fk3a0slalms47KSewnWG", - "text": "test_text_al3FMwSCNM31WOmw9ezx", - "parent_id": parent_motion.id, - }, - ) - created_motion = Motion.objects.get(pk=int(response.data["id"])) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(created_motion.parent, parent_motion) - self.assertEqual(created_motion.category, category) - - def create_parent_motion(self): - """ - Returns a new created motion used for testing amendments. - """ - response = self.client.post( - reverse("motion-list"), - { - "title": "test_title_3leoeo2qac7830c92j9s", - "text": "test_text_9dm3ks9gDuW20Al38L9w", - }, - ) - return Motion.objects.get(pk=int(response.data["id"])) - - -class RetrieveMotion(TestCase): - """ - Tests retrieving a motion (with poll results). - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - self.motion = Motion( - title="test_title_uj5eeSiedohSh3ohyaaj", - text="test_text_ithohchaeThohmae5aug", - ) - self.motion.save() - self.motion.create_poll() - for index in range(10): - get_user_model().objects.create_user( - username=f"user_{index}", password="password" - ) - - def test_guest_state_with_restriction(self): - config["general_system_enable_anonymous"] = True - guest_client = APIClient() - state = self.motion.state - state.restriction = ["motions.can_manage"] - state.save() - # The cache has to be cleared, see: - # https://github.com/OpenSlides/OpenSlides/issues/3396 - inform_changed_data(self.motion) - - response = guest_client.get(reverse("motion-detail", args=[self.motion.pk])) - self.assertEqual(response.status_code, 404) - - def test_admin_state_with_restriction(self): - state = self.motion.state - state.restriction = ["motions.can_manage"] - state.save() - response = self.client.get(reverse("motion-detail", args=[self.motion.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_submitter_state_with_restriction(self): - state = self.motion.state - state.restriction = ["is_submitter"] - state.save() - user = get_user_model().objects.create_user( - username="username_ohS2opheikaSa5theijo", - password="password_kau4eequaisheeBateef", - ) - Submitter.objects.add(user, self.motion) - submitter_client = APIClient() - submitter_client.force_login(user) - response = submitter_client.get(reverse("motion-detail", args=[self.motion.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_user_without_can_see_user_permission_to_see_motion_and_submitter_data( - self, - ): - admin = get_user_model().objects.get(username="admin") - Submitter.objects.add(admin, self.motion) - group = get_group_model().objects.get( - pk=GROUP_DEFAULT_PK - ) # Group with pk 1 is for anonymous and default users. - permission_string = "users.can_see_name" - app_label, codename = permission_string.split(".") - permission = group.permissions.get( - content_type__app_label=app_label, codename=codename - ) - group.permissions.remove(permission) - config["general_system_enable_anonymous"] = True - guest_client = APIClient() - inform_changed_data(group) - inform_changed_data(self.motion) - - response_1 = guest_client.get(reverse("motion-detail", args=[self.motion.pk])) - self.assertEqual(response_1.status_code, status.HTTP_200_OK) - submitter_id = response_1.data["submitters"][0]["user_id"] - response_2 = guest_client.get(reverse("user-detail", args=[submitter_id])) - self.assertEqual(response_2.status_code, status.HTTP_200_OK) - - extra_user = get_user_model().objects.create_user( - username="username_wequePhieFoom0hai3wa", - password="password_ooth7taechai5Oocieya", - ) - - response_3 = guest_client.get(reverse("user-detail", args=[extra_user.pk])) - self.assertEqual(response_3.status_code, 404) - - -class UpdateMotion(TestCase): - """ - Tests updating motions. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - self.motion = Motion( - title="test_title_aeng7ahChie3waiR8xoh", - text="test_text_xeigheeha7thopubeu4U", - ) - self.motion.save() - - def test_simple_patch(self): - response = self.client.patch( - reverse("motion-detail", args=[self.motion.pk]), - {"identifier": "test_identifier_jieseghohj7OoSah1Ko9"}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - motion = Motion.objects.get() - self.assertEqual(motion.title, "test_title_aeng7ahChie3waiR8xoh") - self.assertEqual(motion.identifier, "test_identifier_jieseghohj7OoSah1Ko9") - - def test_patch_as_anonymous_without_manage_perms(self): - config["general_system_enable_anonymous"] = True - guest_client = APIClient() - response = guest_client.patch( - reverse("motion-detail", args=[self.motion.pk]), - {"identifier": "test_identifier_4g2jgj1wrnmvvIRhtqqPO84WD"}, - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - motion = Motion.objects.get() - self.assertEqual(motion.identifier, "1") - - def test_patch_empty_text(self): - response = self.client.patch( - reverse("motion-detail", args=[self.motion.pk]), {"text": ""}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - motion = Motion.objects.get() - self.assertEqual(motion.text, "test_text_xeigheeha7thopubeu4U") - - def test_patch_amendment_paragraphs_no_manage_perms(self): - admin = get_user_model().objects.get(username="admin") - admin.groups.remove(GROUP_ADMIN_PK) - admin.groups.add(GROUP_DELEGATE_PK) - Submitter.objects.add(admin, self.motion) - self.motion.state.allow_submitter_edit = True - self.motion.state.save() - inform_changed_data(admin) - - response = self.client.patch( - reverse("motion-detail", args=[self.motion.pk]), - {"amendment_paragraphs": ["test_paragraph_39fo8qcpcaFMmjfaD2Lb"]}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - motion = Motion.objects.get() - self.assertTrue(isinstance(motion.amendment_paragraphs, list)) - self.assertEqual(len(motion.amendment_paragraphs), 1) - self.assertEqual( - motion.amendment_paragraphs[0], "test_paragraph_39fo8qcpcaFMmjfaD2Lb" - ) - self.assertEqual(motion.text, "") - - def test_patch_workflow(self): - """ - Tests to only update the workflow of a motion. - """ - response = self.client.patch( - reverse("motion-detail", args=[self.motion.pk]), {"workflow_id": "2"} - ) - - motion = Motion.objects.get() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(motion.title, "test_title_aeng7ahChie3waiR8xoh") - self.assertEqual(motion.workflow_id, 2) - - def test_patch_category(self): - """ - Tests to only update the category of a motion. Expects the - category_weight to be resetted. - """ - category = Category.objects.create( - name="test_category_name_FE3jO(Fm83doqqlwcvlv", - prefix="test_prefix_w3ofg2mv79UGFqjk3f8h", - ) - self.motion.category_weight = 1 - self.motion.save() - response = self.client.patch( - reverse("motion-detail", args=[self.motion.pk]), - {"category_id": category.pk}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - motion = Motion.objects.get() - self.assertEqual(motion.category, category) - self.assertEqual(motion.category_weight, 10000) - - def test_patch_supporters(self): - supporter = get_user_model().objects.create_user( - username="test_username_ieB9eicah0uqu6Phoovo", - password="test_password_XaeTe3aesh8ohg6Cohwo", - ) - response = self.client.patch( - reverse("motion-detail", args=[self.motion.pk]), - json.dumps({"supporters_id": [supporter.pk]}), - content_type="application/json", - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - motion = Motion.objects.get() - self.assertEqual(motion.title, "test_title_aeng7ahChie3waiR8xoh") - self.assertEqual( - motion.supporters.get().username, "test_username_ieB9eicah0uqu6Phoovo" - ) - - def test_patch_supporters_non_manager(self): - non_admin = get_user_model().objects.create_user( - username="test_username_uqu6PhoovieB9eicah0o", - password="test_password_Xaesh8ohg6CoheTe3awo", - ) - self.client.login( - username="test_username_uqu6PhoovieB9eicah0o", - password="test_password_Xaesh8ohg6CoheTe3awo", - ) - motion = Motion.objects.get() - Submitter.objects.add(non_admin, self.motion) - motion.supporters.clear() - response = self.client.patch( - reverse("motion-detail", args=[self.motion.pk]), - json.dumps({"supporters_id": [1]}), - content_type="application/json", - ) - # Forbidden because of changed workflow state. - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_removal_of_supporters(self): - # No cache used here. - admin = get_user_model().objects.get(username="admin") - group_admin = admin.groups.get(name="Admin") - admin.groups.remove(group_admin) - Submitter.objects.add(admin, self.motion) - supporter = get_user_model().objects.create_user( - username="test_username_ahshi4oZin0OoSh9chee", - password="test_password_Sia8ahgeenixu5cei2Ib", - ) - self.motion.supporters.add(supporter) - config["motions_remove_supporters"] = True - self.assertEqual(self.motion.supporters.count(), 1) - inform_changed_data((admin, self.motion)) - - response = self.client.patch( - reverse("motion-detail", args=[self.motion.pk]), - {"title": "new_title_ohph1aedie5Du8sai2ye"}, - ) - - # Forbidden because of changed workflow state. - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - -class DeleteMotion(TestCase): - """ - Tests deleting motions. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - self.admin = get_user_model().objects.get(username="admin") - self.motion = Motion( - title="test_title_acle3fa93l11lwlkcc31", - text="test_text_f390sjfyycj29ss56sro", - ) - self.motion.save() - - def test_simple_delete(self): - response = self.client.delete(reverse("motion-detail", args=[self.motion.pk])) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - motions = Motion.objects.count() - self.assertEqual(motions, 0) - - def make_admin_delegate(self): - self.admin.groups.remove(GROUP_ADMIN_PK) - self.admin.groups.add(GROUP_DELEGATE_PK) - inform_changed_data(self.admin) - - def put_motion_in_complex_workflow(self): - workflow = Workflow.objects.get(name="Complex Workflow") - self.motion.reset_state(workflow=workflow) - self.motion.save() - - def test_delete_foreign_motion_as_delegate(self): - self.make_admin_delegate() - self.put_motion_in_complex_workflow() - - response = self.client.delete(reverse("motion-detail", args=[self.motion.pk])) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_delete_own_motion_as_delegate(self): - self.make_admin_delegate() - self.put_motion_in_complex_workflow() - Submitter.objects.add(self.admin, self.motion) - - response = self.client.delete(reverse("motion-detail", args=[self.motion.pk])) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - motions = Motion.objects.count() - self.assertEqual(motions, 0) - - def test_delete_with_two_change_recommendations(self): - self.cr1 = MotionChangeRecommendation.objects.create( - motion=self.motion, internal=False, line_from=1, line_to=1 - ) - self.cr2 = MotionChangeRecommendation.objects.create( - motion=self.motion, internal=False, line_from=2, line_to=2 - ) - response = self.client.delete(reverse("motion-detail", args=[self.motion.pk])) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - motions = Motion.objects.count() - self.assertEqual(motions, 0) - - -class ManageMultipleSubmitters(TestCase): - """ - Tests adding and removing of submitters. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - - self.admin = get_user_model().objects.get() - self.motion1 = Motion( - title="test_title_SlqfMw(waso0saWMPqcZ", - text="test_text_f30skclqS9wWF=xdfaSL", - ) - self.motion1.save() - self.motion2 = Motion( - title="test_title_f>FLEim38MC2m9PFp2jG", - text="test_text_kg39KFGm,ao)22FK9lLu", - ) - self.motion2.save() - - def test_set_submitters(self): - response = self.client.post( - reverse("motion-manage-multiple-submitters"), - json.dumps( - { - "motions": [ - {"id": self.motion1.id, "submitters": [self.admin.pk]}, - {"id": self.motion2.id, "submitters": [self.admin.pk]}, - ] - } - ), - content_type="application/json", - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(self.motion1.submitters.count(), 1) - self.assertEqual(self.motion2.submitters.count(), 1) - self.assertEqual( - self.motion1.submitters.get().user.pk, self.motion2.submitters.get().user.pk - ) - - def test_non_existing_user(self): - response = self.client.post( - reverse("motion-manage-multiple-submitters"), - {"motions": [{"id": self.motion1.id, "submitters": [1337]}]}, - ) - self.assertEqual(response.status_code, 400) - self.assertEqual(self.motion1.submitters.count(), 0) - - def test_add_user_no_data(self): - response = self.client.post(reverse("motion-manage-multiple-submitters")) - self.assertEqual(response.status_code, 400) - self.assertEqual(self.motion1.submitters.count(), 0) - self.assertEqual(self.motion2.submitters.count(), 0) - - def test_add_user_invalid_data(self): - response = self.client.post( - reverse("motion-manage-multiple-submitters"), {"motions": ["invalid_str"]} - ) - self.assertEqual(response.status_code, 400) - self.assertEqual(self.motion1.submitters.count(), 0) - self.assertEqual(self.motion2.submitters.count(), 0) - - def test_add_without_permission(self): - admin = get_user_model().objects.get(username="admin") - admin.groups.add(GROUP_DELEGATE_PK) - admin.groups.remove(GROUP_ADMIN_PK) - inform_changed_data(admin) - - response = self.client.post( - reverse("motion-manage-multiple-submitters"), - {"motions": [{"id": self.motion1.id, "submitters": [self.admin.pk]}]}, - ) - self.assertEqual(response.status_code, 403) - self.assertEqual(self.motion1.submitters.count(), 0) - self.assertEqual(self.motion2.submitters.count(), 0) - - class ManageComments(TestCase): """ Tests the manage_comment view. @@ -932,9 +250,7 @@ class ManageComments(TestCase): def test_wrong_data_type(self): response = self.client.post( - reverse("motion-manage-comments", args=[self.motion.pk]), - None, - format="json", + reverse("motion-manage-comments", args=[self.motion.pk]), None ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( @@ -948,16 +264,13 @@ class ManageComments(TestCase): "section_id": self.section_read_write.id, "comment": [32, "no_correct_data"], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data["detail"], "The comment should be a string.") def test_non_existing_section(self): response = self.client.post( - reverse("motion-manage-comments", args=[self.motion.pk]), - {"section_id": 42}, - format="json", + reverse("motion-manage-comments", args=[self.motion.pk]), {"section_id": 42} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( @@ -972,7 +285,6 @@ class ManageComments(TestCase): "section_id": self.section_read_write.pk, "comment": "test_comment_fk3jrnfwsdg%fj=feijf", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(MotionComment.objects.count(), 1) @@ -993,7 +305,6 @@ class ManageComments(TestCase): "section_id": self.section_read_write.pk, "comment": "test_comment_fk3jrnfwsdg%fj=feijf", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) comment = MotionComment.objects.get() @@ -1010,7 +321,6 @@ class ManageComments(TestCase): response = self.client.delete( reverse("motion-manage-comments", args=[self.motion.pk]), {"section_id": self.section_read_write.pk}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(MotionComment.objects.count(), 0) @@ -1023,7 +333,6 @@ class ManageComments(TestCase): response = self.client.delete( reverse("motion-manage-comments", args=[self.motion.pk]), {"section_id": self.section_read_write.pk}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(MotionComment.objects.count(), 0) @@ -1035,7 +344,6 @@ class ManageComments(TestCase): "section_id": self.section_read.pk, "comment": "test_comment_f38jfwqfj830fj4j(FU3", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(MotionComment.objects.count(), 0) @@ -1058,7 +366,6 @@ class ManageComments(TestCase): "section_id": self.section_read.pk, "comment": "test_comment_fk3jrnfwsdg%fj=feijf", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) comment = MotionComment.objects.get() @@ -1075,7 +382,6 @@ class ManageComments(TestCase): response = self.client.delete( reverse("motion-manage-comments", args=[self.motion.pk]), {"section_id": self.section_read.pk}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(MotionComment.objects.count(), 1) @@ -1382,7 +688,7 @@ class TestMotionCommentSectionSorting(TestCase): def test_simple(self): response = self.client.post( - reverse("motioncommentsection-sort"), {"ids": [3, 2, 1]}, format="json" + reverse("motioncommentsection-sort"), {"ids": [3, 2, 1]} ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1395,37 +701,35 @@ class TestMotionCommentSectionSorting(TestCase): def test_wrong_data(self): response = self.client.post( - reverse("motioncommentsection-sort"), {"ids": "some_string"}, format="json" + reverse("motioncommentsection-sort"), {"ids": "some_string"} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assert_not_changed() def test_wrong_id_type(self): response = self.client.post( - reverse("motioncommentsection-sort"), - {"ids": [1, 2, "some_string"]}, - format="json", + reverse("motioncommentsection-sort"), {"ids": [1, 2, "some_string"]} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assert_not_changed() def test_missing_id(self): response = self.client.post( - reverse("motioncommentsection-sort"), {"ids": [3, 1]}, format="json" + reverse("motioncommentsection-sort"), {"ids": [3, 1]} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assert_not_changed() def test_duplicate_id(self): response = self.client.post( - reverse("motioncommentsection-sort"), {"ids": [3, 2, 1, 1]}, format="json" + reverse("motioncommentsection-sort"), {"ids": [3, 2, 1, 1]} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assert_not_changed() def test_wrong_id(self): response = self.client.post( - reverse("motioncommentsection-sort"), {"ids": [3, 4, 1]}, format="json" + reverse("motioncommentsection-sort"), {"ids": [3, 4, 1]} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assert_not_changed() @@ -1585,308 +889,6 @@ class CreateMotionChangeRecommendation(TestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) -class SupportMotion(TestCase): - """ - Tests supporting a motion. - """ - - def setUp(self): - self.admin = get_user_model().objects.get(username="admin") - self.admin.groups.add(GROUP_DELEGATE_PK) - inform_changed_data(self.admin) - self.client.login(username="admin", password="admin") - self.motion = Motion( - title="test_title_chee7ahCha6bingaew4e", - text="test_text_birah1theL9ooseeFaip", - ) - self.motion.save() - - def test_support(self): - config["motions_min_supporters"] = 1 - - response = self.client.post(reverse("motion-support", args=[self.motion.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, {"detail": "You have supported this motion successfully."} - ) - - def test_unsupport(self): - config["motions_min_supporters"] = 1 - self.motion.supporters.add(self.admin) - response = self.client.delete(reverse("motion-support", args=[self.motion.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, {"detail": "You have unsupported this motion successfully."} - ) - - -class SetState(TestCase): - """ - Tests setting a state. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - self.motion = Motion( - title="test_title_iac4ohquie9Ku6othieC", - text="test_text_Xohphei6Oobee0Evooyu", - ) - self.motion.save() - self.state_id_accepted = 2 # This should be the id of the state 'accepted'. - - def test_set_state(self): - response = self.client.put( - reverse("motion-set-state", args=[self.motion.pk]), - {"state": self.state_id_accepted}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, {"detail": "The state of the motion was set to accepted."} - ) - self.assertEqual(Motion.objects.get(pk=self.motion.pk).state.name, "accepted") - - def test_set_state_with_string(self): - # Using a string is not allowed even if it is the correct name of the state. - response = self.client.put( - reverse("motion-set-state", args=[self.motion.pk]), {"state": "accepted"} - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"detail": "Invalid data. State must be an integer."} - ) - - def test_set_unknown_state(self): - invalid_state_id = 0 - response = self.client.put( - reverse("motion-set-state", args=[self.motion.pk]), - {"state": invalid_state_id}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "detail": "You can not set the state to {0}.", - "args": [str(invalid_state_id)], - }, - ) - - def test_reset(self): - self.motion.set_state(self.state_id_accepted) - self.motion.save() - response = self.client.put(reverse("motion-set-state", args=[self.motion.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, {"detail": "The state of the motion was set to submitted."} - ) - self.assertEqual(Motion.objects.get(pk=self.motion.pk).state.name, "submitted") - - -class SetRecommendation(TestCase): - """ - Tests setting a recommendation. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - self.motion = Motion( - title="test_title_ahfooT5leilahcohJ2uz", - text="test_text_enoogh7OhPoo6eohoCus", - ) - self.motion.save() - self.state_id_accepted = 2 # This should be the id of the state 'accepted'. - - def test_set_recommendation(self): - response = self.client.put( - reverse("motion-set-recommendation", args=[self.motion.pk]), - {"recommendation": self.state_id_accepted}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, - { - "detail": "The recommendation of the motion was set to {0}.", - "args": ["Acceptance"], - }, - ) - self.assertEqual( - Motion.objects.get(pk=self.motion.pk).recommendation.name, "accepted" - ) - - def test_set_state_with_string(self): - # Using a string is not allowed even if it is the correct name of the state. - response = self.client.put( - reverse("motion-set-recommendation", args=[self.motion.pk]), - {"recommendation": "accepted"}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - {"detail": "Invalid data. Recommendation must be an integer."}, - ) - - def test_set_unknown_recommendation(self): - invalid_state_id = 0 - response = self.client.put( - reverse("motion-set-recommendation", args=[self.motion.pk]), - {"recommendation": invalid_state_id}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "detail": "You can not set the recommendation to {0}.", - "args": [str(invalid_state_id)], - }, - ) - - def test_set_invalid_recommendation(self): - # This is a valid state id, but this state is not recommendable because it belongs to a different workflow. - invalid_state_id = 6 # State 'permitted' - response = self.client.put( - reverse("motion-set-recommendation", args=[self.motion.pk]), - {"recommendation": invalid_state_id}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "detail": "You can not set the recommendation to {0}.", - "args": [str(invalid_state_id)], - }, - ) - - def test_set_invalid_recommendation_2(self): - # This is a valid state id, but this state is not recommendable because it has not recommendation label - invalid_state_id = 1 # State 'submitted' - self.motion.set_state(self.state_id_accepted) - self.motion.save() - response = self.client.put( - reverse("motion-set-recommendation", args=[self.motion.pk]), - {"recommendation": invalid_state_id}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, - { - "detail": "You can not set the recommendation to {0}.", - "args": [str(invalid_state_id)], - }, - ) - - def test_reset(self): - self.motion.set_recommendation(self.state_id_accepted) - self.motion.save() - response = self.client.put( - reverse("motion-set-recommendation", args=[self.motion.pk]) - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, - { - "detail": "The recommendation of the motion was set to {0}.", - "args": ["None"], - }, - ) - self.assertTrue(Motion.objects.get(pk=self.motion.pk).recommendation is None) - - def test_set_recommendation_to_current_state(self): - self.motion.set_state(self.state_id_accepted) - self.motion.save() - response = self.client.put( - reverse("motion-set-recommendation", args=[self.motion.pk]), - {"recommendation": self.state_id_accepted}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, - { - "detail": "The recommendation of the motion was set to {0}.", - "args": ["Acceptance"], - }, - ) - self.assertEqual( - Motion.objects.get(pk=self.motion.pk).recommendation.name, "accepted" - ) - - -class CreateMotionPoll(TestCase): - """ - Tests creating polls of motions. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - self.motion = Motion( - title="test_title_Aiqueigh2dae9phabiqu", - text="test_text_Neekoh3zou6li5rue8iL", - ) - self.motion.save() - - def test_create_first_poll_with_values_then_second_poll_without(self): - self.poll = self.motion.create_poll() - self.poll.set_vote_objects_with_values( - self.poll.get_options().get(), {"Yes": 42, "No": 43, "Abstain": 44} - ) - response = self.client.post( - reverse("motion-create-poll", args=[self.motion.pk]) - ) - self.assertEqual(self.motion.polls.count(), 2) - response = self.client.get(reverse("motion-detail", args=[self.motion.pk])) - for key in ("yes", "no", "abstain"): - self.assertTrue( - response.data["polls"][1][key] is None, - f"Vote value '{key}' should be None.", - ) - - -class UpdateMotionPoll(TestCase): - """ - Tests updating polls of motions. - """ - - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - self.motion = Motion( - title="test_title_Aiqueigh2dae9phabiqu", - text="test_text_Neekoh3zou6li5rue8iL", - ) - self.motion.save() - self.poll = self.motion.create_poll() - - def test_invalid_votesvalid_value(self): - response = self.client.put( - reverse("motionpoll-detail", args=[self.poll.pk]), - {"motion_id": self.motion.pk, "votesvalid": "-3"}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_invalid_votesinvalid_value(self): - response = self.client.put( - reverse("motionpoll-detail", args=[self.poll.pk]), - {"motion_id": self.motion.pk, "votesinvalid": "-3"}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_invalid_votescast_value(self): - response = self.client.put( - reverse("motionpoll-detail", args=[self.poll.pk]), - {"motion_id": self.motion.pk, "votescast": "-3"}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_empty_value_for_votesvalid(self): - response = self.client.put( - reverse("motionpoll-detail", args=[self.poll.pk]), - {"motion_id": self.motion.pk, "votesvalid": ""}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - class NumberMotionsInCategories(TestCase): """ Tests numbering motions in categories. diff --git a/tests/integration/topics/test_viewset.py b/tests/integration/topics/test_viewset.py index 6b165ff7e..04502da0b 100644 --- a/tests/integration/topics/test_viewset.py +++ b/tests/integration/topics/test_viewset.py @@ -4,9 +4,8 @@ from rest_framework import status from openslides.agenda.models import Item from openslides.topics.models import Topic -from openslides.utils.test import TestCase - -from ..helpers import count_queries +from tests.count_queries import count_queries +from tests.test_case import TestCase @pytest.mark.django_db(transaction=False) @@ -21,7 +20,7 @@ def test_topic_item_db_queries(): for index in range(10): Topic.objects.create(title=f"topic-{index}") - assert count_queries(Topic.get_elements) == 4 + assert count_queries(Topic.get_elements)() == 4 class TopicCreate(TestCase): diff --git a/tests/integration/users/test_views.py b/tests/integration/users/test_views.py index 293881f6d..7d02e35a5 100644 --- a/tests/integration/users/test_views.py +++ b/tests/integration/users/test_views.py @@ -3,12 +3,15 @@ import json from django.urls import reverse from rest_framework.test import APIClient -from openslides.utils.test import TestCase +from tests.test_case import TestCase class TestWhoAmIView(TestCase): url = reverse("user_whoami") + def setUp(self): + pass + def test_get_anonymous(self): response = self.client.get(self.url) @@ -44,6 +47,9 @@ class TestWhoAmIView(TestCase): class TestUserLogoutView(TestCase): url = reverse("user_logout") + def setUp(self): + pass + def test_get(self): response = self.client.get(self.url) diff --git a/tests/integration/users/test_viewset.py b/tests/integration/users/test_viewset.py index 668c46f01..8af4588fa 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -8,7 +8,8 @@ from rest_framework.test import APIClient from openslides.core.config import config from openslides.users.models import Group, PersonalNote, User from openslides.utils.autoupdate import inform_changed_data -from openslides.utils.test import TestCase +from tests.count_queries import count_queries +from tests.test_case import TestCase from ...common_groups import ( GROUP_ADMIN_PK, @@ -16,7 +17,6 @@ from ...common_groups import ( GROUP_DELEGATE_PK, GROUP_STAFF_PK, ) -from ..helpers import count_queries @pytest.mark.django_db(transaction=False) @@ -29,7 +29,7 @@ def test_user_db_queries(): for index in range(10): User.objects.create(username=f"user{index}") - assert count_queries(User.get_elements) == 3 + assert count_queries(User.get_elements)() == 3 @pytest.mark.django_db(transaction=False) @@ -42,7 +42,7 @@ def test_group_db_queries(): for index in range(10): Group.objects.create(name=f"group{index}") - assert count_queries(Group.get_elements) == 2 + assert count_queries(Group.get_elements)() == 2 class UserGetTest(TestCase): @@ -87,8 +87,6 @@ class UserCreate(TestCase): """ def test_simple_creation(self): - self.client.login(username="admin", password="admin") - response = self.client.post( reverse("user-list"), {"last_name": "Test name keimeiShieX4Aekoe3do"} ) @@ -98,7 +96,6 @@ class UserCreate(TestCase): self.assertEqual(response.data["id"], new_user.id) def test_creation_with_group(self): - self.client.login(username="admin", password="admin") group_pks = (GROUP_DELEGATE_PK, GROUP_STAFF_PK) self.client.post( @@ -111,7 +108,6 @@ class UserCreate(TestCase): self.assertTrue(user.groups.filter(pk=group_pks[1]).exists()) def test_creation_with_default_group(self): - self.client.login(username="admin", password="admin") group_pk = (GROUP_DEFAULT_PK,) response = self.client.post( @@ -139,6 +135,12 @@ class UserCreate(TestCase): user = User.objects.get(username="test_name_Thimoo2ho7ahreighio3") self.assertEqual(user.about_me, "

    <foo>bar</foo>

    ") + def test_double_username(self): + for field in ("last_name", "username"): + response = self.client.post(reverse("user-list"), {"username": "admin"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(User.objects.count(), 1) + class UserUpdate(TestCase): """ @@ -196,7 +198,6 @@ class UserUpdate(TestCase): response = admin_client.patch( reverse("user-detail", args=[user_pk]), {"username": "admin", "is_active": False}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -268,7 +269,7 @@ class UserDelete(TestCase): ids.append(user.id) response = self.admin_client.post( - reverse("user-bulk-delete"), {"user_ids": ids}, format="json" + reverse("user-bulk-delete"), {"user_ids": ids} ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(User.objects.filter(pk__in=ids).exists()) @@ -276,7 +277,7 @@ class UserDelete(TestCase): def test_bulk_delete_self(self): """ The own id should be excluded, so nothing should happen. """ response = self.admin_client.post( - reverse("user-bulk-delete"), {"user_ids": [1]}, format="json" + reverse("user-bulk-delete"), {"user_ids": [1]} ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertTrue(User.objects.filter(pk=1).exists()) @@ -416,9 +417,7 @@ class UserPassword(TestCase): self.assertTrue(user2.check_password(default_password2)) response = self.admin_client.post( - reverse("user-bulk-generate-passwords"), - {"user_ids": [user1.id, user2.id]}, - format="json", + reverse("user-bulk-generate-passwords"), {"user_ids": [user1.id, user2.id]} ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -450,7 +449,6 @@ class UserPassword(TestCase): response = self.admin_client.post( reverse("user-bulk-reset-passwords-to-default"), {"user_ids": [user1.id, user2.id]}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -478,7 +476,6 @@ class UserBulkSetState(TestCase): response = self.client.post( reverse("user-bulk-set-state"), {"user_ids": [1], "field": "is_present", "value": False}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(User.objects.get().is_active) @@ -489,7 +486,6 @@ class UserBulkSetState(TestCase): response = self.client.post( reverse("user-bulk-set-state"), {"user_ids": [1], "field": "invalid", "value": False}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertTrue(User.objects.get().is_active) @@ -500,7 +496,6 @@ class UserBulkSetState(TestCase): response = self.client.post( reverse("user-bulk-set-state"), {"user_ids": [1], "field": "is_active", "value": "invalid"}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertTrue(User.objects.get().is_active) @@ -511,7 +506,6 @@ class UserBulkSetState(TestCase): response = self.client.post( reverse("user-bulk-set-state"), {"user_ids": [1], "field": "is_active", "value": False}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(User.objects.get().is_active) @@ -539,7 +533,6 @@ class UserBulkAlterGroups(TestCase): "action": "add", "group_ids": [GROUP_DELEGATE_PK, GROUP_STAFF_PK], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(self.user.groups.count(), 2) @@ -558,7 +551,6 @@ class UserBulkAlterGroups(TestCase): "action": "remove", "group_ids": [GROUP_DEFAULT_PK, GROUP_STAFF_PK], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(self.user.groups.count(), 1) @@ -574,7 +566,6 @@ class UserBulkAlterGroups(TestCase): "action": "add", "group_ids": [GROUP_DELEGATE_PK], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(self.admin.groups.count(), 1) @@ -588,7 +579,6 @@ class UserBulkAlterGroups(TestCase): "action": "invalid", "group_ids": [GROUP_DELEGATE_PK], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -598,26 +588,49 @@ class UserMassImport(TestCase): Tests mass import of users. """ - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") - def test_mass_import(self): - user_1 = { - "first_name": "first_name_kafaith3woh3thie7Ciy", - "last_name": "last_name_phah0jaeph9ThoongaeL", - "groups_id": [], - } - user_2 = { - "first_name": "first_name_kohdao7Eibouwee8ma2O", - "last_name": "last_name_kafaith3woh3thie7Ciy", - "groups_id": [], - } - response = self.client.post( - reverse("user-mass-import"), {"users": [user_1, user_2]}, format="json" - ) + data = [ + { + "first_name": "first_name_kafaith3woh3thie7Ciy", + "last_name": "last_name_phah0jaeph9ThoongaeL", + "groups_id": [], + }, + { + "first_name": "first_name_kohdao7Eibouwee8ma2O", + "last_name": "last_name_4en5ANFoz2nQmoUkTfYe", + "groups_id": [], + }, + { + "first_name": "first_name_JbCpGkpcYCaQtDNA4pDW", + "last_name": "last_name_z0MMAIwbieKtpzW3dDJY", + "groups_id": [], + }, + ] + response = self.client.post(reverse("user-mass-import"), {"users": data}) self.assertEqual(response.status_code, 200) - self.assertEqual(User.objects.count(), 3) + self.assertEqual(User.objects.count(), 4) + + def test_mass_import_double_username(self): + data = [ + {"username": "double_name", "groups_id": []}, + {"username": "double_name", "groups_id": []}, + ] + response = self.client.post(reverse("user-mass-import"), {"users": data}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + User.objects.count(), 2 + ) # second user is skipped because the username already exists + + def test_mass_import_double_name(self): + data = [ + {"first_name": "double_name", "groups_id": []}, + {"last_name": "double_name", "groups_id": []}, + ] + response = self.client.post(reverse("user-mass-import"), {"users": data}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + User.objects.count(), 3 + ) # if username is generated, the api appends a number behind it and thus generates both users class UserSendIntivationEmail(TestCase): @@ -640,9 +653,7 @@ class UserSendIntivationEmail(TestCase): "subject": config["users_email_subject"], "message": config["users_email_body"], } - response = self.client.post( - reverse("user-mass-invite-email"), data, format="json" - ) + response = self.client.post(reverse("user-mass-invite-email"), data) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["count"], 1) self.assertEqual(len(mail.outbox), 1) @@ -666,6 +677,9 @@ class GroupMetadata(TestCase): class GroupReceive(TestCase): + def setUp(self): + pass + def test_get_groups_as_anonymous_deactivated(self): """ Test to get the groups with an anonymous user, when they are deactivated. @@ -849,7 +863,6 @@ class GroupUpdate(TestCase): response = admin_client.put( reverse("group-detail", args=[group.pk]), {"name": "new_group_name_Chie6duwaepoo8aech7r", "permissions": permissions}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -869,7 +882,6 @@ class GroupUpdate(TestCase): response = admin_client.post( reverse("group-set-permission", args=[GROUP_DEFAULT_PK]), {"perm": "users.can_manage", "set": True}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -887,7 +899,6 @@ class GroupUpdate(TestCase): response = admin_client.post( reverse("group-set-permission", args=[GROUP_DEFAULT_PK]), {"perm": "not_existing.permission", "set": True}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -899,7 +910,6 @@ class GroupUpdate(TestCase): response = admin_client.post( reverse("group-set-permission", args=[GROUP_DEFAULT_PK]), {"perm": "users.can_see_name", "set": False}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -972,7 +982,6 @@ class PersonalNoteTest(TestCase): {"collection": "example-model", "id": 1, "content": content1}, {"collection": "example-model", "id": 2, "content": content2}, ], - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(PersonalNote.objects.exists()) @@ -985,9 +994,7 @@ class PersonalNoteTest(TestCase): def test_anonymous_create(self): guest_client = APIClient() - response = guest_client.post( - reverse("personalnote-create-or-update"), [], format="json" - ) + response = guest_client.post(reverse("personalnote-create-or-update"), []) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertFalse(PersonalNote.objects.exists()) @@ -1007,7 +1014,6 @@ class PersonalNoteTest(TestCase): "content": "test_note_do2ncoi7ci2fm93LjwlO", } ], - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) personal_note = PersonalNote.objects.get() diff --git a/tests/integration/utils/test_consumers.py b/tests/integration/utils/test_consumers.py index 3b4cf65b2..80bf93729 100644 --- a/tests/integration/utils/test_consumers.py +++ b/tests/integration/utils/test_consumers.py @@ -11,9 +11,9 @@ from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_K from openslides.asgi import application from openslides.core.config import config from openslides.utils.autoupdate import ( - Element, - inform_changed_elements, + AutoupdateElement, inform_deleted_data, + inform_elements, ) from openslides.utils.cache import element_cache from openslides.utils.websocket import ( @@ -93,9 +93,9 @@ async def set_config(): collection_string = config.get_collection_string() config_id = config.key_to_id[key] # type: ignore full_data = {"id": config_id, "key": key, "value": value} - await sync_to_async(inform_changed_elements)( + await sync_to_async(inform_elements)( [ - Element( + AutoupdateElement( id=config_id, collection_string=collection_string, full_data=full_data, @@ -227,9 +227,9 @@ async def test_skipping_autoupdate(set_config, get_communicator): await communicator.connect() with patch("openslides.utils.autoupdate.save_history"): - await sync_to_async(inform_changed_elements)( + await sync_to_async(inform_elements)( [ - Element( + AutoupdateElement( id=2, collection_string=PersonalizedCollection().get_collection_string(), full_data={"id": 2, "value": "new value 1", "user_id": 2}, @@ -237,9 +237,9 @@ async def test_skipping_autoupdate(set_config, get_communicator): ) ] ) - await sync_to_async(inform_changed_elements)( + await sync_to_async(inform_elements)( [ - Element( + AutoupdateElement( id=2, collection_string=PersonalizedCollection().get_collection_string(), full_data={"id": 2, "value": "new value 2", "user_id": 2}, diff --git a/tests/old/agenda/test_list_of_speakers.py b/tests/old/agenda/test_list_of_speakers.py index e0c2c8513..b616feb2f 100644 --- a/tests/old/agenda/test_list_of_speakers.py +++ b/tests/old/agenda/test_list_of_speakers.py @@ -2,7 +2,7 @@ from openslides.agenda.models import ListOfSpeakers, Speaker from openslides.topics.models import Topic from openslides.users.models import User from openslides.utils.exceptions import OpenSlidesError -from openslides.utils.test import TestCase +from tests.test_case import TestCase class ListOfSpeakerModelTests(TestCase): diff --git a/tests/old/config/test_config.py b/tests/old/config/test_config.py index c3cd84a0d..b15d96d41 100644 --- a/tests/old/config/test_config.py +++ b/tests/old/config/test_config.py @@ -1,6 +1,6 @@ from openslides.core.config import ConfigVariable, config from openslides.core.exceptions import ConfigError -from openslides.utils.test import TestCase +from tests.test_case import TestCase class TTestConfigException(Exception): diff --git a/tests/old/motions/test_models.py b/tests/old/motions/test_models.py index e6ac402d0..06ddcb9d0 100644 --- a/tests/old/motions/test_models.py +++ b/tests/old/motions/test_models.py @@ -2,7 +2,7 @@ from openslides.core.config import config from openslides.motions.exceptions import WorkflowError from openslides.motions.models import Motion, State, Workflow from openslides.users.models import User -from openslides.utils.test import TestCase +from tests.test_case import TestCase class ModelTest(TestCase): @@ -25,8 +25,6 @@ class ModelTest(TestCase): self.motion.state = State.objects.get(pk=5) self.assertEqual(self.motion.state.name, "in progress") - with self.assertRaises(WorkflowError): - self.motion.create_poll() self.motion.state = State.objects.get(pk=6) self.assertEqual(self.motion.state.name, "submitted") diff --git a/tests/old/utils/test_main.py b/tests/old/utils/test_main.py index d2cec2e61..5561b7259 100644 --- a/tests/old/utils/test_main.py +++ b/tests/old/utils/test_main.py @@ -3,7 +3,7 @@ import sys from unittest.mock import MagicMock, patch from openslides.utils import main -from openslides.utils.test import TestCase +from tests.test_case import TestCase class TestFunctions(TestCase): diff --git a/tests/settings.py b/tests/settings.py index c8355d661..1366534df 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -73,3 +73,7 @@ PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] # Deactivate restricted_data_cache RESTRICTED_DATA_CACHE = False + +REST_FRAMEWORK = {"TEST_REQUEST_DEFAULT_FORMAT": "json"} + +ENABLE_ELECTRONIC_VOTING = True diff --git a/tests/test_case.py b/tests/test_case.py new file mode 100644 index 000000000..6dd7b3266 --- /dev/null +++ b/tests/test_case.py @@ -0,0 +1,114 @@ +import random +import string + +from asgiref.sync import async_to_sync +from django.contrib.auth import get_user_model +from django.test import TestCase as _TestCase +from rest_framework.test import APIClient + +from openslides.core.config import config +from openslides.utils.autoupdate import inform_changed_data +from openslides.utils.cache import element_cache +from openslides.utils.utils import get_element_id +from tests.common_groups import GROUP_ADMIN_PK, GROUP_DELEGATE_PK +from tests.count_queries import AssertNumQueriesContext + + +class TestCase(_TestCase): + maxDiff = None + + def setUp(self): + self.admin = get_user_model().objects.get(username="admin") + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.advancedSetUp() + + def advancedSetUp(self): + pass + + def create_guest_client(self): + config["general_system_enable_anonymous"] = True + return APIClient() + + """ + Adds testing for autoupdates after requests. + """ + + def get_last_autoupdate(self, user=None): + """ + Get the last autoupdate as (changed_data, deleted_element_ids) for the given user. + changed_elements is a dict with element_ids as keys and the actual element as value + user_id=None if for full data, 0 for the anonymous and regular ids for users. + """ + user_id = None if user is None else user.id + current_change_id = async_to_sync(element_cache.get_current_change_id)() + _changed_elements, deleted_element_ids = async_to_sync( + element_cache.get_data_since + )(user_id=user_id, change_id=current_change_id) + + changed_elements = {} + for collection, elements in _changed_elements.items(): + for element in elements: + changed_elements[get_element_id(collection, element["id"])] = element + + return (changed_elements, deleted_element_ids) + + def assertAutoupdate(self, model, user=None): + self.assertTrue( + model.get_element_id() in self.get_last_autoupdate(user=user)[0] + ) + + def assertDeletedAutoupdate(self, model, user=None): + self.assertTrue( + model.get_element_id() in self.get_last_autoupdate(user=user)[1] + ) + + def assertNoAutoupdate(self, model, user=None): + self.assertFalse( + model.get_element_id() in self.get_last_autoupdate(user=user)[0] + ) + + def assertNoDeletedAutoupdate(self, model, user=None): + self.assertFalse( + model.get_element_id() in self.get_last_autoupdate(user=user)[1] + ) + + def assertNumQueries(self, num, func=None, *args, verbose=False, **kwargs): + context = AssertNumQueriesContext(self, num, verbose) + if func is None: + return context + + with context: + func(*args, **kwargs) + + def assertHttpStatusVerbose(self, response, status): + if response.status_code != status: + print(response.data) + self.assertEqual(response.status_code, status) + + """ + Create Helper functions + """ + + def create_user(self): + password = "test_password_" + self._get_random_string() + return ( + get_user_model().objects.create_user( + username="test_user_" + self._get_random_string(), password=password + ), + password, + ) + + def make_admin_delegate(self): + admin = get_user_model().objects.get(username="admin") + admin.groups.add(GROUP_DELEGATE_PK) + admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(admin) + + def _get_random_string(self, length=20): + return "".join( + random.choices( + string.ascii_lowercase + string.ascii_uppercase + string.digits, + k=length, + ) + ) diff --git a/tests/unit/assignments/test_models.py b/tests/unit/assignments/test_models.py new file mode 100644 index 000000000..3c909e229 --- /dev/null +++ b/tests/unit/assignments/test_models.py @@ -0,0 +1 @@ +# TODO: test for AssignmentPoll.set_options() diff --git a/tests/unit/motions/test_models.py b/tests/unit/motions/test_models.py index 992fc80cc..dc3af8163 100644 --- a/tests/unit/motions/test_models.py +++ b/tests/unit/motions/test_models.py @@ -3,6 +3,9 @@ from unittest import TestCase from openslides.motions.models import Motion, MotionChangeRecommendation +# TODO: test for MotionPoll.set_options() + + class MotionChangeRecommendationTest(TestCase): def test_overlapping_line_numbers(self): """