From ced40cab74e670a6c5cf03778a573737256fc07a Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Fri, 18 Oct 2019 14:18:49 +0200 Subject: [PATCH 01/55] Initial work for supporting voting --- .travis.yml | 8 + .../core/core-services/app-load.service.ts | 11 +- .../collection-string-mapper.service.ts | 3 +- client/src/app/core/definitions/app-config.ts | 1 - ...assignment-poll-repository.service.spec.ts | 14 + .../assignment-poll-repository.service.ts | 64 + .../assignment-repository.service.ts | 71 +- ...assignment-vote-repository.service.spec.ts | 14 + .../assignment-vote-repository.service.ts | 58 + .../motion-poll-repository.service.spec.ts | 14 + .../motions/motion-poll-repository.service.ts | 54 + .../motions/motion-repository.service.ts | 41 - .../motion-vote-repository.service.spec.ts | 14 + .../motions/motion-vote-repository.service.ts | 58 + .../models/assignments/assignment-option.ts | 11 + .../assignments/assignment-poll-option.ts | 38 - .../models/assignments/assignment-poll.ts | 40 +- .../models/assignments/assignment-vote.ts | 11 + .../shared/models/base/base-decimal-model.ts | 12 + .../shared/models/motions/motion-option.ts | 9 + .../app/shared/models/motions/motion-poll.ts | 44 +- .../app/shared/models/motions/motion-vote.ts | 11 + .../src/app/shared/models/poll/base-option.ts | 11 + .../src/app/shared/models/poll/base-poll.ts | 33 + .../src/app/shared/models/poll/base-vote.ts | 9 + client/src/app/site/agenda/agenda.config.ts | 3 +- .../site/assignments/assignments.config.ts | 17 +- .../assignment-detail.component.html | 2 +- .../assignment-detail.component.ts | 24 +- .../assignment-poll-dialog.component.ts | 63 +- .../assignment-poll.component.ts | 77 +- .../models/view-assignment-option.ts | 12 + .../models/view-assignment-poll-option.ts | 25 - .../models/view-assignment-poll.ts | 34 +- .../models/view-assignment-vote.ts | 17 + .../assignments/models/view-assignment.ts | 3 - .../services/assignment-pdf.service.ts | 41 +- .../services/assignment-poll-pdf.service.ts | 19 +- .../services/assignment-poll.service.spec.ts | 12 - .../services/assignment-poll.service.ts | 308 ----- client/src/app/site/config/config.config.ts | 4 +- .../app/site/mediafiles/mediafile.config.ts | 1 - .../site/motions/models/view-motion-option.ts | 12 + .../site/motions/models/view-motion-poll.ts | 41 + .../site/motions/models/view-motion-vote.ts | 17 + .../motion-detail/motion-detail.component.ts | 4 +- .../motion-poll-dialog.component.ts | 9 +- .../motion-poll/motion-poll.component.html | 2 +- .../motion-poll/motion-poll.component.ts | 26 +- client/src/app/site/motions/motions.config.ts | 8 - .../motions/services/motion-pdf.service.ts | 7 +- .../motions/services/motion-poll.service.ts | 5 +- .../app/site/projector/projector.config.ts | 4 - client/src/app/site/tags/tag.config.ts | 1 - client/src/app/site/topics/topics.config.ts | 1 - client/src/app/site/users/users.config.ts | 4 +- .../assignments/poll/poll-slide-data.ts | 5 +- .../assignments/poll/poll-slide.component.ts | 35 +- openslides/agenda/mixins.py | 22 +- openslides/agenda/signals.py | 1 - openslides/assignments/access_permissions.py | 9 + openslides/assignments/apps.py | 20 +- openslides/assignments/config_variables.py | 19 - .../migrations/0008_auto_20191017_1040.py | 139 +++ openslides/assignments/models.py | 173 +-- openslides/assignments/serializers.py | 168 +-- openslides/assignments/views.py | 263 +++- .../0026_remove_history_restricted.py | 10 + openslides/core/models.py | 3 - openslides/motions/access_permissions.py | 61 + openslides/motions/apps.py | 6 + .../migrations/0033_auto_20191017_1100.py | 146 +++ openslides/motions/models.py | 103 +- openslides/motions/serializers.py | 161 +-- openslides/motions/views.py | 129 +- openslides/poll/models.py | 373 +++--- openslides/poll/serializers.py | 31 +- openslides/poll/views.py | 165 +++ openslides/utils/auth.py | 27 +- openslides/utils/autoupdate.py | 20 +- openslides/utils/cache.py | 33 +- openslides/utils/models.py | 18 +- openslides/utils/test.py | 7 - openslides/utils/utils.py | 10 +- requirements/production.txt | 2 +- tests/integration/agenda/test_models.py | 2 +- tests/integration/agenda/test_viewset.py | 30 +- tests/integration/assignments/test_polls.py | 507 ++++++++ tests/integration/assignments/test_viewset.py | 69 +- tests/integration/core/test_views.py | 2 +- tests/integration/core/test_viewset.py | 41 +- tests/integration/mediafiles/test_viewset.py | 8 +- tests/integration/motions/test_motions.py | 980 +++++++++++++++ tests/integration/motions/test_polls.py | 1107 +++++++++++++++++ tests/integration/motions/test_views.py | 2 +- tests/integration/motions/test_viewset.py | 1016 +-------------- tests/integration/topics/test_viewset.py | 2 +- tests/integration/users/test_views.py | 8 +- tests/integration/users/test_viewset.py | 39 +- tests/old/agenda/test_list_of_speakers.py | 2 +- tests/old/config/test_config.py | 2 +- tests/old/motions/test_models.py | 4 +- tests/old/utils/test_main.py | 2 +- tests/settings.py | 2 + tests/test_case.py | 98 ++ tests/unit/assignments/test_models.py | 1 + tests/unit/motions/test_models.py | 3 + 107 files changed, 4841 insertions(+), 2702 deletions(-) create mode 100644 client/src/app/core/repositories/assignments/assignment-poll-repository.service.spec.ts create mode 100644 client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts create mode 100644 client/src/app/core/repositories/assignments/assignment-vote-repository.service.spec.ts create mode 100644 client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts create mode 100644 client/src/app/core/repositories/motions/motion-poll-repository.service.spec.ts create mode 100644 client/src/app/core/repositories/motions/motion-poll-repository.service.ts create mode 100644 client/src/app/core/repositories/motions/motion-vote-repository.service.spec.ts create mode 100644 client/src/app/core/repositories/motions/motion-vote-repository.service.ts create mode 100644 client/src/app/shared/models/assignments/assignment-option.ts delete mode 100644 client/src/app/shared/models/assignments/assignment-poll-option.ts create mode 100644 client/src/app/shared/models/assignments/assignment-vote.ts create mode 100644 client/src/app/shared/models/base/base-decimal-model.ts create mode 100644 client/src/app/shared/models/motions/motion-option.ts create mode 100644 client/src/app/shared/models/motions/motion-vote.ts create mode 100644 client/src/app/shared/models/poll/base-option.ts create mode 100644 client/src/app/shared/models/poll/base-poll.ts create mode 100644 client/src/app/shared/models/poll/base-vote.ts create mode 100644 client/src/app/site/assignments/models/view-assignment-option.ts delete mode 100644 client/src/app/site/assignments/models/view-assignment-poll-option.ts create mode 100644 client/src/app/site/assignments/models/view-assignment-vote.ts delete mode 100644 client/src/app/site/assignments/services/assignment-poll.service.spec.ts delete mode 100644 client/src/app/site/assignments/services/assignment-poll.service.ts create mode 100644 client/src/app/site/motions/models/view-motion-option.ts create mode 100644 client/src/app/site/motions/models/view-motion-poll.ts create mode 100644 client/src/app/site/motions/models/view-motion-vote.ts create mode 100644 openslides/assignments/migrations/0008_auto_20191017_1040.py create mode 100644 openslides/core/migrations/0026_remove_history_restricted.py create mode 100644 openslides/motions/migrations/0033_auto_20191017_1100.py create mode 100644 openslides/poll/views.py delete mode 100644 openslides/utils/test.py create mode 100644 tests/integration/assignments/test_polls.py create mode 100644 tests/integration/motions/test_motions.py create mode 100644 tests/integration/motions/test_polls.py create mode 100644 tests/test_case.py create mode 100644 tests/unit/assignments/test_models.py diff --git a/.travis.yml b/.travis.yml index 5561447b5..860bb9d15 100644 --- a/.travis.yml +++ b/.travis.yml @@ -85,7 +85,11 @@ matrix: - "3.6" script: - mypy openslides/ tests/ +<<<<<<< HEAD - pytest --cov --cov-fail-under=72 +======= + - pytest --cov --cov-fail-under=74 +>>>>>>> Initial work for supporting voting - name: "Server: Tests Python 3.7" language: python @@ -96,7 +100,11 @@ matrix: - isort --check-only --diff --recursive openslides tests - black --check --diff --target-version py36 openslides tests - mypy openslides/ tests/ +<<<<<<< HEAD - pytest --cov --cov-fail-under=72 +======= + - pytest --cov --cov-fail-under=74 +>>>>>>> Initial work for supporting voting - name: "Server: Tests Python 3.8" language: python 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/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/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..f23f591eb --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts @@ -0,0 +1,64 @@ +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 { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; +import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; +import { BaseRepository } from '../base-repository'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +/** + * Repository Service for Assignments. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class AssignmentPollRepositoryService extends BaseRepository< + 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 + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + AssignmentPoll + // TODO: relations + ); + } + + public getTitle = (titleInformation: AssignmentPollTitleInformation) => { + return titleInformation.title; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Polls' : 'Poll'); + }; +} 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..fde6641a5 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,12 @@ 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 { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; 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 { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; 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'; @@ -68,8 +68,8 @@ const AssignmentNestedModelDescriptors: NestedModelDescriptors = { 'assignments/assignment-poll': [ { ownKey: 'options', - foreignViewModel: ViewAssignmentPollOption, - foreignModel: AssignmentPollOption, + foreignViewModel: ViewAssignmentOption, + foreignModel: AssignmentOption, order: 'weight', relationDefinitionsByKey: { user: { @@ -97,10 +97,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/'; /** @@ -179,67 +177,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 * 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..555d258f3 --- /dev/null +++ b/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts @@ -0,0 +1,58 @@ +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 { AssignmentVote } from 'app/shared/models/assignments/assignment-vote'; +import { ViewAssignmentVote } from 'app/site/assignments/models/view-assignment-vote'; +import { BaseRepository } from '../base-repository'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +/** + * 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 + // TODO: relations + ); + } + + 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/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..57f342f5c --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-poll-repository.service.ts @@ -0,0 +1,54 @@ +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 { MotionPoll } from 'app/shared/models/motions/motion-poll'; +import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { BaseRepository } from '../base-repository'; +import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; +import { DataStoreService } from '../../core-services/data-store.service'; + +/** + * Repository Service for Assignments. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionPollRepositoryService extends BaseRepository< + ViewMotionPoll, + MotionPoll, + MotionPollTitleInformation +> { + public constructor( + DS: DataStoreService, + dataSend: DataSendService, + mapperService: CollectionStringMapperService, + viewModelStoreService: ViewModelStoreService, + translate: TranslateService, + relationManager: RelationManagerService + ) { + super( + DS, + dataSend, + mapperService, + viewModelStoreService, + translate, + relationManager, + MotionPoll + // TODO: relations + ); + } + + public getTitle = (titleInformation: MotionPollTitleInformation) => { + return titleInformation.title; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Polls' : 'Poll'); + }; +} 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..3626cd7a0 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'; @@ -844,46 +843,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..ff95c237f --- /dev/null +++ b/client/src/app/core/repositories/motions/motion-vote-repository.service.ts @@ -0,0 +1,58 @@ +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 { MotionVote } from 'app/shared/models/motions/motion-vote'; +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'; + +/** + * 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 + // TODO: relations + ); + } + + public getTitle = (titleInformation: object) => { + return 'Vote'; + }; + + public getVerboseName = (plural: boolean = false) => { + return this.translate.instant(plural ? 'Votes' : 'Vote'); + }; +} 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..1593d2108 --- /dev/null +++ b/client/src/app/shared/models/assignments/assignment-option.ts @@ -0,0 +1,11 @@ +import { BaseOption } from '../poll/base-option'; + +export class AssignmentOption extends BaseOption { + public static COLLECTIONSTRING = 'assignments/assignment-option'; + + public user_id: 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..b12d69919 100644 --- a/client/src/app/shared/models/assignments/assignment-poll.ts +++ b/client/src/app/shared/models/assignments/assignment-poll.ts @@ -1,18 +1,18 @@ -import { AssignmentPollMethod } from 'app/site/assignments/services/assignment-poll.service'; -import { AssignmentPollOption } from './assignment-poll-option'; -import { BaseModel } from '../base/base-model'; +import { AssignmentOption } from './assignment-option'; +import { BasePoll, BasePollWithoutNestedModels } 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; +export enum AssignmentPollmethods { + 'yn' = 'yn', + 'yna' = 'yna', + 'votes' = 'votes' +} + +export interface AssignmentPollWithoutNestedModels extends BasePollWithoutNestedModels { + pollmethod: AssignmentPollmethods; + votes_amount: number; + allow_multiple_votes_per_candidate: boolean; + global_no: boolean; + global_abstain: boolean; assignment_id: number; } @@ -20,22 +20,12 @@ export interface AssignmentPollWithoutNestedModels extends BaseModel { +export class AssignmentPoll extends BasePoll { public static COLLECTIONSTRING = 'assignments/assignment-poll'; - private static DECIMAL_FIELDS = ['votesvalid', 'votesinvalid', 'votescast', 'votesno', 'votesabstain']; public id: number; - public options: AssignmentPollOption[]; 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); } } 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/base/base-decimal-model.ts b/client/src/app/shared/models/base/base-decimal-model.ts new file mode 100644 index 000000000..f7bedde9b --- /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 decimalFields: (keyof this)[]; + + public deserialize(input: any): void { + if (input && typeof input === 'object') { + this.decimalFields.forEach(field => (input[field] = parseInt(input[field], 10))); + } + super.deserialize(input); + } +} 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..01dd6287e 100644 --- a/client/src/app/shared/models/motions/motion-poll.ts +++ b/client/src/app/shared/models/motions/motion-poll.ts @@ -1,36 +1,26 @@ -import { Deserializer } from '../base/deserializer'; +import { BasePoll, BasePollWithoutNestedModels } from '../poll/base-poll'; +import { MotionOption } from './motion-option'; + +export enum MotionPollmethods { + 'YN' = 'YN', + 'YNA' = 'YNA' +} + +export interface MotionPollWithoutNestedModels extends BasePollWithoutNestedModels { + motion_id: number; + pollmethod: MotionPollmethods; +} /** * Class representing a poll for a motion. */ -export class MotionPoll extends Deserializer { +export class MotionPoll extends BasePoll { + public static COLLECTIONSTRING = 'motions/motion-poll'; + 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); - } - }); - } - super(input); - } - - public deserialize(input: any): void { - Object.assign(this, input); + super(MotionPoll.COLLECTIONSTRING, input); } } +export interface MotionPoll extends MotionPollWithoutNestedModels {} 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/poll/base-option.ts b/client/src/app/shared/models/poll/base-option.ts new file mode 100644 index 000000000..cc0df616a --- /dev/null +++ b/client/src/app/shared/models/poll/base-option.ts @@ -0,0 +1,11 @@ +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 votes_id: number[]; + + protected decimalFields: (keyof BaseOption)[] = ['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..e8c4a1f5b --- /dev/null +++ b/client/src/app/shared/models/poll/base-poll.ts @@ -0,0 +1,33 @@ +import { BaseDecimalModel } from '../base/base-decimal-model'; +import { BaseOption } from './base-option'; + +export enum PollState { + Created = 1, + Started, + Finished, + Published +} + +export enum PollType { + Analog = 'analog', + Named = 'named', + Pseudoanonymous = 'pseudoanonymous' +} + +export interface BasePollWithoutNestedModels { + state: PollState; + type: PollType; + title: string; + votesvalid: number; + votesinvalid: number; + votescast: number; + groups_id: number[]; + voted_id: number[]; +} + +export abstract class BasePoll> extends BaseDecimalModel { + public options: O[]; + + protected decimalFields: (keyof BasePoll)[] = ['votesvalid', 'votesinvalid', 'votescast']; +} +export interface BasePoll> extends BasePollWithoutNestedModels {} 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..0a3f36dfe --- /dev/null +++ b/client/src/app/shared/models/poll/base-vote.ts @@ -0,0 +1,9 @@ +import { BaseDecimalModel } from '../base/base-decimal-model'; + +export abstract class BaseVote extends BaseDecimalModel { + public weight: number; + public value: 'Y' | 'N' | 'A'; + public user_id?: number; + + protected decimalFields: (keyof BaseVote)[] = ['weight']; +} 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/assignments/assignments.config.ts b/client/src/app/site/assignments/assignments.config.ts index 6826f666a..1b050b9e8 100644 --- a/client/src/app/site/assignments/assignments.config.ts +++ b/client/src/app/site/assignments/assignments.config.ts @@ -1,17 +1,32 @@ import { AppConfig } from '../../core/definitions/app-config'; +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 { 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 { 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. repository: AssignmentRepositoryService + }, + { + model: AssignmentPoll, + viewModel: ViewAssignmentPoll, + repository: AssignmentPollRepositoryService + }, + { + model: AssignmentVote, + viewModel: ViewAssignmentVote, + repository: AssignmentVoteRepositoryService } ], mainMenuEntries: [ 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..f400d8ae1 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 @@ -147,7 +147,7 @@ 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..ecbc3353a 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,7 +22,6 @@ 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 { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment'; import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user'; @@ -171,7 +169,6 @@ 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, @@ -303,10 +300,9 @@ 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); + // await this.repo.createPoll(this.assignment).catch(this.raiseError); } /** @@ -455,24 +451,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-poll-dialog/assignment-poll-dialog.component.ts b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts index 1fe9d7ac5..4233a142e 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,16 +1,9 @@ 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 { 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 { ViewAssignmentOption } from '../../models/view-assignment-option'; 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) @@ -61,16 +54,9 @@ export class AssignmentPollDialogComponent { */ public constructor( public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: ViewAssignmentPoll, - private matSnackBar: MatSnackBar, - private translate: TranslateService, - public pollService: AssignmentPollService, - private userRepo: UserRepositoryService + @Inject(MAT_DIALOG_DATA) public data: ViewAssignmentPoll ) { - this.specialValues = this.pollService.specialPollVotes; - this.poll = this.data.poll; - - switch (this.poll.pollmethod) { + switch (this.data.pollmethod) { case 'votes': this.optionPollKeys = ['Votes']; break; @@ -97,7 +83,7 @@ export class AssignmentPollDialogComponent { * TODO better validation */ public submit(): void { - const error = this.data.options.find(dataoption => { + /*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; @@ -112,8 +98,8 @@ export class AssignmentPollDialogComponent { } ); } else { - this.dialogRef.close(this.poll); - } + this.dialogRef.close(this.data); + }*/ } /** @@ -123,7 +109,8 @@ export class AssignmentPollDialogComponent { * @returns a label for a poll option */ public getLabel(key: CalculablePollKey): string { - return this.pollService.getLabel(key); + // return this.pollService.getLabel(key); + throw new Error('TODO'); } /** @@ -133,8 +120,8 @@ export class AssignmentPollDialogComponent { * @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); + public setValue(value: PollVoteValue, candidate: ViewAssignmentOption, newData: string): void { + /*const vote = candidate.votes.find(v => v.value === value); if (vote) { vote.weight = parseFloat(newData); } else { @@ -142,7 +129,7 @@ export class AssignmentPollDialogComponent { value: value, weight: parseFloat(newData) }); - } + }*/ } /** @@ -152,9 +139,10 @@ export class AssignmentPollDialogComponent { * @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; + public getValue(value: PollVoteValue, candidate: ViewAssignmentOption): number | undefined { + /*const val = candidate.votes.find(v => v.value === value); + return val ? val.weight : undefined;*/ + throw new Error('TODO'); } /** @@ -163,8 +151,9 @@ export class AssignmentPollDialogComponent { * @param value * @returns integer or undefined */ - public getSumValue(value: SummaryPollKey): number | undefined { - return this.data[value] || undefined; + public getSumValue(value: any /*SummaryPollKey*/): number | undefined { + // return this.data[value] || undefined; + throw new Error('TODO'); } /** @@ -173,23 +162,11 @@ export class AssignmentPollDialogComponent { * @param value * @param weight */ - public setSumValue(value: SummaryPollKey, weight: string): void { - this.poll[value] = parseFloat(weight); + public setSumValue(value: any /*SummaryPollKey*/, weight: string): void { + this.data[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/assignment-poll.component.ts b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts index 0d6dee5c0..edc25f584 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,5 +1,5 @@ import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; -import { FormBuilder, FormGroup } from '@angular/forms'; +import { FormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Title } from '@angular/platform-browser'; @@ -7,17 +7,12 @@ 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 { 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 { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service'; -import { AssignmentPollService } from '../../services/assignment-poll.service'; import { ViewAssignment } from '../../models/view-assignment'; +import { ViewAssignmentOption } from '../../models/view-assignment-option'; 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 @@ -69,24 +64,27 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * used in this poll (e.g.) */ public get pollValues(): CalculablePollKey[] { - return this.pollService.getVoteOptionsByPoll(this.poll); + // return this.pollService.getVoteOptionsByPoll(this.poll); + throw new Error('TODO'); } /** * @returns true if the description on the form differs from the poll's description */ public get dirtyDescription(): boolean { - return this.descriptionForm.get('description').value !== this.poll.description; + // return this.descriptionForm.get('description').value !== this.poll.description; + throw new Error('TODO'); } /** * @returns true if vote results can be seen by the user */ public get pollData(): boolean { - if (!this.poll.has_votes) { + /*if (!this.poll.has_votes) { return false; } - return this.poll.published || this.canManage; + return this.poll.published || this.canManage;*/ + throw new Error('TODO'); } /** @@ -113,29 +111,12 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit } } - /** - * 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, - private formBuilder: FormBuilder, private pdfService: AssignmentPollPdfService ) { super(titleService, translate, matSnackBar); @@ -145,12 +126,12 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * Gets the currently selected majority choice option from the repo */ public ngOnInit(): void { - this.majorityChoice = + /*this.majorityChoice = this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) || null; this.descriptionForm = this.formBuilder.group({ description: this.poll ? this.poll.description : '' - }); + });*/ } /** @@ -159,10 +140,10 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * 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?'); + /*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); - } + }*/ } /** @@ -180,15 +161,16 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * @param option * @returns true if the quorum is successfully met */ - public quorumReached(option: ViewAssignmentPollOption): boolean { - const yesValue = this.poll.pollmethod === 'votes' ? 'Votes' : 'Yes'; + public quorumReached(option: ViewAssignmentOption): 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; + return yesQuorum && amount >= yesQuorum;*/ + throw new Error('TODO'); } /** @@ -196,15 +178,15 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * closes successfully (validation is done there) */ public enterVotes(): void { - const dialogRef = this.dialog.open(AssignmentPollDialogComponent, { - data: this.poll, + /*const dialogRef = this.dialog.open(AssignmentPollDialogComponent, { + data: this.poll.copy(), ...mediumDialogSettings }); dialogRef.afterClosed().subscribe(result => { if (result) { this.assignmentRepo.updateVotes(result, this.poll).catch(this.raiseError); } - }); + });*/ } /** @@ -220,7 +202,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * Toggles the 'published' state */ public togglePublished(): void { - this.assignmentRepo.updatePoll({ published: !this.poll.published }, this.poll); + // this.assignmentRepo.updatePoll({ published: !this.poll.published }, this.poll); } /** @@ -228,8 +210,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * * @param option */ - public toggleElected(option: ViewAssignmentPollOption): void { - if (!this.operator.hasPerms('assignments.can_manage')) { + public toggleElected(option: ViewAssignmentOption): void { + /*if (!this.operator.hasPerms('assignments.can_manage')) { return; } @@ -239,7 +221,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit ); if (viewAssignmentRelatedUser) { this.assignmentRepo.markElected(viewAssignmentRelatedUser, this.assignment, !option.is_elected); - } + }*/ } /** @@ -247,8 +229,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * 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); + /*const desc: string = this.descriptionForm.get('description').value; + await this.assignmentRepo.updatePoll({ description: desc }, this.poll).catch(this.raiseError);*/ } /** @@ -256,8 +238,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * @param option * @returns a translated */ - public getQuorumReachedString(option: ViewAssignmentPollOption): string { - const name = this.translate.instant(this.majorityChoice.display_name); + public getQuorumReachedString(option: ViewAssignmentOption): string { + /*const name = this.translate.instant(this.majorityChoice.display_name); const quorum = this.pollService.yesQuorum( this.majorityChoice, this.pollService.calculationDataFromPoll(this.poll), @@ -266,6 +248,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit const isReached = this.quorumReached(option) ? this.translate.instant('reached') : this.translate.instant('not reached'); - return `${name} (${quorum}) ${isReached}`; + return `${name} (${quorum}) ${isReached}`;*/ + throw new Error('TODO'); } } 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..a1324ee4f --- /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 { BaseViewModel } from '../../base/base-view-model'; + +export class ViewAssignmentOption extends BaseViewModel { + public get option(): AssignmentOption { + return this._model; + } + public static COLLECTIONSTRING = AssignmentOption.COLLECTIONSTRING; + protected _collectionString = AssignmentOption.COLLECTIONSTRING; +} + +export interface ViewAssignmentOption extends AssignmentOption {} 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..5306912dd 100644 --- a/client/src/app/site/assignments/models/view-assignment-poll.ts +++ b/client/src/app/site/assignments/models/view-assignment-poll.ts @@ -1,9 +1,16 @@ 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 { ViewGroup } from 'app/site/users/models/view-group'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { ViewAssignmentOption } from './view-assignment-option'; -export class ViewAssignmentPoll extends BaseProjectableViewModel { +export interface AssignmentPollTitleInformation { + title: string; +} + +export class ViewAssignmentPoll extends BaseProjectableViewModel + implements AssignmentPollTitleInformation { public static COLLECTIONSTRING = AssignmentPoll.COLLECTIONSTRING; protected _collectionString = AssignmentPoll.COLLECTIONSTRING; @@ -11,18 +18,10 @@ export class ViewAssignmentPoll extends BaseProjectableViewModel return this._model; } - public getListTitle = () => { - return this.getTitle(); - }; - - public getProjectorTitle = () => { - return this.getTitle(); - }; - public getSlide(): ProjectorElementBuildDeskriptor { - return { + /*return { getBasicProjectorElement: options => ({ - name: 'assignments/poll', + name: 'assignments/assignment-poll', assignment_id: this.assignment_id, poll_id: this.id, getIdentifiers: () => ['name', 'assignment_id', 'poll_id'] @@ -30,10 +29,15 @@ export class ViewAssignmentPoll extends BaseProjectableViewModel slideOptions: [], projectionDefaultName: 'assignments', getDialogTitle: () => 'TODO' - }; + };*/ + throw new Error('TODO'); } } -export interface ViewAssignmentPoll extends AssignmentPollWithoutNestedModels { - options: ViewAssignmentPollOption[]; +interface TIAssignmentPollRelations { + options: ViewAssignmentOption[]; + voted: ViewUser[]; + groups: ViewGroup[]; } + +export interface ViewAssignmentPoll extends AssignmentPollWithoutNestedModels, TIAssignmentPollRelations {} 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..2bd367bcc --- /dev/null +++ b/client/src/app/site/assignments/models/view-assignment-vote.ts @@ -0,0 +1,17 @@ +import { AssignmentVote } from 'app/shared/models/assignments/assignment-vote'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BaseViewModel } from '../../base/base-view-model'; + +export class ViewAssignmentVote extends BaseViewModel { + public get vote(): AssignmentVote { + return this._model; + } + public static COLLECTIONSTRING = AssignmentVote.COLLECTIONSTRING; + protected _collectionString = AssignmentVote.COLLECTIONSTRING; +} + +interface TIAssignmentVoteRelations { + user?: ViewUser; +} + +export interface ViewAssignmentVote extends AssignmentVote, TIAssignmentVoteRelations {} diff --git a/client/src/app/site/assignments/models/view-assignment.ts b/client/src/app/site/assignments/models/view-assignment.ts index 9214b1cc5..abe7291b2 100644 --- a/client/src/app/site/assignments/models/view-assignment.ts +++ b/client/src/app/site/assignments/models/view-assignment.ts @@ -45,9 +45,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 []; 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..5be7f026f 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,7 @@ 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 { 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 +12,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 * @@ -30,11 +20,7 @@ export class AssignmentPdfService { * @param pdfDocumentService PDF functions * @param htmlToPdfService Convert the assignment detail html text to pdf */ - public constructor( - private translate: TranslateService, - private pollService: AssignmentPollService, - private htmlToPdfService: HtmlToPdfService - ) {} + public constructor(private translate: TranslateService, private htmlToPdfService: HtmlToPdfService) {} /** * Main function to control the pdf generation. @@ -161,7 +147,8 @@ export class AssignmentPdfService { * @param pollOption the poll options (yes, no, maybe [...]) * @returns a line in the table */ - private electedCandidateLine(candidateName: string, pollOption: ViewAssignmentPollOption): object { + // TODO: type the result. + /*private electedCandidateLine(candidateName: string, pollOption: ViewAssignmentOption): object { if (pollOption.is_elected) { this.showIsElected = true; return { @@ -172,8 +159,8 @@ export class AssignmentPdfService { return { text: candidateName }; - } - } + }* + }*/ /** * Creates the poll result table for all published polls @@ -181,8 +168,9 @@ export class AssignmentPdfService { * @param assignment the ViewAssignment to create the document for * @returns the table as pdfmake object */ + // TODO: type the result private createPollResultTable(assignment: ViewAssignment): object { - const resultBody = []; + /*const resultBody = []; for (let pollIndex = 0; pollIndex < assignment.polls.length; pollIndex++) { const poll = assignment.polls[pollIndex]; if (poll.published) { @@ -277,7 +265,8 @@ export class AssignmentPdfService { }); } - return resultBody; + return resultBody;*/ + throw new Error('TODO'); } /** @@ -287,32 +276,32 @@ export class AssignmentPdfService { * @param optionLabel Usually Yes or No * @param value the amount of votes * @param poll the specific poll - * @param pollOption the corresponding poll option + * @param option the corresponding poll option * @returns a string a nicer number representation: "Yes 25 (22,2%)" or just "10" */ - private parseVoteValue( + /*private parseVoteValue( optionLabel: PollVoteValue, value: number, poll: ViewAssignmentPoll, - pollOption: ViewAssignmentPollOption + option: ViewAssignmentOption ): 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, + option, optionLabel ); resultString += `${label} ${valueString}`; if ( percentNr && - !this.pollService.isAbstractOption(this.pollService.calculationDataFromPoll(poll), pollOption, optionLabel) + !this.pollService.isAbstractOption(this.pollService.calculationDataFromPoll(poll), option, optionLabel) ) { resultString += ` (${percentNr}%)`; } return `${resultString}\n`; - } + }*/ } 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..aadf9b253 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,6 @@ 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 { ViewAssignmentPoll } from '../models/view-assignment-poll'; /** @@ -113,6 +112,7 @@ export class AssignmentPollPdfService extends PollPdfService { * @param title The identifier of the motion * @param subtitle The actual motion title */ + // TODO: typing of result protected createBallot(data: AbstractPollData): object { return { columns: [ @@ -136,8 +136,9 @@ export class AssignmentPollPdfService extends PollPdfService { }; } + // TODO: typing of result private createCandidateFields(poll: ViewAssignmentPoll): object { - const candidates = poll.options.sort((a, b) => { + /*const candidates = poll.options.sort((a, b) => { return a.weight - b.weight; }); const resultObject = candidates.map(cand => { @@ -150,10 +151,12 @@ export class AssignmentPollPdfService extends PollPdfService { noEntry.margin[1] = 25; resultObject.push(noEntry); } - return resultObject; + return resultObject;*/ + throw new Error('TODO'); } - private createYNBallotEntry(option: string, method: AssignmentPollMethod): object { + // TODO: typing of result + /*private createYNBallotEntry(option: string, method: AssignmentPollmethods): object { const choices = method === 'yna' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No']; const columnstack = choices.map(choice => { return { @@ -171,7 +174,7 @@ export class AssignmentPollPdfService extends PollPdfService { columns: columnstack } ]; - } + }*/ /** * Generates the poll description @@ -179,10 +182,12 @@ export class AssignmentPollPdfService extends PollPdfService { * @param poll * @returns pdfMake definitions */ + // TODO: typing of result private createPollHint(poll: ViewAssignmentPoll): object { - return { + /*return { text: poll.description || '', style: 'description' - }; + };*/ + throw new Error('TODO'); } } 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 deleted file mode 100644 index 63c7046b1..000000000 --- a/client/src/app/site/assignments/services/assignment-poll.service.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { AssignmentPollService } from './assignment-poll.service'; - -describe('PollService', () => { - beforeEach(() => TestBed.configureTestingModule({})); - - it('should be created', () => { - const service: AssignmentPollService = TestBed.get(AssignmentPollService); - expect(service).toBeTruthy(); - }); -}); diff --git a/client/src/app/site/assignments/services/assignment-poll.service.ts b/client/src/app/site/assignments/services/assignment-poll.service.ts deleted file mode 100644 index ad964e421..000000000 --- a/client/src/app/site/assignments/services/assignment-poll.service.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { Injectable } from '@angular/core'; - -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'; -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 - */ - public pollValues: CalculablePollKey[] = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast']; - - /** - * the method used for polls (as per config) - */ - public pollMethod: AssignmentPollValues; - - /** - * the method used to determine the '100%' base (set in config) - */ - public percentBase: AssignmentPercentBase; - - /** - * convenience function for displaying the available majorities - */ - public get majorityMethods(): MajorityMethod[] { - return PollMajorityMethod; - } - - /** - * Constructor. Subscribes to the configuration values needed - * - * @param config ConfigService - */ - public constructor(config: ConfigService) { - super(); - config - .get('assignments_poll_default_majority_method') - .subscribe(method => (this.defaultMajorityMethod = method)); - config - .get('assignments_poll_vote_values') - .subscribe(method => (this.pollMethod = method)); - config - .get('assignments_poll_100_percent_base') - .subscribe(base => (this.percentBase = base)); - } - - public getVoteOptionsByPoll(poll: ViewAssignmentPoll): CalculablePollKey[] { - return this.pollValues.filter(name => poll[name] !== undefined); - } - - /** - * 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); - } - } else { - return null; - } - case 'CAST': - return data.votescast > 0 && data.votesinvalid >= 0 ? data.votescast : null; - case 'VALID': - return data.votesvalid > 0 ? data.votesvalid : null; - 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 - }; - } -} 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/mediafiles/mediafile.config.ts b/client/src/app/site/mediafiles/mediafile.config.ts index 0df6a7fa0..732eec303 100644 --- a/client/src/app/site/mediafiles/mediafile.config.ts +++ b/client/src/app/site/mediafiles/mediafile.config.ts @@ -7,7 +7,6 @@ export const MediafileAppConfig: AppConfig = { name: 'mediafiles', models: [ { - collectionString: 'mediafiles/mediafile', model: Mediafile, viewModel: ViewMediafile, searchOrder: 5, diff --git a/client/src/app/site/motions/models/view-motion-option.ts b/client/src/app/site/motions/models/view-motion-option.ts new file mode 100644 index 000000000..18517d950 --- /dev/null +++ b/client/src/app/site/motions/models/view-motion-option.ts @@ -0,0 +1,12 @@ +import { MotionOption } from 'app/shared/models/motions/motion-option'; +import { BaseViewModel } from '../../base/base-view-model'; + +export class ViewMotionOption extends BaseViewModel { + public get option(): MotionOption { + return this._model; + } + public static COLLECTIONSTRING = MotionOption.COLLECTIONSTRING; + protected _collectionString = MotionOption.COLLECTIONSTRING; +} + +export interface ViewMotionPoll extends MotionOption {} diff --git a/client/src/app/site/motions/models/view-motion-poll.ts b/client/src/app/site/motions/models/view-motion-poll.ts new file mode 100644 index 000000000..ed0bdc6b4 --- /dev/null +++ b/client/src/app/site/motions/models/view-motion-poll.ts @@ -0,0 +1,41 @@ +import { MotionPoll, MotionPollWithoutNestedModels } from 'app/shared/models/motions/motion-poll'; +import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; +import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; +import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { ViewUser } from 'app/site/users/models/view-user'; + +export interface MotionPollTitleInformation { + title: string; +} + +export class ViewMotionPoll extends BaseProjectableViewModel implements MotionPollTitleInformation { + public static COLLECTIONSTRING = MotionPoll.COLLECTIONSTRING; + protected _collectionString = MotionPoll.COLLECTIONSTRING; + + public get poll(): MotionPoll { + return this._model; + } + + public getSlide(): ProjectorElementBuildDeskriptor { + /*return { + getBasicProjectorElement: options => ({ + name: Motion.COLLECTIONSTRING, + id: this.id, + getIdentifiers: () => ['name', 'id'] + }), + slideOptions: [], + projectionDefaultName: 'motions', + getDialogTitle: this.getTitle + };*/ + throw new Error('TODO'); + } +} + +interface TIMotionPollRelations { + options: ViewMotionOption[]; + voted: ViewUser[]; + groups: ViewGroup[]; +} + +export interface ViewMotionPoll extends MotionPollWithoutNestedModels, TIMotionPollRelations {} diff --git a/client/src/app/site/motions/models/view-motion-vote.ts b/client/src/app/site/motions/models/view-motion-vote.ts new file mode 100644 index 000000000..4130b563e --- /dev/null +++ b/client/src/app/site/motions/models/view-motion-vote.ts @@ -0,0 +1,17 @@ +import { MotionVote } from 'app/shared/models/motions/motion-vote'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BaseViewModel } from '../../base/base-view-model'; + +export class ViewMotionVote extends BaseViewModel { + public get vote(): MotionVote { + return this._model; + } + public static COLLECTIONSTRING = MotionVote.COLLECTIONSTRING; + protected _collectionString = MotionVote.COLLECTIONSTRING; +} + +interface TIMotionVoteRelations { + user?: ViewUser; +} + +export interface ViewMotionVote extends MotionVote, TIMotionVoteRelations {} 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..7c7962aef 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 @@ -1382,7 +1382,9 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, * Handler for creating a poll */ public createPoll(): void { - this.repo.createPoll(this.motion).catch(this.raiseError); + // TODO + // this.repo.createPoll({}).catch(this.raiseError); + throw new Error('TODO'); } /** 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 index b3c952ee2..53ff0ed4c 100644 --- 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 @@ -1,8 +1,5 @@ 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'; @@ -35,8 +32,6 @@ export class MotionPollDialogComponent { 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; @@ -57,7 +52,7 @@ export class MotionPollDialogComponent { * TODO better validation */ public submit(): void { - if (this.data.yes === undefined || this.data.no === undefined || this.data.abstain === undefined) { + /*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'), @@ -67,7 +62,7 @@ export class MotionPollDialogComponent { ); } else { this.dialogRef.close(this.data); - } + }*/ } /** 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 index d0a6295a7..fdc2e527f 100644 --- 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 @@ -5,7 +5,7 @@
-
+
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 index 64b71b9dd..43b66a711 100644 --- 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 @@ -1,21 +1,16 @@ import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; -import { MatSnackBar } from '@angular/material'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatSnackBar } from '@angular/material'; 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. @@ -97,9 +92,7 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit { matSnackBar: MatSnackBar, public dialog: MatDialog, public pollService: MotionPollService, - private motionRepo: MotionRepositoryService, private constants: ConstantsService, - private promptService: PromptService, public perms: LocalPermissionsService, private pdfService: MotionPollPdfService ) { @@ -113,7 +106,7 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit { * Subscribes to updates of itself */ public ngOnInit(): void { - this.poll = new MotionPoll(this.rawPoll); + /*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); @@ -121,17 +114,18 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit { 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?'); + /*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); - } + }*/ + throw new Error('TODO'); } /** @@ -192,7 +186,7 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit { * Triggers the 'edit poll' dialog' */ public editPoll(): void { - const dialogRef = this.dialog.open(MotionPollDialogComponent, { + /*const dialogRef = this.dialog.open(MotionPollDialogComponent, { data: { ...this.poll }, ...infoDialogSettings }); @@ -200,7 +194,8 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit { if (result) { this.motionRepo.updatePoll(result).catch(this.raiseError); } - }); + });*/ + throw new Error('TODO'); } /** @@ -209,7 +204,8 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit { * @returns true if the quorum is reached */ public get quorumYesReached(): boolean { - return this.poll.yes >= this.yesQuorum; + // return this.poll.yes >= this.yesQuorum; + return false; } /** diff --git a/client/src/app/site/motions/motions.config.ts b/client/src/app/site/motions/motions.config.ts index 6d3633403..554aaf19d 100644 --- a/client/src/app/site/motions/motions.config.ts +++ b/client/src/app/site/motions/motions.config.ts @@ -28,52 +28,44 @@ 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, 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..6cda57f65 100644 --- a/client/src/app/site/motions/services/motion-pdf.service.ts +++ b/client/src/app/site/motions/services/motion-pdf.service.ts @@ -10,11 +10,9 @@ 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 { getRecommendationTypeName } from 'app/shared/utils/recommendation-type-names'; import { MotionExportInfo } from './motion-export.service'; -import { MotionPollService } from './motion-poll.service'; import { ChangeRecoMode, InfoToExport, LineNumberingMode, PERSONAL_NOTE_ID } from '../motions.constants'; import { ViewMotion } from '../models/view-motion'; import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragraph'; @@ -62,7 +60,6 @@ export class MotionPdfService { private configService: ConfigService, private pdfDocumentService: PdfDocumentService, private htmlToPdfService: HtmlToPdfService, - private pollService: MotionPollService, private linenumberingService: LinenumberingService, private commentRepo: MotionCommentSectionRepositoryService ) {} @@ -366,7 +363,7 @@ export class MotionPdfService { const column2 = []; const column3 = []; motion.motion.polls.map((poll, index) => { - if (poll.has_votes) { + /*if (poll.has_votes) { if (motion.motion.polls.length > 1) { column1.push(index + 1 + '. ' + this.translate.instant('Vote')); column2.push(''); @@ -389,7 +386,7 @@ export class MotionPdfService { ? column3.push('') : column3.push(`(${this.pollService.calculatePercentage(poll, value)} %)`); }); - } + }*/ }); metaTableBody.push([ { 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..28757936b 100644 --- a/client/src/app/site/motions/services/motion-poll.service.ts +++ b/client/src/app/site/motions/services/motion-poll.service.ts @@ -73,7 +73,7 @@ export class MotionPollService extends PollService { * the base cannot be calculated */ public getBaseAmount(poll: MotionPoll): number { - if (!poll) { + /*if (!poll) { return 0; } switch (this.percentBase) { @@ -102,7 +102,8 @@ export class MotionPollService extends PollService { return 0; } return poll.yes + poll.no; - } + }*/ + return 0; } /** 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/tags/tag.config.ts b/client/src/app/site/tags/tag.config.ts index 99377fccc..c80a64d7e 100644 --- a/client/src/app/site/tags/tag.config.ts +++ b/client/src/app/site/tags/tag.config.ts @@ -7,7 +7,6 @@ export const TagAppConfig: AppConfig = { name: 'tag', models: [ { - collectionString: 'core/tag', model: Tag, viewModel: ViewTag, searchOrder: 8, 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/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/assignments/poll/poll-slide-data.ts b/client/src/app/slides/assignments/poll/poll-slide-data.ts index 5a4b69462..a5a3f2e32 100644 --- a/client/src/app/slides/assignments/poll/poll-slide-data.ts +++ b/client/src/app/slides/assignments/poll/poll-slide-data.ts @@ -1,5 +1,4 @@ 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; @@ -12,12 +11,12 @@ export interface PollSlideOption { export interface PollSlideData { title: string; - assignments_poll_100_percent_base: AssignmentPercentBase; + assignments_poll_100_percent_base: any /*AssignmentPercentBase*/; poll: { published: boolean; description?: string; has_votes?: boolean; - pollmethod?: AssignmentPollMethod; + pollmethod?: any /*AssignmentPollmethods*/; votesno?: string; votesabstain?: string; votesvalid?: string; diff --git a/client/src/app/slides/assignments/poll/poll-slide.component.ts b/client/src/app/slides/assignments/poll/poll-slide.component.ts index 4282af97b..6cbb944dc 100644 --- a/client/src/app/slides/assignments/poll/poll-slide.component.ts +++ b/client/src/app/slides/assignments/poll/poll-slide.component.ts @@ -1,14 +1,7 @@ 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'; @@ -20,20 +13,19 @@ import { PollSlideData, PollSlideOption } from './poll-slide-data'; export class PollSlideComponent extends BaseSlideComponent { private _data: SlideData; - private calculationData: CalculationData; - - public get pollValues(): SummaryPollKey[] { + public get pollValues(): any { + // SummaryPollKey[] { if (!this.data) { return []; } - const values: SummaryPollKey[] = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast']; + const values: any /*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 = { + /*this.calculationData = { pollMethod: data.data.poll.pollmethod, votesno: parseFloat(data.data.poll.votesno), votesabstain: parseFloat(data.data.poll.votesabstain), @@ -51,17 +43,13 @@ export class PollSlideComponent extends BaseSlideComponent { }; }), 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 @@ -70,7 +58,7 @@ export class PollSlideComponent extends BaseSlideComponent { * @param option */ public getVotePercent(key: PollVoteValue, option: PollSlideOption): string { - const calcOption = { + /*const calcOption = { votes: option.votes.map(vote => { return { weight: parseFloat(vote.weight), value: vote.value }; }) @@ -79,19 +67,22 @@ export class PollSlideComponent extends BaseSlideComponent { const number = this.translate.instant( this.pollService.getSpecialLabel(parseFloat(option.votes.find(v => v.value === key).weight)) ); - return percent === null ? number : `${number} (${percent}%)`; + return percent === null ? number : `${number} (${percent}%)`;*/ + throw new Error('TODO'); } public getPollPercent(key: CalculablePollKey): string { - const percent = this.pollService.getValuePercent(this.calculationData, key); + /*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}%)`; + return percent === null ? number : `${number} (${percent}%)`;*/ + throw new Error('TODO'); } /** * @returns a translated label for a key */ public getLabel(key: CalculablePollKey): string { - return this.translate.instant(this.pollService.getLabel(key)); + // return this.translate.instant(this.pollService.getLabel(key)); + throw new Error('TODO'); } } 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/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/assignments/access_permissions.py b/openslides/assignments/access_permissions.py index f37ed22d6..2afba46f1 100644 --- a/openslides/assignments/access_permissions.py +++ b/openslides/assignments/access_permissions.py @@ -42,3 +42,12 @@ class AssignmentAccessPermissions(BaseAccessPermissions): data = [] return data + + +class AssignmentPollAccessPermissions(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]]: + return full_data diff --git a/openslides/assignments/apps.py b/openslides/assignments/apps.py index 2c4e9b018..aa5b611e2 100644 --- a/openslides/assignments/apps.py +++ b/openslides/assignments/apps.py @@ -15,7 +15,11 @@ 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, + AssignmentVoteViewSet, + ) # Define projector elements. register_projector_slides() @@ -30,7 +34,14 @@ 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("AssignmentVote").get_collection_string(), + AssignmentVoteViewSet, + ) # Register required_users required_user.add_collection_string( @@ -47,13 +58,16 @@ 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"): + yield self.get_model(model_name) def required_users(element: Dict[str, Any]) -> Set[int]: """ Returns all user ids that are displayed as candidates (including poll options) in the assignment element. + + TODO: Adapt this method for new poll structure!! """ candidates = set( related_user["user_id"] for related_user in element["assignment_related_users"] diff --git a/openslides/assignments/config_variables.py b/openslides/assignments/config_variables.py index a7bc6ead4..5a7e40f9a 100644 --- a/openslides/assignments/config_variables.py +++ b/openslides/assignments/config_variables.py @@ -12,25 +12,6 @@ def get_config_variables(): to be evaluated during app loading (see apps.py). """ # Ballot and ballot papers - yield ConfigVariable( - name="assignments_poll_vote_values", - default_value="auto", - 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"}, - ), - weight=410, - group="Elections", - subgroup="Ballot and ballot papers", - ) - yield ConfigVariable( name="assignments_poll_100_percent_base", default_value="YES_NO_ABSTAIN", diff --git a/openslides/assignments/migrations/0008_auto_20191017_1040.py b/openslides/assignments/migrations/0008_auto_20191017_1040.py new file mode 100644 index 000000000..5a8b4aac2 --- /dev/null +++ b/openslides/assignments/migrations/0008_auto_20191017_1040.py @@ -0,0 +1,139 @@ +# 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.RemoveField(model_name="assignmentpoll", name="description"), + migrations.RemoveField(model_name="assignmentpoll", name="published"), + 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="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="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="voted", + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name="assignmentpoll", + name="allow_multiple_votes_per_candidate", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="assignmentpoll", + name="pollmethod", + field=models.CharField( + choices=[("YN", "YN"), ("YNA", "YNA"), ("votes", "votes")], max_length=5 + ), + ), + migrations.AlterField( + model_name="assignmentvote", + name="value", + field=models.CharField( + choices=[("Y", "Y"), ("N", "N"), ("A", "A")], max_length=1 + ), + ), + 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.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" + ), + migrations.RemoveField(model_name="assignmentpoll", name="votesabstain"), + migrations.RemoveField(model_name="assignmentpoll", name="votesno"), + ] diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 062246909..1446c07c8 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -1,5 +1,4 @@ from collections import OrderedDict -from decimal import Decimal from typing import Any, Dict, List from django.conf import settings @@ -11,19 +10,16 @@ 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.models import RESTModelMixin from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE -from .access_permissions import AssignmentAccessPermissions +from .access_permissions import ( + AssignmentAccessPermissions, + AssignmentPollAccessPermissions, +) class AssignmentRelatedUser(RESTModelMixin, models.Model): @@ -196,7 +192,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model """ return self.elected.filter(pk=user.pk).exists() - def set_candidate(self, user): + def add_candidate(self, user): """ Adds the user as candidate. """ @@ -215,7 +211,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model 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,59 +229,6 @@ 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 @@ -332,88 +275,76 @@ class AssignmentVote(RESTModelMixin, BaseVote): class Meta: default_permissions = () - def get_root_rest_element(self): - """ - Returns the assignment to this instance which is the root REST element. - """ - return self.option.poll.assignment - class AssignmentOption(RESTModelMixin, BaseOption): + vote_class = AssignmentVote + poll = models.ForeignKey( "AssignmentPoll", on_delete=models.CASCADE, 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): - """ - Returns the assignment to this instance which is the root REST element. - """ - return self.poll.assignment + return self.poll +# Meta-TODO: Is this todo resolved? # 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() option_class = AssignmentOption assignment = models.ForeignKey( Assignment, on_delete=models.CASCADE, related_name="polls" ) - pollmethod = models.CharField(max_length=5, default="yna") - description = models.CharField(max_length=79, blank=True) - votesabstain = models.DecimalField( - null=True, - blank=True, - validators=[MinValueValidator(Decimal("-2"))], - max_digits=15, - decimal_places=6, - ) - """ General abstain votes, used for pollmethod 'votes' """ - votesno = models.DecimalField( - null=True, - blank=True, - validators=[MinValueValidator(Decimal("-2"))], - max_digits=15, - decimal_places=6, - ) - """ General no votes, used for pollmethod 'votes' """ + POLLMETHOD_YN = "YN" + POLLMETHOD_YNA = "YNA" + POLLMETHOD_VOTES = "votes" + POLLMETHODS = (("YN", "YN"), ("YNA", "YNA"), ("votes", "votes")) + pollmethod = models.CharField(max_length=5, choices=POLLMETHODS) + + global_abstain = models.BooleanField(default=True) + global_no = models.BooleanField(default=True) + + 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 create_options(self): + related_users = AssignmentRelatedUser.objects.filter( + assignment__id=self.assignment.id + ).exclude(elected=True) + options = [ + AssignmentOption( + user=related_user.user, weight=related_user.weight, poll=self + ) + for related_user in related_users + ] + AssignmentOption.objects.bulk_create(options) + inform_changed_data(self) - def get_vote_values(self): - if self.pollmethod == "yna": - return ["Yes", "No", "Abstain"] - if self.pollmethod == "yn": - return ["Yes", "No"] - return ["Votes"] - - def get_ballot(self): - return self.assignment.polls.filter(id__lte=self.pk).count() - - def get_percent_base_choice(self): - return config["assignments_poll_100_percent_base"] - - def get_root_rest_element(self): - """ - Returns the assignment to this instance which is the root REST element. - """ - return self.assignment + # Add all candidates to list of speakers of related agenda item + if config["assignments_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 + inform_changed_data(self.assignment.list_of_speakers) diff --git a/openslides/assignments/serializers.py b/openslides/assignments/serializers.py index 7c061d143..b9f7a8ebb 100644 --- a/openslides/assignments/serializers.py +++ b/openslides/assignments/serializers.py @@ -1,18 +1,20 @@ -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, +) from openslides.utils.rest_api import ( BooleanField, + CharField, DecimalField, - DictField, + IdPrimaryKeyRelatedField, IntegerField, - ListField, ModelSerializer, SerializerMethodField, ValidationError, ) -from ..utils.auth import has_perm +from ..utils.auth import get_group_model, has_perm from ..utils.autoupdate import inform_changed_data from ..utils.validate import validate_html from .models import ( @@ -42,13 +44,7 @@ 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", "elected", "weight") class AssignmentVoteSerializer(ModelSerializer): @@ -56,9 +52,15 @@ class AssignmentVoteSerializer(ModelSerializer): Serializer for assignment.models.AssignmentVote objects. """ + pollstate = SerializerMethodField() + class Meta: model = AssignmentVote - fields = ("weight", "value") + fields = ("pollstate",) + BASE_VOTE_FIELDS + read_only_fields = BASE_VOTE_FIELDS + + def get_pollstate(self, vote): + return vote.option.poll.state class AssignmentOptionSerializer(ModelSerializer): @@ -66,24 +68,21 @@ class AssignmentOptionSerializer(ModelSerializer): Serializer for assignment.models.AssignmentOption objects. """ - votes = AssignmentVoteSerializer(many=True, read_only=True) - is_elected = SerializerMethodField() + 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 + ) + + votes = IdPrimaryKeyRelatedField(many=True, read_only=True) 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",) + BASE_OPTION_FIELDS + read_only_fields = ("user",) + BASE_OPTION_FIELDS -class AssignmentAllPollSerializer(ModelSerializer): +class AssignmentPollSerializer(ModelSerializer): """ Serializer for assignment.models.AssignmentPoll objects. @@ -91,103 +90,42 @@ class AssignmentAllPollSerializer(ModelSerializer): """ 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, + + title = CharField(allow_blank=False, required=True) + groups = IdPrimaryKeyRelatedField( + many=True, required=False, queryset=get_group_model().objects.all() + ) + 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 ) - 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,) + "pollmethod", + "votes_amount", + "allow_multiple_votes_per_candidate", + "global_no", + "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): - """ - 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}] - """ - # 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 + """ Prevent from updating the assignment """ + validated_data.pop("assignment", None) + return super().update(instance, validated_data) -class AssignmentFullSerializer(ModelSerializer): +class AssignmentSerializer(ModelSerializer): """ Serializer for assignment.models.Assignment objects. With all polls. """ @@ -195,7 +133,7 @@ class AssignmentFullSerializer(ModelSerializer): assignment_related_users = AssignmentRelatedUserSerializer( many=True, read_only=True ) - polls = AssignmentAllPollSerializer(many=True, read_only=True) + polls = IdPrimaryKeyRelatedField(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 diff --git a/openslides/assignments/views.py b/openslides/assignments/views.py index d0ac09214..656294e1e 100644 --- a/openslides/assignments/views.py +++ b/openslides/assignments/views.py @@ -1,21 +1,21 @@ +from decimal import Decimal + from django.contrib.auth import get_user_model from django.db import transaction +from openslides.poll.views import 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, AssignmentPoll, AssignmentRelatedUser, AssignmentVote # Viewsets for the REST API @@ -48,7 +48,6 @@ class AssignmentViewSet(ModelViewSet): "update", "destroy", "mark_elected", - "create_poll", "sort_related_users", ): result = has_perm(self.request.user, "assignments.can_see") and has_perm( @@ -98,7 +97,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,7 +120,7 @@ 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): @@ -186,7 +185,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) @@ -211,7 +210,7 @@ class AssignmentViewSet(ModelViewSet): "args": [str(user)], } ) - assignment.delete_related_user(user) + assignment.remove_candidate(user) return Response( {"detail": "Candidate {0} was withdrawn successfully.", "args": [str(user)]} ) @@ -243,7 +242,7 @@ class AssignmentViewSet(ModelViewSet): "args": [str(user)], } ) - assignment.set_candidate(user) + assignment.add_candidate(user) message = "User {0} was successfully unelected." return Response({"detail": message, "args": [str(user)]}) @@ -309,7 +308,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 +316,246 @@ 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": "Can not create poll because there are no candidates."} + ) + + super().perform_create(serializer) + + def handle_analog_vote(self, data, poll, user): + """ + Request data: + { + "options": {: {"Y": , ["N": ], ["A": ] }}, + ["votesvalid": ], ["votesinvalid": ], ["votescast": ], + ["global_no": ], ["global_abstain": ] + } + All amounts are decimals as strings + + required fields per pollmethod: + - votes: Y + - YN: YN + - YNA: YNA + """ + 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"}) + self.parse_decimal_value(value.get("Y"), min_value=-2) + if poll.pollmethod in ( + AssignmentPoll.POLLMETHOD_YN, + AssignmentPoll.POLLMETHOD_YNA, + ): + self.parse_decimal_value(value.get("N"), min_value=-2) + if poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA: + self.parse_decimal_value(value.get("A"), min_value=-2) + + # Check and set votes* values here, because this might raise errors. + if "votesvalid" in data: + poll.votesvalid = self.parse_decimal_value(data["votesvalid"], min_value=-2) + if "votesinvalid" in data: + poll.votesinvalid = self.parse_decimal_value( + data["votesinvalid"], min_value=-2 + ) + if "votescast" in data: + poll.votescast = self.parse_decimal_value(data["votescast"], min_value=-2) + + 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 "global_no" in data and global_no_enabled: + self.parse_decimal_value(data["votescast"], min_value=-2) + if "global_abstain" in data and global_abstain_enabled: + self.parse_decimal_value(data["votescast"], min_value=-2) + + options = poll.get_options() + + # Check, if all options were given + db_option_ids = set(option.id for option in options) + data_option_ids = set(int(option_id) for option_id in options_data.keys()) + if data_option_ids != db_option_ids: + raise ValidationError( + {"error": "You have to provide values for all options"} + ) + + # TODO: make this atomic + for option_id, vote in options_data.items(): + option = options.get(pk=int(option_id)) + Y = self.parse_decimal_value(vote["Y"], min_value=-2) + AssignmentVote.objects.create(option=option, value="Y", weight=Y) + + if poll.pollmethod in ( + AssignmentPoll.POLLMETHOD_YN, + AssignmentPoll.POLLMETHOD_YNA, + ): + N = self.parse_decimal_value(vote["N"], min_value=-2) + AssignmentVote.objects.create(option=option, value="N", weight=N) + + if poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA: + A = self.parse_decimal_value(vote["A"], min_value=-2) + AssignmentVote.objects.create(option=option, value="A", weight=A) + + # Create votes for global no and global abstain + first_option = options.first() + if "global_no" in data and global_no_enabled: + global_no = self.parse_decimal_value(data["votescast"], min_value=-2) + AssignmentVote.objects.create( + option=first_option, value="N", weight=global_no + ) + if "global_abstain" in data and global_abstain_enabled: + global_abstain = self.parse_decimal_value(data["votescast"], min_value=-2) + AssignmentVote.objects.create( + option=first_option, value="A", weight=global_abstain + ) + + poll.state = AssignmentPoll.STATE_FINISHED # directly stop the poll + poll.save() + + def validate_vote_data(self, data, poll): + 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 is_int(amount): + raise ValidationError({"detail": "Each amounts must be int"}) + amount = int(amount) + if amount < 1: + raise ValidationError({"detail": "At least 1 vote per option"}) + 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 have to give exactly {0} votes", + "args": [poll.votes_amount], + } + ) + # Check, if all options are valid + db_option_ids = set(option.id for option in poll.get_options()) + data_option_ids = set(int(option_id) for option_id in data.keys()) + if len(data_option_ids - db_option_ids): + raise ValidationError({"error": "There are invalid option ids."}) + elif data == "N" and poll.global_no: + pass + elif data == "A" and poll.global_abstain: + pass + 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 poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA and value not in ( + "Y", + "N", + "A", + ): + raise ValidationError("Every value must be Y, N or A") + elif poll.pollmethod == AssignmentPoll.POLLMETHOD_YN and value not in ( + "Y", + "N", + ): + raise ValidationError("Every value must be Y or N") + + # Check, if all options were given + db_option_ids = set(option.id for option in poll.get_options()) + data_option_ids = set(int(option_id) for option_id in data.keys()) + if data_option_ids != db_option_ids: + raise ValidationError( + {"error": "You have to provide values for all options"} + ) + + def create_votes(self, data, poll, user=None): + options = poll.get_options() + if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: + if isinstance(data, dict): + for option_id, amount in data.items(): + option = options.get(pk=option_id) + vote = AssignmentVote.objects.create( + option=option, user=user, weight=Decimal(amount), value="Y" + ) + inform_changed_data(vote, no_delete_on_restriction=True) + else: + option = options.first() + vote = AssignmentVote.objects.create( + option=option, user=user, weight=Decimal(1), value=data + ) + inform_changed_data(vote, no_delete_on_restriction=True) + elif poll.pollmethod in ( + AssignmentPoll.POLLMETHOD_YN, + AssignmentPoll.POLLMETHOD_YNA, + ): + pass + # TODO + + def handle_named_vote(self, data, poll, user): + """ + Request data for votes pollmethod: + {: } | '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 + - amonts must be integer numbers >= 1. + - ids should be integers of valid option ids for this poll + - amounts must be one ("1"), if poll.allow_multiple_votes_per_candidate if False + - The sum of all amounts must be poll.votes_amount votes + + Request data for YN/YNA pollmethod: + {: 'Y' | 'N' [|'A']} + - all option_ids must be given + - 'A' is only allowed in YNA pollmethod + """ + self.validate_vote_data(data, poll) + # Instead of reusing all existing votes for the user, delete all previous votes + for vote in poll.get_votes().filter(user=user): + vote.delete() + self.create_votes(data, poll, user) + + def handle_pseudoanonymous_vote(self, data, poll): + """ + For request data see handle_named_vote + """ + self.validate_vote_data(data, poll) + self.create_votes(data, poll) + + +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/migrations/0026_remove_history_restricted.py b/openslides/core/migrations/0026_remove_history_restricted.py new file mode 100644 index 000000000..94368becd --- /dev/null +++ b/openslides/core/migrations/0026_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", "0025_projector_color")] + + operations = [migrations.RemoveField(model_name="history", name="restricted")] diff --git a/openslides/core/models.py b/openslides/core/models.py index ab8697a7e..b0a793924 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -273,7 +273,6 @@ class HistoryManager(models.Manager): ), now=history_time, information=element.get("information", []), - restricted=element.get("restricted", False), user_id=element.get("user_id"), full_data=data, ) @@ -324,8 +323,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/motions/access_permissions.py b/openslides/motions/access_permissions.py index 621666bfb..b27e7dccc 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -179,3 +179,64 @@ class StateAccessPermissions(BaseAccessPermissions): """ base_permission = "motions.can_see" + + +class MotionPollAccessPermissions(BaseAccessPermissions): + base_permission = "motions.can_see" + STATE_PUBLISHED = 4 + + 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 voted_id field from the poll + """ + + if await async_has_perm(user_id, "motions.can_manage_polls"): + data = full_data + else: + data = [] + for poll in full_data: + if poll["state"] != self.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 option in poll["options"]: + del option["yes"] + del option["no"] + del option["abstain"] + data.append(poll) + return data + + +class MotionVoteAccessPermissions(BaseAccessPermissions): + base_permission = "motions.can_see" + STATE_PUBLISHED = 4 + + 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, "motions.can_manage_polls"): + data = full_data + else: + data = [ + vote + for vote in full_data + if vote["pollstate"] == self.STATE_PUBLISHED + or vote["user_id"] == user_id + ] + return data diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py index 73e9319dd..ebd922a19 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -20,6 +20,7 @@ class MotionsAppConfig(AppConfig): StatuteParagraphViewSet, MotionViewSet, MotionCommentSectionViewSet, + MotionVoteViewSet, MotionBlockViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, @@ -66,6 +67,9 @@ class MotionsAppConfig(AppConfig): router.register( self.get_model("MotionPoll").get_collection_string(), MotionPollViewSet ) + router.register( + self.get_model("MotionVote").get_collection_string(), MotionVoteViewSet + ) router.register(self.get_model("State").get_collection_string(), StateViewSet) # Register required_users @@ -92,6 +96,8 @@ class MotionsAppConfig(AppConfig): "State", "MotionChangeRecommendation", "MotionCommentSection", + "MotionPoll", + "MotionVote", ): yield self.get_model(model_name) diff --git a/openslides/motions/migrations/0033_auto_20191017_1100.py b/openslides/motions/migrations/0033_auto_20191017_1100.py new file mode 100644 index 000000000..2dac920de --- /dev/null +++ b/openslides/motions/migrations/0033_auto_20191017_1100.py @@ -0,0 +1,146 @@ +# 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="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="voted", + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name="motionvote", + name="option", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + 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=django.db.models.deletion.CASCADE, + related_name="options", + to="motions.MotionPoll", + ), + ), + 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/models.py b/openslides/motions/models.py index 7b619c7ec..e3f22fee7 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -8,12 +8,7 @@ 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.models import RESTModelMixin @@ -26,6 +21,8 @@ from .access_permissions import ( MotionBlockAccessPermissions, MotionChangeRecommendationAccessPermissions, MotionCommentSectionAccessPermissions, + MotionPollAccessPermissions, + MotionVoteAccessPermissions, StateAccessPermissions, StatuteParagraphAccessPermissions, WorkflowAccessPermissions, @@ -80,6 +77,11 @@ class MotionManager(models.Manager): "agenda_items", "lists_of_speakers", "polls", + "polls__groups", + "polls__voted", + "polls__options", + "polls__options__votes", + "polls__options__votes__user", "attachments", "tags", "submitters", @@ -269,6 +271,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 +427,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): """ @@ -881,82 +868,48 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode class MotionVote(RESTModelMixin, BaseVote): - """Saves the votes for a MotionPoll. - - 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.""" + access_permissions = MotionVoteAccessPermissions() + option = models.ForeignKey( + "MotionOption", on_delete=models.CASCADE, related_name="votes" + ) class Meta: default_permissions = () - def get_root_rest_element(self): - """ - Returns the motion to this instance which is the root REST element. - """ - return self.option.poll.motion - 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.""" - vote_class = MotionVote - """The VoteClass, to witch this Class links.""" + + poll = models.ForeignKey( + "MotionPoll", related_name="options", on_delete=models.CASCADE + ) class Meta: default_permissions = () def get_root_rest_element(self): - """ - Returns the motion to this instance which is the root REST element. - """ - return self.poll.motion + return self.poll +# Meta-TODO: Is this todo resolved? # 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.""" +class MotionPoll(RESTModelMixin, BasePoll): + access_permissions = MotionPollAccessPermissions() + option_class = MotionOption motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls") - """The motion to witch the object belongs.""" - 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'.""" + 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): diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index c3397cf22..24a48f00c 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -1,17 +1,19 @@ -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, +) + 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 +30,9 @@ from .models import ( MotionChangeRecommendation, MotionComment, MotionCommentSection, + MotionOption, MotionPoll, + MotionVote, State, StatuteParagraph, Submitter, @@ -220,116 +224,65 @@ class AmendmentParagraphsJSONSerializerField(Field): return data +class MotionVoteSerializer(ModelSerializer): + pollstate = SerializerMethodField() + + class Meta: + model = MotionVote + fields = ("pollstate",) + BASE_VOTE_FIELDS + read_only_fields = BASE_VOTE_FIELDS + + def get_pollstate(self, vote): + return vote.option.poll.state + + +class MotionOptionSerializer(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 + ) + + votes = IdPrimaryKeyRelatedField(many=True, read_only=True) + + class Meta: + model = MotionOption + fields = BASE_OPTION_FIELDS + read_only_fields = BASE_OPTION_FIELDS + + class MotionPollSerializer(ModelSerializer): """ 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, + options = MotionOptionSerializer(many=True, read_only=True) + + title = CharField(allow_blank=False, required=True) + groups = IdPrimaryKeyRelatedField( + many=True, required=False, queryset=get_group_model().objects.all() + ) + 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 ) - 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'. - - 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 + """ Prevent from updating the motion """ + validated_data.pop("motion", None) + return super().update(instance, validated_data) class MotionChangeRecommendationSerializer(ModelSerializer): @@ -418,7 +371,7 @@ class MotionSerializer(ModelSerializer): """ comments = MotionCommentSerializer(many=True, read_only=True) - polls = MotionPollSerializer(many=True, read_only=True) + polls = IdPrimaryKeyRelatedField(many=True, read_only=True) modified_final_version = CharField(allow_blank=True, required=False) reason = CharField(allow_blank=True, required=False) state_restriction = SerializerMethodField() diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 98140c2f6..629f8a3cf 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 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, @@ -43,13 +42,13 @@ from .models import ( MotionComment, MotionCommentSection, MotionPoll, + MotionVote, State, StatuteParagraph, Submitter, Workflow, ) from .numbering import numbering -from .serializers import MotionPollSerializer # Viewsets for the REST API @@ -87,7 +86,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" @@ -400,9 +398,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 +1038,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 +1108,7 @@ class MotionViewSet(TreeSortMixin, ModelViewSet): ) -class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class MotionPollViewSet(BasePollViewSet): """ API endpoint for motion polls. @@ -1145,9 +1116,8 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): """ queryset = MotionPoll.objects.all() - serializer_class = MotionPollSerializer - def check_view_permissions(self): + def has_manage_permissions(self): """ Returns True if the user has required permissions. """ @@ -1155,16 +1125,30 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): self.request.user, "motions.can_manage_metadata" ) + 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): """ 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 +1162,75 @@ 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): + """ + Request data: + { "Y": , "N": , ["A": ], + ["votesvalid": ], ["votesinvalid": ], ["votescast": ]} + All amounts are decimals as strings + """ + if not isinstance(data, dict): + raise ValidationError({"detail": "Data must be a dict"}) + Y = self.parse_decimal_value(data.get("Y"), min_value=-2) + N = self.parse_decimal_value(data.get("N"), min_value=-2) + if poll.pollmethod == MotionPoll.POLLMETHOD_YNA: + A = self.parse_decimal_value(data.get("A"), min_value=-2) + + option = poll.options.get() + MotionVote.objects.create(option=option, value="Y", weight=Y) + MotionVote.objects.create(option=option, value="N", weight=N) + if poll.pollmethod == MotionPoll.POLLMETHOD_YNA: + MotionVote.objects.create(option=option, value="A", weight=A) + + if "votesvalid" in data: + poll.votesvalid = self.parse_decimal_value(data["votesvalid"], min_value=-2) + if "votesinvalid" in data: + poll.votesinvalid = self.parse_decimal_value( + data["votesinvalid"], min_value=-2 + ) + if "votescast" in data: + poll.votescast = self.parse_decimal_value(data["votescast"], min_value=-2) + + poll.state = MotionPoll.STATE_FINISHED # directly stop the poll + poll.save() + + def validate_vote_data(self, data, poll): + 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") + + def handle_named_vote(self, data, poll, user): + self.validate_vote_data(data, poll) + + option = poll.options.get() + vote, _ = MotionVote.objects.get_or_create(user=user, option=option) + self.set_vote_data(data, vote, poll) + + def handle_pseudoanonymous_vote(self, data, poll): + self.validate_vote_data(data, poll) + + option = poll.options.get() + vote = MotionVote.objects.create(option=option) + self.set_vote_data(data, vote, poll) + + def set_vote_data(self, data, vote, poll): + vote.value = data + vote.weight = Decimal("1") + vote.save(no_delete_on_restriction=True) + + +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 +1668,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/models.py b/openslides/poll/models.py index 146342814..29c48770e 100644 --- a/openslides/poll/models.py +++ b/openslides/poll/models.py @@ -1,20 +1,44 @@ -import locale from decimal import Decimal from typing import Optional, 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_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 + + def get_root_rest_element(self): + return self.option.get_root_rest_element() + 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,146 +46,135 @@ class BaseOption(models.Model): class Meta: abstract = True - def get_votes(self): - return self.get_vote_class().objects.filter(option=self) + @property + def yes(self) -> Decimal: + return self.sum_weight("Y") - def get_vote_class(self): - if self.vote_class is None: + @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 {self} has to have an attribute vote_class." + f"The option class {cls} has to have an attribute vote_class." ) - return self.vote_class + return cls.vote_class - def __getitem__(self, name): - try: - return self.get_votes().get(value=name) - except self.get_vote_class().DoesNotExist: - raise KeyError - - -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() + def get_root_rest_element(self): + return self.poll.get_root_rest_element() 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, + ) 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.count_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.count_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 count_users_voted(self): + return self.voted.all().count() def get_options(self): """ @@ -169,75 +182,49 @@ class BasePoll(models.Model): """ return self.get_option_class().objects.filter(poll=self) - def get_option_class(self): - """ - Returns the option class for the poll. Default is self.option_class. - """ - return self.option_class + def create_options(self): + """ Should be called after creation of this model. """ + raise NotImplementedError() - def get_vote_values(self): - """ - Returns the possible values for the poll. Default is as list. - """ - return self.vote_values + @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_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): """ Return a QuerySet with all vote objects related to this poll. + + TODO: This might be a performance issue when used in properties that are serialized. """ 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 vote in self.get_votes(): + vote.user = None + vote.save() - 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): + self.voted.clear() + # 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) -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..ebf5409f8 100644 --- a/openslides/poll/serializers.py +++ b/openslides/poll/serializers.py @@ -1,19 +1,16 @@ -from ..utils.rest_api import ValidationError +BASE_POLL_FIELDS = ( + "state", + "type", + "title", + "groups", + "votesvalid", + "votesinvalid", + "votescast", + "options", + "voted", + "id", +) +BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain", "votes") -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: - if ( - key in ("votesvalid", "votesinvalid", "votescast") - and data[key] is not None - and data[key] < -2 - ): - raise ValidationError( - {"detail": "Value for {0} must not be less than -2", "args": [key]} - ) - return data +BASE_VOTE_FIELDS = ("id", "weight", "value", "user") diff --git a/openslides/poll/views.py b/openslides/poll/views.py new file mode 100644 index 000000000..4772f9cdb --- /dev/null +++ b/openslides/poll/views.py @@ -0,0 +1,165 @@ +from django.contrib.auth.models import AnonymousUser + +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): + 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() + + def perform_create(self, serializer): + poll = serializer.save() + poll.create_options() + + def update(self, *args, **kwargs): + """ + Customized view endpoint to update a motion poll. + """ + poll = self.get_object() + + if poll.state != BasePoll.STATE_CREATED: + raise ValidationError( + {"detail": "You can just edit a poll if it was not started."} + ) + + return super().update(*args, **kwargs) + + @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() + 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()) + 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()) + 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() + + if poll.state not in (BasePoll.STATE_FINISHED, BasePoll.STATE_PUBLISHED): + raise ValidationError( + {"detail": "You can only reset this poll after it is finished"} + ) + + 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 poll.state != BasePoll.STATE_STARTED: + raise ValidationError({"detail": "You cannot vote for an unstarted poll"}) + + if isinstance(request.user, AnonymousUser): + self.permission_denied(request) + + # check permissions based on poll type and handle requests + if poll.type == BasePoll.TYPE_ANALOG: + if not self.has_manage_permissions(): + self.permission_denied(request) + + self.handle_analog_vote(request.data, poll, request.user) + # special: change the poll state to finished. + poll.state = BasePoll.STATE_FINISHED + poll.save() + + elif poll.type == BasePoll.TYPE_NAMED: + self.assert_can_vote(poll, request) + self.handle_named_vote(request.data, poll, request.user) + poll.voted.add(request.user) + + elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: + self.assert_can_vote(poll, request) + + if request.user in poll.voted.all(): + raise ValidationError( + {"detail": "You have already voted for this poll."} + ) + self.handle_pseudoanonymous_vote(request.data, poll) + poll.voted.add(request.user) + + inform_changed_data(poll) # needed for the changed voted relation + return Response() + + def assert_can_vote(self, poll, request): + """ + Raises a permission denied, if the user is not in a poll group + and present + """ + if not request.user.is_present or not in_some_groups( + request.user.id, poll.groups.all(), exact=True + ): + self.permission_denied(request) + + def parse_decimal_value(self, value, min_value=None): + """ Raises a ValidationError on incorrect values """ + field = DecimalField(min_value=min_value, max_digits=15, decimal_places=6) + return field.to_internal_value(value) + + +class BaseVoteViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + pass diff --git a/openslides/utils/auth.py b/openslides/utils/auth.py index 7407370a0..36403a983 100644 --- a/openslides/utils/auth.py +++ b/openslides/utils/auth.py @@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ImproperlyConfigured from django.db.models import Model +from django.db.models.query import QuerySet from .cache import element_cache @@ -111,28 +112,40 @@ 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: +def in_some_groups( + user_id: int, groups: Union[List[int], QuerySet], 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: Union[List[int], QuerySet], 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. """ + if isinstance(groups, QuerySet): + groups = [group.pk for group in groups] + if not user_id and not await async_anonymous_is_enabled(): in_some_groups = False elif not user_id: @@ -144,7 +157,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..6221a72d4 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -28,13 +28,18 @@ class Element(ElementBase, total=False): 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. + + 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 AutoupdateFormat = TypedDict( @@ -53,7 +58,7 @@ def inform_changed_data( instances: Union[Iterable[Model], Model], information: List[str] = None, user_id: Optional[int] = None, - restricted: bool = False, + no_delete_on_restriction: bool = False, ) -> None: """ Informs the autoupdate system and the caching system about the creation or @@ -84,8 +89,8 @@ def inform_changed_data( collection_string=root_instance.get_collection_string(), full_data=root_instance.get_full_data(), information=information, - restricted=restricted, user_id=user_id, + no_delete_on_restriction=no_delete_on_restriction, ) bundle = autoupdate_bundle.get(threading.get_ident()) @@ -101,7 +106,6 @@ 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 @@ -119,7 +123,6 @@ def inform_deleted_data( collection_string=deleted_element[0], full_data=None, information=information, - restricted=restricted, user_id=user_id, ) @@ -197,7 +200,12 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: cache_elements: Dict[str, Optional[Dict[str, Any]]] = {} for element in elements: element_id = get_element_id(element["collection_string"], element["id"]) - cache_elements[element_id] = element["full_data"] + full_data = element["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: diff --git a/openslides/utils/cache.py b/openslides/utils/cache.py index 59bd2d2d5..2cf03d41b 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( @@ -319,6 +333,16 @@ class ElementCache: # 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 +351,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/models.py b/openslides/utils/models.py index f9c9dea7b..b9aa4cc8e 100644 --- a/openslides/utils/models.py +++ b/openslides/utils/models.py @@ -8,7 +8,7 @@ from . import logging from .access_permissions import BaseAccessPermissions from .autoupdate import Element, inform_changed_data, inform_changed_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: 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..f739f1304 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,14 @@ 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 + + _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/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..b0b898a3d 100644 --- a/tests/integration/agenda/test_viewset.py +++ b/tests/integration/agenda/test_viewset.py @@ -16,7 +16,7 @@ from openslides.motions.models import Motion 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.test_case import TestCase from ...common_groups import GROUP_DEFAULT_PK from ..helpers import count_queries @@ -284,25 +284,11 @@ 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 +369,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]), @@ -392,6 +378,7 @@ class ManageSpeaker(TestCase): self.assertEqual(response.status_code, 403) def test_remove_someone_else(self): + print(self.user) speaker = Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.delete( reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), @@ -419,7 +406,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 +420,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 +501,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..d2faefa9b --- /dev/null +++ b/tests/integration/assignments/test_polls.py @@ -0,0 +1,507 @@ +from decimal import Decimal + +from django.urls import reverse +from rest_framework import status + +from openslides.assignments.models import ( + Assignment, + AssignmentOption, + AssignmentPoll, + AssignmentVote, +) +from openslides.poll.models import BasePoll +from openslides.utils.auth import get_group_model +from tests.test_case import TestCase + + +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): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_ailai4toogh3eefaa2Vo", + "pollmethod": "YNA", + "type": "named", + "assignment_id": self.assignment.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(AssignmentPoll.objects.exists()) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo") + self.assertEqual(poll.pollmethod, "YNA") + self.assertEqual(poll.type, "named") + # Check defaults + self.assertTrue(poll.global_no) + self.assertTrue(poll.global_abstain) + self.assertFalse(poll.allow_multiple_votes_per_candidate) + self.assertEqual(poll.votes_amount, 1) + self.assertEqual(poll.assignment.id, self.assignment.id) + 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": "YN", + "type": "pseudoanonymous", + "assignment_id": self.assignment.id, + "global_no": False, + "global_abstain": False, + "allow_multiple_votes_per_candidate": True, + "votes_amount": 5, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(AssignmentPoll.objects.exists()) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.title, "test_title_ahThai4pae1pi4xoogoo") + self.assertEqual(poll.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) + + def test_no_candidates(self): + self.assignment.remove_candidate(self.admin) + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_eing5eipue5cha2Iefai", + "pollmethod": "YNA", + "type": "named", + "assignment_id": self.assignment.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_missing_title(self): + response = self.client.post( + reverse("assignmentpoll-list"), + {"pollmethod": "YNA", "type": "named", "assignment_id": self.assignment.id}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_missing_pollmethod(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_OoCh9aitaeyaeth8nom1", + "type": "named", + "assignment_id": self.assignment.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_missing_type(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Ail9Eizohshim0fora6o", + "pollmethod": "YNA", + "assignment_id": self.assignment.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_missing_assignment_id(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_eic7ooxaht5mee3quohK", + "pollmethod": "YNA", + "type": "named", + }, + format="json", + ) + self.assertEqual(response.status_code, 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": "YNA", + "type": "named", + "assignment_id": self.assignment.id, + "groups_id": [1, 2], + }, + format="json", + ) + self.assertEqual(response.status_code, 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": "YNA", + "type": "named", + "assignment_id": self.assignment.id, + "groups_id": [], + }, + format="json", + ) + self.assertEqual(response.status_code, 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": "YNA", + "type": "not_existing", + "assignment_id": self.assignment.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + 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, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.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="votes", + type=BasePoll.TYPE_NAMED, + ) + 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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": "YNA"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.pollmethod, "YNA") + + def test_patch_invalid_pollmethod(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"pollmethod": "invalid"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.pollmethod, "votes") + + def test_patch_type(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), {"type": "analog"} + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.type, "named") + + def test_patch_groups_to_empty(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"groups_id": []}, + format="json", + ) + self.assertEqual(response.status_code, 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]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.groups.count(), 1) + self.assertEqual(poll.groups.get(), group2) + + def test_patch_wrong_state(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"title": "test_title_Oophah8EaLaequu3toh8"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.title, "test_title_beeFaihuNae1vej2ai8m") + + def test_patch_multiple_fields(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + { + "title": "test_title_ees6Tho8ahheen4cieja", + "pollmethod": "votes", + "global_no": True, + "global_abstain": False, + "allow_multiple_votes_per_candidate": True, + "votes_amount": 42, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.title, "test_title_ees6Tho8ahheen4cieja") + self.assertEqual(poll.pollmethod, "votes") + self.assertTrue(poll.global_no) + self.assertFalse(poll.global_abstain) + self.assertTrue(poll.allow_multiple_votes_per_candidate) + self.assertEqual(poll.votes_amount, 42) + + +class VoteAssignmentPollAnalogYNA(TestCase): + def advancedSetUp(self): + self.assignment = Assignment.objects.create( + title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1 + ) + self.assignment.add_candidate(self.admin) + self.poll = AssignmentPoll.objects.create( + assignment=self.assignment, + title="test_title_beeFaihuNae1vej2ai8m", + pollmethod="YNA", + type=BasePoll.TYPE_ANALOG, + ) + self.poll.create_options() + + def start_poll(self): + self.poll.state = AssignmentPoll.STATE_STARTED + self.poll.save() + + def add_second_candidate(self): + user, _ = self.create_user() + AssignmentOption.objects.create(user=user, poll=self.poll) + + def test_start_poll(self): + response = self.client.post( + reverse("assignmentpoll-start", args=[self.poll.pk]) + ) + self.assertEqual(response.status_code, 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_vote(self): + self.add_second_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", + }, + format="json", + ) + self.assertEqual(response.status_code, 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, None) + 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_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"}, + } + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_too_few_options(self): + self.add_second_candidate() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"options": {"1": {"Y": "1", "N": "2.35", "A": "-1"}}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_wrong_options(self): + user, _ = self.create_user() + self.assignment.add_candidate(user) + 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"}, + } + }, + format="json", + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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], + format="json", + ) + self.assertEqual(response.status_code, 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"]}, + format="json", + ) + self.assertEqual(response.status_code, 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"}}, + format="json", + ) + self.assertEqual(response.status_code, 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]}}, + format="json", + ) + self.assertEqual(response.status_code, 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, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentVote.objects.exists()) diff --git a/tests/integration/assignments/test_viewset.py b/tests/integration/assignments/test_viewset.py index 7862cdb18..eb837226c 100644 --- a/tests/integration/assignments/test_viewset.py +++ b/tests/integration/assignments/test_viewset.py @@ -9,7 +9,7 @@ from openslides.assignments.models import Assignment 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 tests.test_case import TestCase from ..helpers import count_queries @@ -75,7 +75,7 @@ class CreateAssignment(TestCase): self.assertTrue(assignment.attachments.exists()) -class CanidatureSelf(TestCase): +class CandidatureSelf(TestCase): """ Tests self candidation view. """ @@ -99,7 +99,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 +152,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 +173,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 +184,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 +198,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 +267,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 +321,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 +343,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 +355,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 +372,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") @@ -408,7 +408,7 @@ class MarkElectedOtherUser(TestCase): ) def test_mark_elected(self): - self.assignment.set_candidate( + self.assignment.add_candidate( get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev") ) response = self.client.post( @@ -437,46 +437,3 @@ class MarkElectedOtherUser(TestCase): .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..a8a2e2111 100644 --- a/tests/integration/core/test_viewset.py +++ b/tests/integration/core/test_viewset.py @@ -13,8 +13,8 @@ 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 tests.test_case import TestCase from ..helpers import count_queries @@ -107,7 +107,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 +116,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 +131,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 +139,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 +152,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 +167,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 +179,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 +257,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 +268,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 +306,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 +320,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 +332,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 +341,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 +348,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 +367,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 +377,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/mediafiles/test_viewset.py b/tests/integration/mediafiles/test_viewset.py index 5620986a7..eaa74bad1 100644 --- a/tests/integration/mediafiles/test_viewset.py +++ b/tests/integration/mediafiles/test_viewset.py @@ -7,7 +7,7 @@ from rest_framework import status from rest_framework.test import APIClient from openslides.mediafiles.models import Mediafile -from openslides.utils.test import TestCase +from tests.test_case import TestCase from ..helpers import count_queries @@ -41,6 +41,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() @@ -170,8 +171,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) @@ -268,7 +269,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..dcb3d31c3 --- /dev/null +++ b/tests/integration/motions/test_motions.py @@ -0,0 +1,980 @@ +import json +from decimal import Decimal + +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, + MotionChangeRecommendation, + MotionComment, + MotionCommentSection, + MotionPoll, + MotionVote, + 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.test_case import TestCase + +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 all poll groups, + * 1 request to get all poll voted users, + * 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 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}") + + 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" + ) + + # Create some fully populated polls: + poll1 = MotionPoll.objects.create( + motion=motion, + title="test_title_XeejaeFez3chahpei9qu", + pollmethod="YNA", + type=BasePoll.TYPE_NAMED, + ) + poll1.create_options() + option = poll1.options.get() + MotionVote.objects.create( + user=user1, option=option, value="Y", weight=Decimal(1) + ) + poll1.voted.add(user1) + MotionVote.objects.create( + user=user2, option=option, value="N", weight=Decimal(1) + ) + poll1.voted.add(user2) + + poll2 = MotionPoll.objects.create( + motion=motion, + title="test_title_iecuW7eekeGh4uunow1e", + pollmethod="YNA", + type=BasePoll.TYPE_NAMED, + ) + poll2.create_options() + option = poll2.options.get() + MotionVote.objects.create( + user=user2, option=option, value="Y", weight=Decimal(1) + ) + poll2.voted.add(user2) + MotionVote.objects.create( + user=user3, option=option, value="N", weight=Decimal(1) + ) + poll2.voted.add(user3) + + assert count_queries(Motion.get_elements) == 18 + + +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 + """ + + 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..afd96cb14 --- /dev/null +++ b/tests/integration/motions/test_polls.py @@ -0,0 +1,1107 @@ +from decimal import Decimal + +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, 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.test_case import TestCase + + +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_simple(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_ailai4toogh3eefaa2Vo", + "pollmethod": "YNA", + "type": "named", + "motion_id": self.motion.id, + }, + format="json", + ) + self.assertEqual(response.status_code, 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_missing_title(self): + response = self.client.post( + reverse("motionpoll-list"), + {"pollmethod": "YNA", "type": "named", "motion_id": self.motion.id}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + + def test_missing_pollmethod(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_OoCh9aitaeyaeth8nom1", + "type": "named", + "motion_id": self.motion.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + + def test_missing_type(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_Ail9Eizohshim0fora6o", + "pollmethod": "YNA", + "motion_id": self.motion.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + + def test_missing_assignment_id(self): + response = self.client.post( + reverse("motionpoll-list"), + { + "title": "test_title_eic7ooxaht5mee3quohK", + "pollmethod": "YNA", + "type": "named", + }, + format="json", + ) + self.assertEqual(response.status_code, 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, + "groups_id": [1, 2], + }, + format="json", + ) + self.assertEqual(response.status_code, 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": "YNA", + "type": "named", + "motion_id": self.motion.id, + "groups_id": [], + }, + format="json", + ) + self.assertEqual(response.status_code, 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": "YNA", + "type": "not_existing", + "motion_id": self.motion.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) + + 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, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.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="YN", + type="named", + ) + 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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": "YNA"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.pollmethod, "YNA") + + def test_patch_invalid_pollmethod(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), {"pollmethod": "invalid"} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.pollmethod, "YN") + + def test_patch_type(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), {"type": "analog"} + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.type, "named") + + def test_patch_groups_to_empty(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"groups_id": []}, + format="json", + ) + self.assertEqual(response.status_code, 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]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.groups.count(), 1) + self.assertEqual(poll.groups.get(), group2) + + def test_patch_wrong_state(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"title": "test_title_Oophah8EaLaequu3toh8"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.title, "test_title_beeFaihuNae1vej2ai8m") + + +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.assertEqual(response.status_code, 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_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", + }, + format="json", + ) + self.assertEqual(response.status_code, 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, None) + 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.assertEqual(response.status_code, 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"}, + format="json", + ) + self.assertEqual(response.status_code, 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], format="json" + ) + self.assertEqual(response.status_code, 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"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + +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.assertEqual(response.status_code, 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", format="json" + ) + self.assertEqual(response.status_code, 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.count_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")) + 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", format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "A", format="json" + ) + self.assertEqual(response.status_code, 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.count_users_voted(), 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", format="json" + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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], format="json" + ) + self.assertEqual(response.status_code, 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.user1, user1_password = self.create_user() + self.user1.groups.add(self.delegate_group) + self.user1.is_present = True + self.user1.save() + self.user1_client = APIClient() + self.user1_client.login(username=self.user1.username, password=user1_password) + + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_tho8PhiePh8upaex6phi", + pollmethod="YNA", + type=BasePoll.TYPE_NAMED, + state=MotionPoll.STATE_STARTED, + ) + self.poll.create_options() + self.poll.groups.add(self.delegate_group) + + def test_vote(self): + response = self.user1_client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "A" + ) + self.assertEqual(response.status_code, 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", + "groups_id": [GROUP_DELEGATE_PK], + "votesvalid": "1.000000", + "votesinvalid": "0.000000", + "votescast": "1.000000", + "options": [ + { + "id": 1, + "yes": "0.000000", + "no": "0.000000", + "abstain": "1.000000", + "votes_id": [vote.id], + } + ], + "voted_id": [self.user1.id], + "id": 1, + }, + "motions/motion-vote:1": { + "pollstate": 2, + "id": 1, + "weight": "1.000000", + "value": "A", + "user_id": self.user1.id, + }, + }, + ) + self.assertEqual(autoupdate[1], []) + + # Expect user1 to receive his vote + autoupdate = self.get_last_autoupdate(user=self.user1) + self.assertEqual( + autoupdate[0]["motions/motion-vote:1"], + { + "pollstate": 2, + "id": 1, + "weight": "1.000000", + "value": "A", + "user_id": self.user1.id, + }, + ) + self.assertEqual(autoupdate[1], []) + + # Expect non-admins to get a restricted poll update + for user in (self.user1, 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", + "groups_id": [GROUP_DELEGATE_PK], + "options": [{"id": 1, "votes_id": [vote.id]}], + "id": 1, + }, + ) + + # 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, user1_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=user1_password) + + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_cahP1umooteehah2jeey", + pollmethod="YNA", + type=BasePoll.TYPE_PSEUDOANONYMOUS, + state=MotionPoll.STATE_STARTED, + ) + 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.assertEqual(response.status_code, 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", + "groups_id": [GROUP_DELEGATE_PK], + "votesvalid": "1.000000", + "votesinvalid": "0.000000", + "votescast": "1.000000", + "options": [ + { + "id": 1, + "yes": "0.000000", + "no": "0.000000", + "abstain": "1.000000", + "votes_id": [vote.id], + } + ], + "voted_id": [self.user.id], + "id": 1, + }, + "motions/motion-vote:1": { + "pollstate": 2, + "id": 1, + "weight": "1.000000", + "value": "A", + "user_id": None, + }, + }, + ) + 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", + "groups_id": [GROUP_DELEGATE_PK], + "options": [{"id": 1, "votes_id": [vote.id]}], + "id": 1, + }, + ) + + 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.assertEqual(response.status_code, 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", format="json" + ) + self.assertEqual(response.status_code, 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.count_users_voted(), 1) + self.assertTrue(self.admin in poll.voted.all()) + 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, 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", format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), "A", format="json" + ) + self.assertEqual(response.status_code, 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", format="json" + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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], format="json" + ) + self.assertEqual(response.status_code, 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_ANALOG, + ) + 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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, + ) + 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.assertEqual(response.status_code, 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", + "groups_id": [], + "votesvalid": "0.000000", + "votesinvalid": "0.000000", + "votescast": "0.000000", + "options": [ + { + "id": 1, + "yes": "0.000000", + "no": "2.000000", + "abstain": "0.000000", + "votes_id": [1], + } + ], + "voted_id": [], + "id": 1, + }, + "motions/motion-vote:1": { + "pollstate": 4, + "id": 1, + "weight": "2.000000", + "value": "N", + "user_id": None, + }, + }, + ) + self.assertEqual(autoupdate[1], []) + + def test_publish_wrong_state(self): + response = self.client.post(reverse("motionpoll-publish", args=[self.poll.pk])) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.get_votes().count(), 2) + self.assertEqual(poll.count_users_voted(), 2) + self.assertEqual(poll.votesvalid, Decimal("2")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("2")) + self.assertTrue(self.user1 in poll.voted.all()) + self.assertTrue(self.user2 in poll.voted.all()) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("1")) + self.assertEqual(option.no, Decimal("1")) + self.assertEqual(option.abstain, Decimal("0")) + 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.get_votes().count(), 0) + self.assertEqual(poll.count_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.assertEqual(response.status_code, status.HTTP_200_OK) + for user in (self.admin, self.user1, self.user2): + self.assertDeletedAutoupdate(self.vote1, user=user) + self.assertDeletedAutoupdate(self.vote2, user=user) + + def test_reset_wrong_state(self): + self.poll.state = MotionPoll.STATE_STARTED + self.poll.save() + response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk])) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertTrue(poll.get_votes().exists()) + self.assertEqual(poll.count_users_voted(), 2) 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..af91831eb 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,62 +14,16 @@ 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 tests.common_groups import GROUP_ADMIN_PK, GROUP_DELEGATE_PK, GROUP_STAFF_PK +from tests.test_case import TestCase 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 - - @pytest.mark.django_db(transaction=False) def test_category_db_queries(): """ @@ -220,637 +171,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 +252,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 +266,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 +287,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 +307,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 +323,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 +335,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 +346,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 +368,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 +384,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 +690,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 +703,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 +891,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..04a6f5641 100644 --- a/tests/integration/topics/test_viewset.py +++ b/tests/integration/topics/test_viewset.py @@ -4,7 +4,7 @@ from rest_framework import status from openslides.agenda.models import Item from openslides.topics.models import Topic -from openslides.utils.test import TestCase +from tests.test_case import TestCase from ..helpers import count_queries 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..f57322881 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -8,7 +8,7 @@ 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.test_case import TestCase from ...common_groups import ( GROUP_ADMIN_PK, @@ -196,7 +196,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 +267,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 +275,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 +415,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 +447,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 +474,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 +484,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 +494,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 +504,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 +531,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 +549,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 +564,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 +577,6 @@ class UserBulkAlterGroups(TestCase): "action": "invalid", "group_ids": [GROUP_DELEGATE_PK], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -614,7 +602,7 @@ class UserMassImport(TestCase): "groups_id": [], } response = self.client.post( - reverse("user-mass-import"), {"users": [user_1, user_2]}, format="json" + reverse("user-mass-import"), {"users": [user_1, user_2]} ) self.assertEqual(response.status_code, 200) self.assertEqual(User.objects.count(), 3) @@ -640,9 +628,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 +652,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 +838,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 +857,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 +874,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 +885,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 +957,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 +969,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 +989,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/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..734f1ca6f 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -73,3 +73,5 @@ PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] # Deactivate restricted_data_cache RESTRICTED_DATA_CACHE = False + +REST_FRAMEWORK = {"TEST_REQUEST_DEFAULT_FORMAT": "json"} diff --git a/tests/test_case.py b/tests/test_case.py new file mode 100644 index 000000000..fd3ffc0e2 --- /dev/null +++ b/tests/test_case.py @@ -0,0 +1,98 @@ +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 + + +class TestCase(_TestCase): + 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] + ) + + """ + 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): """ From ce171980e89aebd6dabaf2210bdb4213b063ece3 Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Tue, 29 Oct 2019 09:00:11 +0100 Subject: [PATCH 02/55] Relations in the client --- .../assignment-poll-repository.service.ts | 56 ++++++++++++++++++- .../assignment-repository.service.ts | 31 ++-------- .../assignment-vote-repository.service.ts | 15 ++++- .../motions/motion-poll-repository.service.ts | 49 +++++++++++++++- .../motions/motion-repository.service.ts | 8 ++- .../motions/motion-vote-repository.service.ts | 15 ++++- .../models/assignments/assignment-option.ts | 1 + .../shared/models/assignments/assignment.ts | 10 ---- .../shared/models/base/base-decimal-model.ts | 4 +- .../src/app/shared/models/motions/motion.ts | 2 - .../src/app/shared/models/poll/base-option.ts | 5 +- .../src/app/shared/models/poll/base-poll.ts | 4 +- .../src/app/shared/models/poll/base-vote.ts | 5 +- .../models/view-assignment-option.ts | 9 ++- .../assignments/models/view-assignment.ts | 2 +- .../site/motions/models/view-motion-option.ts | 7 ++- .../app/site/motions/models/view-motion.ts | 2 + client/src/app/site/motions/motions.config.ts | 10 +++- .../motions/services/motion-pdf.service.ts | 4 +- .../services/motion-poll-pdf.service.ts | 4 +- openslides/assignments/access_permissions.py | 9 +++ openslides/assignments/models.py | 2 + openslides/assignments/serializers.py | 8 +-- openslides/assignments/views.py | 16 ------ openslides/motions/serializers.py | 4 -- openslides/poll/serializers.py | 4 +- tests/integration/motions/test_motions.py | 2 +- tests/integration/motions/test_polls.py | 15 ++--- 28 files changed, 205 insertions(+), 98 deletions(-) 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 index f23f591eb..78257cb08 100644 --- a/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts @@ -5,12 +5,61 @@ 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 { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; +import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; -import { BaseRepository } from '../base-repository'; +import { ViewAssignmentVote } from 'app/site/assignments/models/view-assignment-vote'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { BaseRepository, NestedModelDescriptors } from '../base-repository'; 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: 'M2M', + ownIdKey: 'voted_id', + ownKey: 'voted', + foreignViewModel: ViewUser + } +]; + +const AssignmentPollNestedModelDescriptors: NestedModelDescriptors = { + 'assignments/assignment-poll': [ + { + ownKey: 'options', + foreignViewModel: ViewAssignmentOption, + foreignModel: AssignmentOption, + order: 'weight', + relationDefinitionsByKey: { + user: { + type: 'M2O', + ownIdKey: 'user_id', + ownKey: 'user', + foreignViewModel: ViewUser + }, + votes: { + type: 'O2M', + foreignIdKey: 'option_id', + ownKey: 'votes', + foreignViewModel: ViewAssignmentVote + } + }, + titles: { + getTitle: (viewOption: ViewAssignmentOption) => (viewOption.user ? viewOption.user.getTitle() : '') + } + } + ] +}; + /** * Repository Service for Assignments. * @@ -49,8 +98,9 @@ export class AssignmentPollRepositoryService extends BaseRepository< viewModelStoreService, translate, relationManager, - AssignmentPoll - // TODO: relations + AssignmentPoll, + AssignmentPollRelations, + AssignmentPollNestedModelDescriptors ); } 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 fde6641a5..ecd6775fa 100644 --- a/client/src/app/core/repositories/assignments/assignment-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-repository.service.ts @@ -8,11 +8,8 @@ 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 { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; -import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentRelatedUser } from 'app/shared/models/assignments/assignment-related-user'; import { AssignmentTitleInformation, ViewAssignment } from 'app/site/assignments/models/view-assignment'; -import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { ViewAssignmentRelatedUser } from 'app/site/assignments/models/view-assignment-related-user'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; @@ -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: ViewAssignmentOption, - foreignModel: AssignmentOption, - order: 'weight', - relationDefinitionsByKey: { - user: { - type: 'M2O', - ownIdKey: 'candidate_id', - ownKey: 'user', - foreignViewModel: ViewUser - } - } } ] }; 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 index 555d258f3..3e16559f3 100644 --- a/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts @@ -5,12 +5,23 @@ 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 { 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 + } +]; + /** * Repository Service for Assignments. * @@ -43,8 +54,8 @@ export class AssignmentVoteRepositoryService extends BaseRepository '' + } + } + ] +}; + /** * Repository Service for Assignments. * @@ -39,8 +81,9 @@ export class MotionPollRepositoryService extends BaseRepository< viewModelStoreService, translate, relationManager, - MotionPoll - // TODO: relations + MotionPoll, + MotionPollRelations, + MotionPollNestedModelDescriptors ); } 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 3626cd7a0..b9f4af858 100644 --- a/client/src/app/core/repositories/motions/motion-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-repository.service.ts @@ -23,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'; @@ -125,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. ]; 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 index ff95c237f..801659fa0 100644 --- a/client/src/app/core/repositories/motions/motion-vote-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-vote-repository.service.ts @@ -5,12 +5,23 @@ 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 { 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 + } +]; + /** * Repository Service for Assignments. * @@ -43,8 +54,8 @@ export class MotionVoteRepositoryService extends BaseRepository { 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.ts b/client/src/app/shared/models/assignments/assignment.ts index d05a90f4a..7e763f88a 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'; @@ -22,18 +21,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 index f7bedde9b..1f40f5cb9 100644 --- a/client/src/app/shared/models/base/base-decimal-model.ts +++ b/client/src/app/shared/models/base/base-decimal-model.ts @@ -1,11 +1,11 @@ import { BaseModel } from './base-model'; export abstract class BaseDecimalModel extends BaseModel { - protected abstract decimalFields: (keyof this)[]; + protected abstract getDecimalFields(): (keyof this)[]; public deserialize(input: any): void { if (input && typeof input === 'object') { - this.decimalFields.forEach(field => (input[field] = parseInt(input[field], 10))); + this.getDecimalFields().forEach(field => (input[field] = parseInt(input[field], 10))); } super.deserialize(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 index cc0df616a..53da72281 100644 --- a/client/src/app/shared/models/poll/base-option.ts +++ b/client/src/app/shared/models/poll/base-option.ts @@ -5,7 +5,8 @@ export abstract class BaseOption extends BaseDecimalModel { public yes: number; public no: number; public abstain: number; - public votes_id: number[]; - protected decimalFields: (keyof BaseOption)[] = ['yes', 'no', 'abstain']; + protected getDecimalFields(): (keyof BaseOption)[] { + 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 index e8c4a1f5b..1fc1fe6fe 100644 --- a/client/src/app/shared/models/poll/base-poll.ts +++ b/client/src/app/shared/models/poll/base-poll.ts @@ -28,6 +28,8 @@ export interface BasePollWithoutNestedModels { export abstract class BasePoll> extends BaseDecimalModel { public options: O[]; - protected decimalFields: (keyof BasePoll)[] = ['votesvalid', 'votesinvalid', 'votescast']; + protected getDecimalFields(): (keyof BasePoll)[] { + return ['votesvalid', 'votesinvalid', 'votescast']; + } } export interface BasePoll> extends BasePollWithoutNestedModels {} diff --git a/client/src/app/shared/models/poll/base-vote.ts b/client/src/app/shared/models/poll/base-vote.ts index 0a3f36dfe..849b7d0a5 100644 --- a/client/src/app/shared/models/poll/base-vote.ts +++ b/client/src/app/shared/models/poll/base-vote.ts @@ -3,7 +3,10 @@ import { BaseDecimalModel } from '../base/base-decimal-model'; export abstract class BaseVote extends BaseDecimalModel { public weight: number; public value: 'Y' | 'N' | 'A'; + public option_id: number; public user_id?: number; - protected decimalFields: (keyof BaseVote)[] = ['weight']; + protected getDecimalFields(): (keyof BaseVote)[] { + return ['weight']; + } } diff --git a/client/src/app/site/assignments/models/view-assignment-option.ts b/client/src/app/site/assignments/models/view-assignment-option.ts index a1324ee4f..d364cbfbe 100644 --- a/client/src/app/site/assignments/models/view-assignment-option.ts +++ b/client/src/app/site/assignments/models/view-assignment-option.ts @@ -1,5 +1,7 @@ import { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; +import { ViewUser } from 'app/site/users/models/view-user'; import { BaseViewModel } from '../../base/base-view-model'; +import { ViewAssignmentVote } from './view-assignment-vote'; export class ViewAssignmentOption extends BaseViewModel { public get option(): AssignmentOption { @@ -9,4 +11,9 @@ export class ViewAssignmentOption extends BaseViewModel { protected _collectionString = AssignmentOption.COLLECTIONSTRING; } -export interface ViewAssignmentOption extends AssignmentOption {} +interface TIMotionOptionRelations { + votes: ViewAssignmentVote[]; + user: ViewUser; +} + +export interface ViewAssignmentOption extends AssignmentOption, TIMotionOptionRelations {} diff --git a/client/src/app/site/assignments/models/view-assignment.ts b/client/src/app/site/assignments/models/view-assignment.ts index abe7291b2..75aaaddcf 100644 --- a/client/src/app/site/assignments/models/view-assignment.ts +++ b/client/src/app/site/assignments/models/view-assignment.ts @@ -104,7 +104,7 @@ export class ViewAssignment extends BaseViewModelWithAgendaItemAndListOfSpeakers } interface IAssignmentRelations { assignment_related_users: ViewAssignmentRelatedUser[]; - polls?: ViewAssignmentPoll[]; + polls: ViewAssignmentPoll[]; tags?: ViewTag[]; attachments?: ViewMediafile[]; } diff --git a/client/src/app/site/motions/models/view-motion-option.ts b/client/src/app/site/motions/models/view-motion-option.ts index 18517d950..f5e1f7165 100644 --- a/client/src/app/site/motions/models/view-motion-option.ts +++ b/client/src/app/site/motions/models/view-motion-option.ts @@ -1,5 +1,6 @@ import { MotionOption } from 'app/shared/models/motions/motion-option'; import { BaseViewModel } from '../../base/base-view-model'; +import { ViewMotionVote } from './view-motion-vote'; export class ViewMotionOption extends BaseViewModel { public get option(): MotionOption { @@ -9,4 +10,8 @@ export class ViewMotionOption extends BaseViewModel { protected _collectionString = MotionOption.COLLECTIONSTRING; } -export interface ViewMotionPoll extends MotionOption {} +interface TIMotionOptionRelations { + votes: ViewMotionVote[]; +} + +export interface ViewMotionOption extends MotionOption, TIMotionOptionRelations {} diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index fc66c3f20..4e71068ad 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -16,6 +16,7 @@ import { ViewCategory } from './view-category'; import { ViewMotionBlock } from './view-motion-block'; import { ViewMotionChangeRecommendation } from './view-motion-change-recommendation'; import { ViewMotionCommentSection } from './view-motion-comment-section'; +import { ViewMotionPoll } from './view-motion-poll'; import { ViewState } from './view-state'; import { ViewSubmitter } from './view-submitter'; import { ViewWorkflow } from './view-workflow'; @@ -359,6 +360,7 @@ interface TIMotionRelations { amendments?: ViewMotion[]; changeRecommendations?: ViewMotionChangeRecommendation[]; diffLines?: DiffLinesInParagraph[]; + polls: ViewMotionPoll[]; } export interface ViewMotion extends MotionWithoutNestedModels, TIMotionRelations {} diff --git a/client/src/app/site/motions/motions.config.ts b/client/src/app/site/motions/motions.config.ts index 554aaf19d..89cde7823 100644 --- a/client/src/app/site/motions/motions.config.ts +++ b/client/src/app/site/motions/motions.config.ts @@ -3,10 +3,14 @@ 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 { 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 { 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 +23,8 @@ 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 { 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'; @@ -70,7 +76,9 @@ export const MotionsAppConfig: AppConfig = { viewModel: ViewStatuteParagraph, searchOrder: 9, repository: StatuteParagraphRepositoryService - } + }, + { model: MotionPoll, viewModel: ViewMotionPoll, repository: MotionPollRepositoryService }, + { 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 6cda57f65..cbea9c8e5 100644 --- a/client/src/app/site/motions/services/motion-pdf.service.ts +++ b/client/src/app/site/motions/services/motion-pdf.service.ts @@ -358,11 +358,11 @@ 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) => { + motion.polls.map((poll, index) => { /*if (poll.has_votes) { if (motion.motion.polls.length > 1) { column1.push(index + 1 + '. ' + this.translate.instant('Vote')); 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..168ed88f4 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 @@ -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/openslides/assignments/access_permissions.py b/openslides/assignments/access_permissions.py index 2afba46f1..3fb5ed90a 100644 --- a/openslides/assignments/access_permissions.py +++ b/openslides/assignments/access_permissions.py @@ -51,3 +51,12 @@ class AssignmentPollAccessPermissions(BaseAccessPermissions): self, full_data: List[Dict[str, Any]], user_id: int ) -> List[Dict[str, Any]]: return full_data + + +class AssignmentVoteAccessPermissions(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]]: + return full_data diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 1446c07c8..8211abc32 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -19,6 +19,7 @@ from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE from .access_permissions import ( AssignmentAccessPermissions, AssignmentPollAccessPermissions, + AssignmentVoteAccessPermissions, ) @@ -268,6 +269,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model class AssignmentVote(RESTModelMixin, BaseVote): + access_permissions = AssignmentVoteAccessPermissions() option = models.ForeignKey( "AssignmentOption", on_delete=models.CASCADE, related_name="votes" ) diff --git a/openslides/assignments/serializers.py b/openslides/assignments/serializers.py index b9f7a8ebb..9ef737cb8 100644 --- a/openslides/assignments/serializers.py +++ b/openslides/assignments/serializers.py @@ -74,12 +74,10 @@ class AssignmentOptionSerializer(ModelSerializer): max_digits=15, decimal_places=6, min_value=-2, read_only=True ) - votes = IdPrimaryKeyRelatedField(many=True, read_only=True) - class Meta: model = AssignmentOption - fields = ("user",) + BASE_OPTION_FIELDS - read_only_fields = ("user",) + BASE_OPTION_FIELDS + fields = ("user", "weight") + BASE_OPTION_FIELDS + read_only_fields = ("user", "weight") + BASE_OPTION_FIELDS class AssignmentPollSerializer(ModelSerializer): @@ -133,7 +131,6 @@ class AssignmentSerializer(ModelSerializer): assignment_related_users = AssignmentRelatedUserSerializer( many=True, read_only=True ) - polls = IdPrimaryKeyRelatedField(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 @@ -150,7 +147,6 @@ class AssignmentSerializer(ModelSerializer): "phase", "assignment_related_users", "poll_description_default", - "polls", "agenda_item_id", "list_of_speakers_id", "agenda_create", diff --git a/openslides/assignments/views.py b/openslides/assignments/views.py index 656294e1e..51069d693 100644 --- a/openslides/assignments/views.py +++ b/openslides/assignments/views.py @@ -246,22 +246,6 @@ class AssignmentViewSet(ModelViewSet): 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): """ diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index 24a48f00c..dfcee8b25 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -243,8 +243,6 @@ class MotionOptionSerializer(ModelSerializer): max_digits=15, decimal_places=6, min_value=-2, read_only=True ) - votes = IdPrimaryKeyRelatedField(many=True, read_only=True) - class Meta: model = MotionOption fields = BASE_OPTION_FIELDS @@ -371,7 +369,6 @@ class MotionSerializer(ModelSerializer): """ comments = MotionCommentSerializer(many=True, read_only=True) - polls = IdPrimaryKeyRelatedField(many=True, read_only=True) modified_final_version = CharField(allow_blank=True, required=False) reason = CharField(allow_blank=True, required=False) state_restriction = SerializerMethodField() @@ -419,7 +416,6 @@ class MotionSerializer(ModelSerializer): "recommendation_extension", "tags", "attachments", - "polls", "agenda_item_id", "list_of_speakers_id", "agenda_create", diff --git a/openslides/poll/serializers.py b/openslides/poll/serializers.py index ebf5409f8..9bc312623 100644 --- a/openslides/poll/serializers.py +++ b/openslides/poll/serializers.py @@ -11,6 +11,6 @@ BASE_POLL_FIELDS = ( "id", ) -BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain", "votes") +BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain") -BASE_VOTE_FIELDS = ("id", "weight", "value", "user") +BASE_VOTE_FIELDS = ("id", "weight", "value", "user", "option") diff --git a/tests/integration/motions/test_motions.py b/tests/integration/motions/test_motions.py index dcb3d31c3..026353ae9 100644 --- a/tests/integration/motions/test_motions.py +++ b/tests/integration/motions/test_motions.py @@ -419,7 +419,7 @@ class RetrieveMotion(TestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) def test_user_without_can_see_user_permission_to_see_motion_and_submitter_data( - self + self, ): admin = get_user_model().objects.get(username="admin") Submitter.objects.add(admin, self.motion) diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index afd96cb14..1aa528090 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -582,7 +582,6 @@ class VoteMotionPollNamedAutoupdates(TestCase): "yes": "0.000000", "no": "0.000000", "abstain": "1.000000", - "votes_id": [vote.id], } ], "voted_id": [self.user1.id], @@ -594,6 +593,7 @@ class VoteMotionPollNamedAutoupdates(TestCase): "weight": "1.000000", "value": "A", "user_id": self.user1.id, + "option_id": 1, }, }, ) @@ -605,6 +605,7 @@ class VoteMotionPollNamedAutoupdates(TestCase): autoupdate[0]["motions/motion-vote:1"], { "pollstate": 2, + "option_id": 1, "id": 1, "weight": "1.000000", "value": "A", @@ -626,7 +627,7 @@ class VoteMotionPollNamedAutoupdates(TestCase): "type": "named", "title": "test_title_tho8PhiePh8upaex6phi", "groups_id": [GROUP_DELEGATE_PK], - "options": [{"id": 1, "votes_id": [vote.id]}], + "options": [{"id": 1}], "id": 1, }, ) @@ -653,12 +654,12 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): self.other_user, _ = self.create_user() inform_changed_data(self.other_user) - self.user, user1_password = self.create_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=user1_password) + self.user_client.login(username=self.user.username, password=user_password) self.poll = MotionPoll.objects.create( motion=self.motion, @@ -699,7 +700,6 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "yes": "0.000000", "no": "0.000000", "abstain": "1.000000", - "votes_id": [vote.id], } ], "voted_id": [self.user.id], @@ -707,6 +707,7 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): }, "motions/motion-vote:1": { "pollstate": 2, + "option_id": 1, "id": 1, "weight": "1.000000", "value": "A", @@ -730,7 +731,7 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "type": "pseudoanonymous", "title": "test_title_cahP1umooteehah2jeey", "groups_id": [GROUP_DELEGATE_PK], - "options": [{"id": 1, "votes_id": [vote.id]}], + "options": [{"id": 1}], "id": 1, }, ) @@ -953,7 +954,6 @@ class PublishMotionPoll(TestCase): "yes": "0.000000", "no": "2.000000", "abstain": "0.000000", - "votes_id": [1], } ], "voted_id": [], @@ -961,6 +961,7 @@ class PublishMotionPoll(TestCase): }, "motions/motion-vote:1": { "pollstate": 4, + "option_id": 1, "id": 1, "weight": "2.000000", "value": "N", From 5fa8341614dd0f0be8fbfeb7e723c8a4931c573a Mon Sep 17 00:00:00 2001 From: jsangmeister Date: Tue, 29 Oct 2019 12:58:37 +0100 Subject: [PATCH 03/55] added testing for named and pseudoanonymous assignment voting added queries count tests for assignment and motion polls and votes --- openslides/assignments/access_permissions.py | 39 +- openslides/assignments/models.py | 36 + openslides/assignments/views.py | 39 +- openslides/motions/access_permissions.py | 29 +- openslides/motions/models.py | 34 + openslides/poll/access_permissions.py | 29 + openslides/poll/views.py | 4 +- tests/integration/assignments/test_polls.py | 1175 +++++++++++++++++- tests/integration/motions/test_polls.py | 63 +- 9 files changed, 1388 insertions(+), 60 deletions(-) create mode 100644 openslides/poll/access_permissions.py diff --git a/openslides/assignments/access_permissions.py b/openslides/assignments/access_permissions.py index 3fb5ed90a..96a43f647 100644 --- a/openslides/assignments/access_permissions.py +++ b/openslides/assignments/access_permissions.py @@ -1,5 +1,8 @@ +import json from typing import Any, Dict, List +from ..poll.access_permissions import BaseVoteAccessPermissions +from ..poll.views import BasePoll from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm @@ -50,13 +53,35 @@ class AssignmentPollAccessPermissions(BaseAccessPermissions): async def get_restricted_data( self, full_data: List[Dict[str, Any]], user_id: int ) -> List[Dict[str, Any]]: - return full_data + """ + 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 voted_id field from the poll + """ + + if await async_has_perm(user_id, "assignments.can_manage_polls"): + 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 option in poll["options"]: + del option["yes"] + del option["no"] + del option["abstain"] + data.append(poll) + return data -class AssignmentVoteAccessPermissions(BaseAccessPermissions): +class AssignmentVoteAccessPermissions(BaseVoteAccessPermissions): base_permission = "assignments.can_see" - - async def get_restricted_data( - self, full_data: List[Dict[str, Any]], user_id: int - ) -> List[Dict[str, Any]]: - return full_data + manage_permission = "assignments.can_manage" diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 8211abc32..008ecf2ca 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -268,8 +268,23 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model return {"title": self.title} +class AssignmentVoteManager(models.Manager): + """ + Customized model manager to support our get_full_queryset method. + """ + + def get_full_queryset(self): + """ + Returns the normal queryset with all assignment votes. In the background we + join and prefetch all related models. + """ + return self.get_queryset().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" ) @@ -296,11 +311,32 @@ class AssignmentOption(RESTModelMixin, BaseOption): return self.poll +class AssignmentPollManager(models.Manager): + """ + Customized model manager to support our get_full_queryset method. + """ + + def get_full_queryset(self): + """ + Returns the normal queryset with all assignment polls. In the background we + join and prefetch all related models. + """ + return ( + self.get_queryset() + .select_related("assignment") + .prefetch_related( + "options", "options__user", "options__votes", "groups", "voted" + ) + ) + + # Meta-TODO: Is this todo resolved? # TODO: remove the type-ignoring in the next line, after this is solved: # https://github.com/python/mypy/issues/3855 class AssignmentPoll(RESTModelMixin, BasePoll): access_permissions = AssignmentPollAccessPermissions() + objects = AssignmentPollManager() + option_class = AssignmentOption assignment = models.ForeignKey( diff --git a/openslides/assignments/views.py b/openslides/assignments/views.py index 51069d693..5d60a5f59 100644 --- a/openslides/assignments/views.py +++ b/openslides/assignments/views.py @@ -330,8 +330,8 @@ class AssignmentPollViewSet(BasePollViewSet): required fields per pollmethod: - votes: Y - - YN: YN - - YNA: YNA + - YN: YN + - YNA: YNA """ if not isinstance(data, dict): raise ValidationError({"detail": "Data must be a dict"}) @@ -428,8 +428,13 @@ class AssignmentPollViewSet(BasePollViewSet): if not is_int(amount): raise ValidationError({"detail": "Each amounts must be int"}) amount = int(amount) - if amount < 1: - raise ValidationError({"detail": "At least 1 vote per option"}) + 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"} @@ -485,27 +490,41 @@ class AssignmentPollViewSet(BasePollViewSet): ) def create_votes(self, data, poll, user=None): + """ + Helper function for handle_(named|pseudoanonymous)_vote + Assumes data is already validated + """ options = poll.get_options() if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: if isinstance(data, dict): for option_id, amount in data.items(): + # skip empty votes + if amount == 0: + continue option = options.get(pk=option_id) vote = AssignmentVote.objects.create( option=option, user=user, weight=Decimal(amount), value="Y" ) inform_changed_data(vote, no_delete_on_restriction=True) - else: + else: # global_no or global_abstain option = options.first() vote = AssignmentVote.objects.create( - option=option, user=user, weight=Decimal(1), value=data + option=option, + user=user, + weight=Decimal(poll.votes_amount), + value=data, ) inform_changed_data(vote, no_delete_on_restriction=True) elif poll.pollmethod in ( AssignmentPoll.POLLMETHOD_YN, AssignmentPoll.POLLMETHOD_YNA, ): - pass - # TODO + for option_id, result in data.items(): + option = options.get(pk=option_id) + vote = AssignmentVote.objects.create( + option=option, user=user, value=result + ) + inform_changed_data(vote, no_delete_on_restriction=True) def handle_named_vote(self, data, poll, user): """ @@ -514,9 +533,9 @@ class AssignmentPollViewSet(BasePollViewSet): - 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 - - amonts must be integer numbers >= 1. + - amounts must be integer numbers >= 1. - ids should be integers of valid option ids for this poll - - amounts must be one ("1"), if poll.allow_multiple_votes_per_candidate if False + - amounts must be 0 or 1, if poll.allow_multiple_votes_per_candidate is False - The sum of all amounts must be poll.votes_amount votes Request data for YN/YNA pollmethod: diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py index b27e7dccc..3f1a07903 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -1,6 +1,8 @@ import json from typing import Any, Dict, List +from ..poll.access_permissions import BaseVoteAccessPermissions +from ..poll.views import BasePoll from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm, async_in_some_groups @@ -183,7 +185,6 @@ class StateAccessPermissions(BaseAccessPermissions): class MotionPollAccessPermissions(BaseAccessPermissions): base_permission = "motions.can_see" - STATE_PUBLISHED = 4 async def get_restricted_data( self, full_data: List[Dict[str, Any]], user_id: int @@ -201,7 +202,7 @@ class MotionPollAccessPermissions(BaseAccessPermissions): else: data = [] for poll in full_data: - if poll["state"] != self.STATE_PUBLISHED: + if poll["state"] != BasePoll.STATE_PUBLISHED: poll = json.loads( json.dumps(poll) ) # copy, so we can remove some fields. @@ -217,26 +218,6 @@ class MotionPollAccessPermissions(BaseAccessPermissions): return data -class MotionVoteAccessPermissions(BaseAccessPermissions): +class MotionVoteAccessPermissions(BaseVoteAccessPermissions): base_permission = "motions.can_see" - STATE_PUBLISHED = 4 - - 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, "motions.can_manage_polls"): - data = full_data - else: - data = [ - vote - for vote in full_data - if vote["pollstate"] == self.STATE_PUBLISHED - or vote["user_id"] == user_id - ] - return data + manage_permission = "motions.can_manage_polls" diff --git a/openslides/motions/models.py b/openslides/motions/models.py index e3f22fee7..46dd0b9f7 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -867,12 +867,27 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode return {"title": self.title} +class MotionVoteManager(models.Manager): + """ + Customized model manager to support our get_full_queryset method. + """ + + def get_full_queryset(self): + """ + Returns the normal queryset with all motion votes. In the background we + join and prefetch all related models. + """ + return self.get_queryset().select_related("user", "option", "option__poll") + + class MotionVote(RESTModelMixin, BaseVote): access_permissions = MotionVoteAccessPermissions() option = models.ForeignKey( "MotionOption", on_delete=models.CASCADE, related_name="votes" ) + objects = MotionVoteManager() + class Meta: default_permissions = () @@ -891,6 +906,23 @@ class MotionOption(RESTModelMixin, BaseOption): return self.poll +class MotionPollManager(models.Manager): + """ + Customized model manager to support our get_full_queryset method. + """ + + def get_full_queryset(self): + """ + Returns the normal queryset with all motion polls. In the background we + join and prefetch all related models. + """ + return ( + self.get_queryset() + .select_related("motion") + .prefetch_related("options", "options__votes", "groups", "voted") + ) + + # Meta-TODO: Is this todo resolved? # TODO: remove the type-ignoring in the next line, after this is solved: # https://github.com/python/mypy/issues/3855 @@ -898,6 +930,8 @@ class MotionPoll(RESTModelMixin, BasePoll): access_permissions = MotionPollAccessPermissions() option_class = MotionOption + objects = MotionPollManager() + motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls") POLLMETHOD_YN = "YN" diff --git a/openslides/poll/access_permissions.py b/openslides/poll/access_permissions.py new file mode 100644 index 000000000..ba3d3aa1b --- /dev/null +++ b/openslides/poll/access_permissions.py @@ -0,0 +1,29 @@ +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 diff --git a/openslides/poll/views.py b/openslides/poll/views.py index 4772f9cdb..b8ab9f942 100644 --- a/openslides/poll/views.py +++ b/openslides/poll/views.py @@ -136,9 +136,7 @@ class BasePollViewSet(ModelViewSet): self.assert_can_vote(poll, request) if request.user in poll.voted.all(): - raise ValidationError( - {"detail": "You have already voted for this poll."} - ) + self.permission_denied(request) self.handle_pseudoanonymous_vote(request.data, poll) poll.voted.add(request.user) diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index d2faefa9b..ef05cb594 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -1,7 +1,12 @@ +import random from decimal import Decimal +from typing import Any +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.assignments.models import ( Assignment, @@ -11,8 +16,77 @@ from openslides.assignments.models import ( ) 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.test_case import TestCase +from ..helpers import count_queries + + +@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 + + +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.objects.create( + title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1 + ) + 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.objects.create( + assignment=assignment, + title="test_title_UnMiGzEHmwqplmVBPNEZ", + pollmethod=AssignmentPoll.POLLMETHOD_YN, + type=AssignmentPoll.TYPE_NAMED, + ) + poll.create_options() + 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", + ) + 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) + ) + poll.voted.add(user) + class CreateAssignmentPoll(TestCase): def advancedSetUp(self): @@ -329,28 +403,40 @@ class UpdateAssignmentPoll(TestCase): self.assertEqual(poll.votes_amount, 42) -class VoteAssignmentPollAnalogYNA(TestCase): +class VoteAssignmentPollBaseTestClass(TestCase): def advancedSetUp(self): self.assignment = Assignment.objects.create( - title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1 + title="test_assignment_tcLT59bmXrXif424Qw7K", open_posts=1 ) self.assignment.add_candidate(self.admin) - self.poll = AssignmentPoll.objects.create( - assignment=self.assignment, - title="test_title_beeFaihuNae1vej2ai8m", - pollmethod="YNA", - type=BasePoll.TYPE_ANALOG, - ) + self.poll = self.create_poll() + self.admin.is_present = True + self.admin.save() + self.poll.groups.add(GROUP_ADMIN_PK) self.poll.create_options() + 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_second_candidate(self): + 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]) @@ -364,7 +450,7 @@ class VoteAssignmentPollAnalogYNA(TestCase): self.assertFalse(poll.get_votes().exists()) def test_vote(self): - self.add_second_candidate() + self.add_candidate() self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), @@ -410,7 +496,7 @@ class VoteAssignmentPollAnalogYNA(TestCase): self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_too_few_options(self): - self.add_second_candidate() + self.add_candidate() self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), @@ -421,15 +507,14 @@ class VoteAssignmentPollAnalogYNA(TestCase): self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_options(self): - user, _ = self.create_user() - self.assignment.add_candidate(user) + 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": "1", "N": "2.35", "A": "-1"}, + "3": {"Y": "1", "N": "2.35", "A": "-1"}, } }, format="json", @@ -505,3 +590,1065 @@ class VoteAssignmentPollAnalogYNA(TestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) + + +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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(AssignmentVote.objects.count(), 1) + vote = AssignmentVote.objects.get() + self.assertEqual(vote.value, "N") + + 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.assertEqual(response.status_code, 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]), + {"1": "Y"}, + format="json", + ) + self.assertEqual(response.status_code, 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]), + {"1": "Y", "3": "N"}, + format="json", + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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], + format="json", + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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}, format="json" + ) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" + ) + self.assertEqual(response.status_code, 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("0")) + 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_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.assertEqual(response.status_code, 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("2")) + self.assertEqual(option.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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + 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.assertEqual(response.status_code, 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("2")) + + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_negative_vote(self): + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": -1}, + format="json", + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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], + format="json", + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, 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]), + {"1": "Y"}, + format="json", + ) + self.assertEqual(response.status_code, 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]), + {"1": "Y", "3": "N"}, + format="json", + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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], + format="json", + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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}, format="json" + ) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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], + format="json", + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.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, + ) + 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.assertEqual(response.status_code, 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, + "groups_id": [GROUP_DELEGATE_PK], + "id": 1, + "options": [ + { + "abstain": "1.000000", + "id": 1, + "no": "0.000000", + "user_id": self.admin.id, + "weight": 1, + "yes": "0.000000", + } + ], + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "state": AssignmentPoll.STATE_STARTED, + "title": self.poll.title, + "type": AssignmentPoll.TYPE_NAMED, + "voted_id": [self.user.id], + "votes_amount": 1, + "votescast": "1.000000", + "votesinvalid": "0.000000", + "votesvalid": "1.000000", + }, + "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, + "title": self.poll.title, + "groups_id": [GROUP_DELEGATE_PK], + "options": [{"id": 1, "user_id": self.admin.id, "weight": 1}], + "id": 1, + "votes_amount": 1, + }, + ) + + # Other users should not get a vote autoupdate + self.assertNoAutoupdate(vote, user=self.other_user) + self.assertNoDeletedAutoupdate(vote, user=self.other_user) + + +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.assertEqual(response.status_code, 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, + "groups_id": [GROUP_DELEGATE_PK], + "id": 1, + "options": [ + { + "abstain": "1.000000", + "id": 1, + "no": "0.000000", + "user_id": self.admin.id, + "weight": 1, + "yes": "0.000000", + } + ], + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "state": AssignmentPoll.STATE_STARTED, + "title": self.poll.title, + "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, + "voted_id": [self.user.id], + "votes_amount": 1, + "votescast": "1.000000", + "votesinvalid": "0.000000", + "votesvalid": "1.000000", + }, + "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, + "title": self.poll.title, + "groups_id": [GROUP_DELEGATE_PK], + "options": [{"id": 1, "user_id": self.admin.id, "weight": 1}], + "id": 1, + "votes_amount": 1, + }, + ) + + self.assertNoAutoupdate(vote, user=user) + self.assertNoDeletedAutoupdate(vote, user=user) diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index 1aa528090..ae4b3f977 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -1,18 +1,77 @@ from decimal import Decimal +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.motions.models import Motion, MotionPoll, MotionVote +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.test_case import TestCase +from ..helpers import count_queries + + +@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 + + +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): """ @@ -816,7 +875,7 @@ class VoteMotionPollPseudoanonymous(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "A", format="json" ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) option = MotionPoll.objects.get().options.get() self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.no, Decimal("1")) From 1246dd54add34df2ab096d5464766a28290bc34b Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Tue, 29 Oct 2019 09:44:19 +0100 Subject: [PATCH 04/55] majorities in polls --- .../shared/models/assignments/assignment.ts | 2 +- .../assignment-detail.component.html | 4 +- .../assignment-detail.component.ts | 2 +- openslides/assignments/access_permissions.py | 42 +-- openslides/assignments/config_variables.py | 88 ++---- ...auto_20191017_1040.py => 0008_voting_1.py} | 58 +++- .../assignments/migrations/0009_voting_2.py | 145 +++++++++ .../assignments/migrations/0010_voting_3.py | 23 ++ openslides/assignments/models.py | 85 +++--- openslides/assignments/serializers.py | 81 +++-- openslides/motions/access_permissions.py | 40 +-- openslides/motions/config_variables.py | 77 ++--- ...auto_20191017_1100.py => 0033_voting_1.py} | 31 ++ .../motions/migrations/0034_voting_2.py | 107 +++++++ openslides/motions/models.py | 3 - openslides/motions/serializers.py | 51 ++-- openslides/poll/access_permissions.py | 42 +++ openslides/poll/majority.py | 7 - openslides/poll/models.py | 34 ++- openslides/poll/serializers.py | 89 +++++- tests/integration/assignments/test_polls.py | 289 +++++++++++++----- tests/integration/motions/test_polls.py | 172 ++++++----- 22 files changed, 995 insertions(+), 477 deletions(-) rename openslides/assignments/migrations/{0008_auto_20191017_1040.py => 0008_voting_1.py} (72%) create mode 100644 openslides/assignments/migrations/0009_voting_2.py create mode 100644 openslides/assignments/migrations/0010_voting_3.py rename openslides/motions/migrations/{0033_auto_20191017_1100.py => 0033_voting_1.py} (81%) create mode 100644 openslides/motions/migrations/0034_voting_2.py delete mode 100644 openslides/poll/majority.py diff --git a/client/src/app/shared/models/assignments/assignment.ts b/client/src/app/shared/models/assignments/assignment.ts index 7e763f88a..620126972 100644 --- a/client/src/app/shared/models/assignments/assignment.ts +++ b/client/src/app/shared/models/assignments/assignment.ts @@ -7,7 +7,7 @@ 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[]; } 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 f400d8ae1..8ab276b7a 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 @@ -280,13 +280,13 @@ [form]="assignmentForm" > - +
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 ecbc3353a..bf795e250 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 @@ -189,7 +189,7 @@ 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: [], diff --git a/openslides/assignments/access_permissions.py b/openslides/assignments/access_permissions.py index 96a43f647..9a76c5bd5 100644 --- a/openslides/assignments/access_permissions.py +++ b/openslides/assignments/access_permissions.py @@ -1,8 +1,9 @@ -import json from typing import Any, Dict, List -from ..poll.access_permissions import BaseVoteAccessPermissions -from ..poll.views import BasePoll +from ..poll.access_permissions import ( + BasePollAccessPermissions, + BaseVoteAccessPermissions, +) from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm @@ -47,39 +48,10 @@ class AssignmentAccessPermissions(BaseAccessPermissions): return data -class AssignmentPollAccessPermissions(BaseAccessPermissions): +class AssignmentPollAccessPermissions(BasePollAccessPermissions): base_permission = "assignments.can_see" - - 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 voted_id field from the poll - """ - - if await async_has_perm(user_id, "assignments.can_manage_polls"): - 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 option in poll["options"]: - del option["yes"] - del option["no"] - del option["abstain"] - data.append(poll) - return data + manage_permission = "assignments.can_manage_polls" + additional_fields = ["amount_global_no", "amount_global_abstain"] class AssignmentVoteAccessPermissions(BaseVoteAccessPermissions): diff --git a/openslides/assignments/config_variables.py b/openslides/assignments/config_variables.py index 5a7e40f9a..f63cbbdf1 100644 --- a/openslides/assignments/config_variables.py +++ b/openslides/assignments/config_variables.py @@ -1,7 +1,5 @@ -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(): @@ -11,87 +9,47 @@ def get_config_variables(): 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 + # Polls yield ConfigVariable( - name="assignments_poll_100_percent_base", - default_value="YES_NO_ABSTAIN", + name="assignment_poll_default_100_percent_base", + default_value="YNA", 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": base[0], "display_name": base[1]} + for base in AssignmentPoll.PERCENT_BASES ), - 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", + weight=400, + group="Polls", + subgroup="Elections", ) - # TODO: Add server side validation of the choices. yield ConfigVariable( - name="assignments_poll_default_majority_method", - default_value=majorityMethods[0]["value"], + name="assignment_poll_default_majority_method", + default_value="simple", input_type="choice", - choices=majorityMethods, + choices=tuple( + {"value": method[0], "display_name": method[1]} + for method in AssignmentPoll.MAJORITY_METHODS + ), label="Required majority", help_text="Default method to check whether a candidate has reached the required majority.", - weight=425, - group="Elections", - subgroup="Ballot and ballot papers", + weight=405, + group="Polls", + subgroup="Elections", ) yield ConfigVariable( - name="assignments_pdf_ballot_papers_selection", - default_value="CUSTOM_NUMBER", - input_type="choice", - label="Number of ballot papers (selection)", - choices=( - {"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"}, - { - "value": "NUMBER_OF_ALL_PARTICIPANTS", - "display_name": "Number of all participants", - }, - { - "value": "CUSTOM_NUMBER", - "display_name": "Use the following custom number", - }, - ), - weight=430, - group="Elections", - subgroup="Ballot and ballot papers", - ) - - yield ConfigVariable( - name="assignments_pdf_ballot_papers_number", - default_value=8, - input_type="integer", - label="Custom number of ballot papers", - weight=435, - group="Elections", - subgroup="Ballot and ballot papers", - validators=(MinValueValidator(1),), - ) - - yield ConfigVariable( - name="assignments_add_candidates_to_list_of_speakers", + 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=440, - group="Elections", - subgroup="Ballot and ballot papers", + weight=410, + group="Polls", + subgroup="Elections", ) # PDF - yield ConfigVariable( name="assignments_pdf_title", default_value="Elections", diff --git a/openslides/assignments/migrations/0008_auto_20191017_1040.py b/openslides/assignments/migrations/0008_voting_1.py similarity index 72% rename from openslides/assignments/migrations/0008_auto_20191017_1040.py rename to openslides/assignments/migrations/0008_voting_1.py index 5a8b4aac2..e220a4748 100644 --- a/openslides/assignments/migrations/0008_auto_20191017_1040.py +++ b/openslides/assignments/migrations/0008_voting_1.py @@ -21,8 +21,6 @@ class Migration(migrations.Migration): migrations.RenameField( model_name="assignmentoption", old_name="candidate", new_name="user" ), - migrations.RemoveField(model_name="assignmentpoll", name="description"), - migrations.RemoveField(model_name="assignmentpoll", name="published"), migrations.AddField( model_name="assignmentpoll", name="global_abstain", @@ -99,6 +97,53 @@ class Migration(migrations.Migration): 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 inclusive global ones"), + ("valid", "All valid ballots"), + ("cast", "All casted ballots"), + ("disabled", "Disabled (no percents)"), + ], + default="", + max_length=8, + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="assignment", + name="poll_description_default", + field=models.CharField(blank=True, max_length=255), + ), + 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", @@ -106,13 +151,6 @@ class Migration(migrations.Migration): choices=[("YN", "YN"), ("YNA", "YNA"), ("votes", "votes")], max_length=5 ), ), - migrations.AlterField( - model_name="assignmentvote", - name="value", - field=models.CharField( - choices=[("Y", "Y"), ("N", "N"), ("A", "A")], max_length=1 - ), - ), migrations.AlterField( model_name="assignmentvote", name="weight", @@ -134,6 +172,4 @@ class Migration(migrations.Migration): migrations.RenameField( model_name="assignmentpoll", old_name="votesvalid", new_name="db_votesvalid" ), - migrations.RemoveField(model_name="assignmentpoll", name="votesabstain"), - migrations.RemoveField(model_name="assignmentpoll", name="votesno"), ] diff --git a/openslides/assignments/migrations/0009_voting_2.py b/openslides/assignments/migrations/0009_voting_2.py new file mode 100644 index 000000000..da3437572 --- /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(): + # Polls, 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..5a3b2fc9a --- /dev/null +++ b/openslides/assignments/migrations/0010_voting_3.py @@ -0,0 +1,23 @@ +# 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"), + ] diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 008ecf2ca..4ee22bc9a 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -1,5 +1,4 @@ -from collections import OrderedDict -from typing import Any, Dict, List +from decimal import Decimal from django.conf import settings from django.core.validators import MinValueValidator @@ -120,7 +119,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. """ @@ -230,40 +229,6 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model self.phase = phase - 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} @@ -330,9 +295,6 @@ class AssignmentPollManager(models.Manager): ) -# Meta-TODO: Is this todo resolved? -# TODO: remove the type-ignoring in the next line, after this is solved: -# https://github.com/python/mypy/issues/3855 class AssignmentPoll(RESTModelMixin, BasePoll): access_permissions = AssignmentPollAccessPermissions() objects = AssignmentPollManager() @@ -343,12 +305,32 @@ class AssignmentPoll(RESTModelMixin, BasePoll): Assignment, on_delete=models.CASCADE, related_name="polls" ) + description = models.CharField(max_length=255, blank=True) + POLLMETHOD_YN = "YN" POLLMETHOD_YNA = "YNA" POLLMETHOD_VOTES = "votes" POLLMETHODS = (("YN", "YN"), ("YNA", "YNA"), ("votes", "votes")) 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 inclusive global ones"), + (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) global_no = models.BooleanField(default=True) @@ -360,6 +342,27 @@ class AssignmentPoll(RESTModelMixin, BasePoll): class Meta: default_permissions = () + @property + def amount_global_no(self): + if self.pollmethod != AssignmentPoll.POLLMETHOD_VOTES or not self.global_no: + return None + no_sum = Decimal(0) + for option in self.options.all(): + no_sum += option.no + return no_sum + + @property + def amount_global_abstain(self): + if ( + self.pollmethod != AssignmentPoll.POLLMETHOD_VOTES + or not self.global_abstain + ): + return None + abstain_sum = Decimal(0) + for option in self.options.all(): + abstain_sum += option.abstain + return abstain_sum + def create_options(self): related_users = AssignmentRelatedUser.objects.filter( assignment__id=self.assignment.id @@ -374,7 +377,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll): inform_changed_data(self) # Add all candidates to list of speakers of related agenda item - if config["assignments_add_candidates_to_list_of_speakers"]: + if config["assignment_poll_add_candidates_to_list_of_speakers"]: for related_user in related_users: try: Speaker.objects.add( diff --git a/openslides/assignments/serializers.py b/openslides/assignments/serializers.py index 9ef737cb8..13ed729d8 100644 --- a/openslides/assignments/serializers.py +++ b/openslides/assignments/serializers.py @@ -2,19 +2,19 @@ from openslides.poll.serializers import ( BASE_OPTION_FIELDS, BASE_POLL_FIELDS, BASE_VOTE_FIELDS, + BaseOptionSerializer, + BasePollSerializer, + BaseVoteSerializer, ) from openslides.utils.rest_api import ( BooleanField, - CharField, DecimalField, - IdPrimaryKeyRelatedField, IntegerField, ModelSerializer, - SerializerMethodField, ValidationError, ) -from ..utils.auth import get_group_model, has_perm +from ..utils.auth import has_perm from ..utils.autoupdate import inform_changed_data from ..utils.validate import validate_html from .models import ( @@ -47,40 +47,29 @@ class AssignmentRelatedUserSerializer(ModelSerializer): fields = ("id", "user", "elected", "weight") -class AssignmentVoteSerializer(ModelSerializer): +class AssignmentVoteSerializer(BaseVoteSerializer): """ Serializer for assignment.models.AssignmentVote objects. """ - pollstate = SerializerMethodField() - class Meta: model = AssignmentVote - fields = ("pollstate",) + BASE_VOTE_FIELDS + fields = BASE_VOTE_FIELDS read_only_fields = BASE_VOTE_FIELDS - def get_pollstate(self, vote): - return vote.option.poll.state - -class AssignmentOptionSerializer(ModelSerializer): +class AssignmentOptionSerializer(BaseOptionSerializer): """ Serializer for assignment.models.AssignmentOption objects. """ - 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 - ) - class Meta: model = AssignmentOption fields = ("user", "weight") + BASE_OPTION_FIELDS read_only_fields = ("user", "weight") + BASE_OPTION_FIELDS -class AssignmentPollSerializer(ModelSerializer): +class AssignmentPollSerializer(BasePollSerializer): """ Serializer for assignment.models.AssignmentPoll objects. @@ -88,20 +77,10 @@ class AssignmentPollSerializer(ModelSerializer): """ options = AssignmentOptionSerializer(many=True, read_only=True) - - title = CharField(allow_blank=False, required=True) - groups = IdPrimaryKeyRelatedField( - many=True, required=False, queryset=get_group_model().objects.all() - ) - voted = IdPrimaryKeyRelatedField(many=True, read_only=True) - - votesvalid = DecimalField( + amount_global_no = 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( + amount_global_abstain = DecimalField( max_digits=15, decimal_places=6, min_value=-2, read_only=True ) @@ -109,19 +88,55 @@ class AssignmentPollSerializer(ModelSerializer): model = AssignmentPoll fields = ( "assignment", + "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 update(self, instance, validated_data): - """ Prevent from updating the assignment """ + """ 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 + ): + """ + Returns None, if the 100-%-base must not be changed, otherwise the correct 100-%-base. + """ + 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 AssignmentSerializer(ModelSerializer): """ @@ -146,7 +161,7 @@ class AssignmentSerializer(ModelSerializer): "open_posts", "phase", "assignment_related_users", - "poll_description_default", + "default_poll_description", "agenda_item_id", "list_of_speakers_id", "agenda_create", diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py index 3f1a07903..797f81d25 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -1,8 +1,10 @@ import json from typing import Any, Dict, List -from ..poll.access_permissions import BaseVoteAccessPermissions -from ..poll.views import BasePoll +from ..poll.access_permissions import ( + BasePollAccessPermissions, + BaseVoteAccessPermissions, +) from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm, async_in_some_groups @@ -183,39 +185,9 @@ class StateAccessPermissions(BaseAccessPermissions): base_permission = "motions.can_see" -class MotionPollAccessPermissions(BaseAccessPermissions): +class MotionPollAccessPermissions(BasePollAccessPermissions): base_permission = "motions.can_see" - - 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 voted_id field from the poll - """ - - if await async_has_perm(user_id, "motions.can_manage_polls"): - 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 option in poll["options"]: - del option["yes"] - del option["no"] - del option["abstain"] - data.append(poll) - return data + manage_permission = "motions.can_manage_polls" class MotionVoteAccessPermissions(BaseVoteAccessPermissions): diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index abc052e74..c9251e71d 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 @@ -348,51 +348,6 @@ def get_config_variables(): 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"], - input_type="choice", - choices=majorityMethods, - label="Required majority", - help_text="Default method to check whether a motion has reached the required majority.", - weight=372, - group="Motions", - subgroup="Voting and ballot papers", - ) - - yield ConfigVariable( - name="motions_pdf_ballot_papers_selection", - default_value="CUSTOM_NUMBER", - input_type="choice", - label="Number of ballot papers (selection)", - choices=( - {"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"}, - { - "value": "NUMBER_OF_ALL_PARTICIPANTS", - "display_name": "Number of all participants", - }, - { - "value": "CUSTOM_NUMBER", - "display_name": "Use the following custom number", - }, - ), - weight=374, - group="Motions", - subgroup="Voting and ballot papers", - ) - - yield ConfigVariable( - name="motions_pdf_ballot_papers_number", - default_value=8, - input_type="integer", - label="Custom number of ballot papers", - weight=376, - group="Motions", - subgroup="Voting and ballot papers", - validators=(MinValueValidator(1),), - ) - # PDF export yield ConfigVariable( @@ -432,3 +387,33 @@ def get_config_variables(): group="Motions", subgroup="PDF export", ) + + # Polls + yield ConfigVariable( + name="motion_poll_default_100_percent_base", + default_value="YNA", + input_type="choice", + label="The 100-%-base of an election result consists of", + choices=tuple( + {"value": base[0], "display_name": base[1]} + for base in MotionPoll.PERCENT_BASES + ), + weight=420, + group="Polls", + subgroup="Motions", + ) + + yield ConfigVariable( + name="motion_poll_default_majority_method", + default_value="simple", + input_type="choice", + 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 candidate has reached the required majority.", + weight=425, + group="Polls", + subgroup="Motions", + ) diff --git a/openslides/motions/migrations/0033_auto_20191017_1100.py b/openslides/motions/migrations/0033_voting_1.py similarity index 81% rename from openslides/motions/migrations/0033_auto_20191017_1100.py rename to openslides/motions/migrations/0033_voting_1.py index 2dac920de..0d0ba8554 100644 --- a/openslides/motions/migrations/0033_auto_20191017_1100.py +++ b/openslides/motions/migrations/0033_voting_1.py @@ -81,6 +81,37 @@ class Migration(migrations.Migration): name="voted", field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), ), + 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 per candidate"), + ("YNA", "Yes/No/Abstain per candidate"), + ("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", diff --git a/openslides/motions/migrations/0034_voting_2.py b/openslides/motions/migrations/0034_voting_2.py new file mode 100644 index 000000000..7d1f8fb56 --- /dev/null +++ b/openslides/motions/migrations/0034_voting_2.py @@ -0,0 +1,107 @@ +# 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(): + 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 46dd0b9f7..dbea8468c 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -923,9 +923,6 @@ class MotionPollManager(models.Manager): ) -# Meta-TODO: Is this todo resolved? -# TODO: remove the type-ignoring in the next line, after this is solved: -# https://github.com/python/mypy/issues/3855 class MotionPoll(RESTModelMixin, BasePoll): access_permissions = MotionPollAccessPermissions() option_class = MotionOption diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index dfcee8b25..2a526abd7 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -5,6 +5,9 @@ from openslides.poll.serializers import ( BASE_OPTION_FIELDS, BASE_POLL_FIELDS, BASE_VOTE_FIELDS, + BaseOptionSerializer, + BasePollSerializer, + BaseVoteSerializer, ) from ..core.config import config @@ -13,7 +16,6 @@ from ..utils.autoupdate import inform_changed_data from ..utils.rest_api import ( BooleanField, CharField, - DecimalField, Field, IdPrimaryKeyRelatedField, IntegerField, @@ -224,64 +226,47 @@ class AmendmentParagraphsJSONSerializerField(Field): return data -class MotionVoteSerializer(ModelSerializer): - pollstate = SerializerMethodField() - +class MotionVoteSerializer(BaseVoteSerializer): class Meta: model = MotionVote - fields = ("pollstate",) + BASE_VOTE_FIELDS + fields = BASE_VOTE_FIELDS read_only_fields = BASE_VOTE_FIELDS - def get_pollstate(self, vote): - return vote.option.poll.state - - -class MotionOptionSerializer(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 - ) +class MotionOptionSerializer(BaseOptionSerializer): class Meta: model = MotionOption fields = BASE_OPTION_FIELDS read_only_fields = BASE_OPTION_FIELDS -class MotionPollSerializer(ModelSerializer): +class MotionPollSerializer(BasePollSerializer): """ Serializer for motion.models.MotionPoll objects. """ options = MotionOptionSerializer(many=True, read_only=True) - title = CharField(allow_blank=False, required=True) - groups = IdPrimaryKeyRelatedField( - many=True, required=False, queryset=get_group_model().objects.all() - ) - 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 - ) - class Meta: model = MotionPoll fields = ("motion", "pollmethod") + BASE_POLL_FIELDS read_only_fields = ("state",) def update(self, instance, validated_data): - """ Prevent from updating the motion """ + """ Prevent updating the motion """ validated_data.pop("motion", None) return super().update(instance, validated_data) + 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): """ diff --git a/openslides/poll/access_permissions.py b/openslides/poll/access_permissions.py index ba3d3aa1b..86d1820e2 100644 --- a/openslides/poll/access_permissions.py +++ b/openslides/poll/access_permissions.py @@ -1,3 +1,4 @@ +import json from typing import Any, Dict, List from ..poll.views import BasePoll @@ -5,6 +6,47 @@ from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm +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 voted_id field from the poll + - Remove fields given in self.assitional_fields from the poll + """ + + 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] + for option in poll["options"]: + del option["yes"] + del option["no"] + del option["abstain"] + data.append(poll) + return data + + class BaseVoteAccessPermissions(BaseAccessPermissions): manage_permission = "" # set by subclass 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 29c48770e..6415583a5 100644 --- a/openslides/poll/models.py +++ b/openslides/poll/models.py @@ -1,5 +1,5 @@ from decimal import Decimal -from typing import Optional, Type +from typing import Iterable, Optional, Tuple, Type from django.conf import settings from django.core.validators import MinValueValidator @@ -131,6 +131,36 @@ class BasePoll(models.Model): 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 per candidate"), + (PERCENT_BASE_YNA, "Yes/No/Abstain per candidate"), + (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 @@ -201,8 +231,6 @@ class BasePoll(models.Model): def get_votes(self): """ Return a QuerySet with all vote objects related to this poll. - - TODO: This might be a performance issue when used in properties that are serialized. """ return self.get_vote_class().objects.filter(option__poll__id=self.id) diff --git a/openslides/poll/serializers.py b/openslides/poll/serializers.py index 9bc312623..74af68396 100644 --- a/openslides/poll/serializers.py +++ b/openslides/poll/serializers.py @@ -1,3 +1,34 @@ +from ..utils.auth import get_group_model +from ..utils.rest_api import ( + CharField, + DecimalField, + IdPrimaryKeyRelatedField, + ModelSerializer, + SerializerMethodField, +) + + +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") + + +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 + ) + + BASE_POLL_FIELDS = ( "state", "type", @@ -9,8 +40,62 @@ BASE_POLL_FIELDS = ( "options", "voted", "id", + "onehundred_percent_base", + "majority_method", ) -BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain") -BASE_VOTE_FIELDS = ("id", "weight", "value", "user", "option") +class BasePollSerializer(ModelSerializer): + title = CharField(allow_blank=False, required=True) + groups = IdPrimaryKeyRelatedField( + many=True, required=False, queryset=get_group_model().objects.all() + ) + 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 comobinations 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 micht noght 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 norm_100_percent_base_to_pollmethod( + self, onehundred_percent_base, pollmethod, old_100_percent_base=None + ): + raise NotImplementedError() diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index ef05cb594..acdc1d7ca 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -91,7 +91,7 @@ def create_assignment_polls(): class CreateAssignmentPoll(TestCase): def advancedSetUp(self): self.assignment = Assignment.objects.create( - title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1 + title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1, ) self.assignment.add_candidate(self.admin) @@ -100,24 +100,28 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_ailai4toogh3eefaa2Vo", - "pollmethod": "YNA", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "type": "named", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(AssignmentPoll.objects.exists()) poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo") - self.assertEqual(poll.pollmethod, "YNA") + 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) @@ -127,26 +131,29 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_ahThai4pae1pi4xoogoo", - "pollmethod": "YN", + "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", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(AssignmentPoll.objects.exists()) poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_ahThai4pae1pi4xoogoo") - self.assertEqual(poll.pollmethod, "YN") + 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) @@ -154,62 +161,34 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_eing5eipue5cha2Iefai", - "pollmethod": "YNA", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "type": "named", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) - def test_missing_title(self): - response = self.client.post( - reverse("assignmentpoll-list"), - {"pollmethod": "YNA", "type": "named", "assignment_id": self.assignment.id}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(AssignmentPoll.objects.exists()) - - def test_missing_pollmethod(self): - response = self.client.post( - reverse("assignmentpoll-list"), - { - "title": "test_title_OoCh9aitaeyaeth8nom1", - "type": "named", - "assignment_id": self.assignment.id, - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(AssignmentPoll.objects.exists()) - - def test_missing_type(self): - response = self.client.post( - reverse("assignmentpoll-list"), - { - "title": "test_title_Ail9Eizohshim0fora6o", - "pollmethod": "YNA", - "assignment_id": self.assignment.id, - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(AssignmentPoll.objects.exists()) - - def test_missing_assignment_id(self): - response = self.client.post( - reverse("assignmentpoll-list"), - { - "title": "test_title_eic7ooxaht5mee3quohK", - "pollmethod": "YNA", - "type": "named", - }, - format="json", - ) - self.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) def test_with_groups(self): group1 = get_group_model().objects.get(pk=1) @@ -218,12 +197,13 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", + "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], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) poll = AssignmentPoll.objects.get() @@ -235,12 +215,13 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", + "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": [], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) poll = AssignmentPoll.objects.get() @@ -251,11 +232,12 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_yaiyeighoh0Iraet3Ahc", - "pollmethod": "YNA", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "type": "not_existing", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) @@ -268,12 +250,93 @@ class CreateAssignmentPoll(TestCase): "pollmethod": "not_existing", "type": "named", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, - format="json", ) self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual( + poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_VOTES + ) + class UpdateAssignmentPoll(TestCase): """ @@ -289,8 +352,10 @@ class UpdateAssignmentPoll(TestCase): self.poll = AssignmentPoll.objects.create( assignment=self.assignment, title="test_title_beeFaihuNae1vej2ai8m", - pollmethod="votes", + 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) @@ -317,11 +382,13 @@ class UpdateAssignmentPoll(TestCase): def test_patch_pollmethod(self): response = self.client.patch( - reverse("assignmentpoll-detail", args=[self.poll.pk]), {"pollmethod": "YNA"} + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"pollmethod": AssignmentPoll.POLLMETHOD_YNA}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() - self.assertEqual(poll.pollmethod, "YNA") + 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( @@ -330,7 +397,7 @@ class UpdateAssignmentPoll(TestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) poll = AssignmentPoll.objects.get() - self.assertEqual(poll.pollmethod, "votes") + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_VOTES) def test_patch_type(self): response = self.client.patch( @@ -350,9 +417,7 @@ class UpdateAssignmentPoll(TestCase): def test_patch_groups_to_empty(self): response = self.client.patch( - reverse("assignmentpoll-detail", args=[self.poll.pk]), - {"groups_id": []}, - format="json", + reverse("assignmentpoll-detail", args=[self.poll.pk]), {"groups_id": []}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() @@ -363,7 +428,6 @@ class UpdateAssignmentPoll(TestCase): response = self.client.patch( reverse("assignmentpoll-detail", args=[self.poll.pk]), {"groups_id": [group2.id]}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() @@ -381,12 +445,50 @@ class UpdateAssignmentPoll(TestCase): poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_beeFaihuNae1vej2ai8m") + def test_patch_100_percent_base(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "cast"}, + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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": "votes", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, "global_no": True, "global_abstain": False, "allow_multiple_votes_per_candidate": True, @@ -396,9 +498,11 @@ class UpdateAssignmentPoll(TestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_ees6Tho8ahheen4cieja") - self.assertEqual(poll.pollmethod, "votes") + 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) @@ -462,7 +566,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): "votesvalid": "4.64", "votesinvalid": "-2", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(AssignmentVote.objects.count(), 6) @@ -490,7 +593,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): "2": {"Y": "1", "N": "2.35", "A": "-1"}, } }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -501,7 +603,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": {"1": {"Y": "1", "N": "2.35", "A": "-1"}}}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -517,7 +618,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): "3": {"Y": "1", "N": "2.35", "A": "-1"}, } }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -543,9 +643,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): 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", + reverse("assignmentpoll-vote", args=[self.poll.pk]), [1, 2, 5], ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -555,7 +653,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": [1, "string"]}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -565,7 +662,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": {"string": "some_other_string"}}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -575,7 +671,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": {"1": [None]}}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -586,7 +681,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): 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, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), data ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -861,6 +956,8 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.no, Decimal("2")) self.assertEqual(option.abstain, Decimal("0")) + self.assertEqual(poll.amount_global_no, Decimal("2")) + self.assertEqual(poll.amount_global_abstain, Decimal("0")) def test_global_no_forbidden(self): self.poll.global_no = False @@ -871,6 +968,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): ) self.assertEqual(response.status_code, 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 @@ -885,6 +983,8 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.no, Decimal("0")) self.assertEqual(option.abstain, Decimal("2")) + self.assertEqual(poll.amount_global_no, Decimal("0")) + self.assertEqual(poll.amount_global_abstain, Decimal("2")) def test_global_abstain_forbidden(self): self.poll.global_abstain = False @@ -895,6 +995,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): ) self.assertEqual(response.status_code, 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() @@ -1463,12 +1564,16 @@ class VoteAssignmentPollAutoupdatesBaseClass(TestCase): 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) @@ -1495,6 +1600,8 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "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": [ @@ -1510,7 +1617,10 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "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, "voted_id": [self.user.id], "votes_amount": 1, "votescast": "1.000000", @@ -1558,7 +1668,10 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "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, "user_id": self.admin.id, "weight": 1}], "id": 1, @@ -1593,6 +1706,8 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "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": [ @@ -1608,7 +1723,10 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "state": AssignmentPoll.STATE_STARTED, "title": self.poll.title, + "description": self.description, "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, + "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, "voted_id": [self.user.id], "votes_amount": 1, "votescast": "1.000000", @@ -1642,7 +1760,10 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "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, "user_id": self.admin.id, "weight": 1}], "id": 1, diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index ae4b3f977..66c6a320d 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -95,8 +95,9 @@ class CreateMotionPoll(TestCase): "pollmethod": "YNA", "type": "named", "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(MotionPoll.objects.exists()) @@ -107,53 +108,24 @@ class CreateMotionPoll(TestCase): self.assertEqual(poll.motion.id, self.motion.id) self.assertTrue(poll.options.exists()) - def test_missing_title(self): - response = self.client.post( - reverse("motionpoll-list"), - {"pollmethod": "YNA", "type": "named", "motion_id": self.motion.id}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(MotionPoll.objects.exists()) - - def test_missing_pollmethod(self): - response = self.client.post( - reverse("motionpoll-list"), - { - "title": "test_title_OoCh9aitaeyaeth8nom1", - "type": "named", - "motion_id": self.motion.id, - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(MotionPoll.objects.exists()) - - def test_missing_type(self): - response = self.client.post( - reverse("motionpoll-list"), - { - "title": "test_title_Ail9Eizohshim0fora6o", - "pollmethod": "YNA", - "motion_id": self.motion.id, - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(MotionPoll.objects.exists()) - - def test_missing_assignment_id(self): - response = self.client.post( - reverse("motionpoll-list"), - { - "title": "test_title_eic7ooxaht5mee3quohK", - "pollmethod": "YNA", - "type": "named", - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(MotionPoll.objects.exists()) + def test_missing_keys(self): + complete_request_data = { + "title": "test_title_OoCh9aitaeyaeth8nom1", + "type": "named", + "pollmethod": "YNA", + "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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) def test_with_groups(self): group1 = get_group_model().objects.get(pk=1) @@ -165,9 +137,10 @@ class CreateMotionPoll(TestCase): "pollmethod": "YNA", "type": "named", "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [1, 2], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) poll = MotionPoll.objects.get() @@ -182,9 +155,10 @@ class CreateMotionPoll(TestCase): "pollmethod": "YNA", "type": "named", "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) poll = MotionPoll.objects.get() @@ -199,7 +173,6 @@ class CreateMotionPoll(TestCase): "type": "not_existing", "motion_id": self.motion.id, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.exists()) @@ -213,7 +186,6 @@ class CreateMotionPoll(TestCase): "type": "named", "motion_id": self.motion.id, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.exists()) @@ -236,8 +208,10 @@ class UpdateMotionPoll(TestCase): self.poll = MotionPoll.objects.create( motion=self.motion, title="test_title_beeFaihuNae1vej2ai8m", - pollmethod="YN", + pollmethod="YNA", type="named", + onehundred_percent_base="YN", + majority_method="simple", ) self.poll.create_options() self.poll.groups.add(self.group) @@ -266,11 +240,12 @@ class UpdateMotionPoll(TestCase): def test_patch_pollmethod(self): response = self.client.patch( - reverse("motionpoll-detail", args=[self.poll.pk]), {"pollmethod": "YNA"} + reverse("motionpoll-detail", args=[self.poll.pk]), {"pollmethod": "YN"} ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() - self.assertEqual(poll.pollmethod, "YNA") + self.assertEqual(poll.pollmethod, "YN") + self.assertEqual(poll.onehundred_percent_base, "YN") def test_patch_invalid_pollmethod(self): response = self.client.patch( @@ -278,7 +253,7 @@ class UpdateMotionPoll(TestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) poll = MotionPoll.objects.get() - self.assertEqual(poll.pollmethod, "YN") + self.assertEqual(poll.pollmethod, "YNA") def test_patch_type(self): response = self.client.patch( @@ -296,11 +271,45 @@ class UpdateMotionPoll(TestCase): poll = MotionPoll.objects.get() self.assertEqual(poll.type, "named") - def test_patch_groups_to_empty(self): + def test_patch_100_percent_base(self): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), - {"groups_id": []}, - format="json", + {"onehundred_percent_base": "cast"}, + ) + self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -311,7 +320,6 @@ class UpdateMotionPoll(TestCase): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), {"groups_id": [group2.id]}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -378,7 +386,6 @@ class VoteMotionPollAnalog(TestCase): "votesvalid": "4.64", "votesinvalid": "-2", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -403,9 +410,7 @@ class VoteMotionPollAnalog(TestCase): 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"}, - format="json", + reverse("motionpoll-vote", args=[self.poll.pk]), {"Y": "4", "N": "22.6"}, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -413,7 +418,7 @@ class VoteMotionPollAnalog(TestCase): def test_vote_wrong_data_format(self): self.start_poll() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5], format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -423,7 +428,6 @@ class VoteMotionPollAnalog(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), {"Y": "some string", "N": "-2", "A": "3"}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -477,7 +481,7 @@ class VoteMotionPollNamed(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -498,11 +502,11 @@ class VoteMotionPollNamed(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) self.assertEqual(response.status_code, status.HTTP_200_OK) response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "A", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "A" ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -524,7 +528,7 @@ class VoteMotionPollNamed(TestCase): config["general_system_enable_anonymous"] = True guest_client = APIClient() response = guest_client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "Y", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "Y" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -572,7 +576,7 @@ class VoteMotionPollNamed(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5], format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -608,6 +612,8 @@ class VoteMotionPollNamedAutoupdates(TestCase): 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) @@ -631,6 +637,8 @@ class VoteMotionPollNamedAutoupdates(TestCase): "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", @@ -685,6 +693,8 @@ class VoteMotionPollNamedAutoupdates(TestCase): "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, @@ -726,6 +736,8 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): 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) @@ -749,6 +761,8 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "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", @@ -789,6 +803,8 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "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, @@ -847,7 +863,7 @@ class VoteMotionPollPseudoanonymous(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -869,11 +885,11 @@ class VoteMotionPollPseudoanonymous(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) self.assertEqual(response.status_code, status.HTTP_200_OK) response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "A", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "A" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) option = MotionPoll.objects.get().options.get() @@ -889,7 +905,7 @@ class VoteMotionPollPseudoanonymous(TestCase): config["general_system_enable_anonymous"] = True guest_client = APIClient() response = guest_client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "Y", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "Y" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -928,7 +944,7 @@ class VoteMotionPollPseudoanonymous(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5], format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -976,6 +992,8 @@ class PublishMotionPoll(TestCase): 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() @@ -1003,6 +1021,8 @@ class PublishMotionPoll(TestCase): "state": 4, "type": "pseudoanonymous", "title": "test_title_Nufae0iew7Iorox2thoo", + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [], "votesvalid": "0.000000", "votesinvalid": "0.000000", From 6605934a3372e071d4114b2a46a5c7ac7df1ea6e Mon Sep 17 00:00:00 2001 From: jsangmeister Date: Fri, 1 Nov 2019 09:11:12 +0100 Subject: [PATCH 05/55] added count query decorator --- openslides/motions/views.py | 4 +- openslides/utils/cache.py | 6 +- tests/count_queries.py | 32 ++++++++ tests/integration/agenda/test_viewset.py | 2 +- tests/integration/assignments/test_polls.py | 5 +- tests/integration/assignments/test_viewset.py | 3 +- tests/integration/core/test_viewset.py | 3 +- tests/integration/helpers.py | 17 ----- tests/integration/mediafiles/test_viewset.py | 3 +- tests/integration/motions/test_motions.py | 76 ++++++++++++++++++- tests/integration/motions/test_polls.py | 3 +- tests/integration/motions/test_viewset.py | 3 +- tests/integration/topics/test_viewset.py | 3 +- tests/integration/users/test_viewset.py | 2 +- 14 files changed, 123 insertions(+), 39 deletions(-) create mode 100644 tests/count_queries.py diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 629f8a3cf..bc98d956b 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -192,9 +192,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."} diff --git a/openslides/utils/cache.py b/openslides/utils/cache.py index 2cf03d41b..92cc0385a 100644 --- a/openslides/utils/cache.py +++ b/openslides/utils/cache.py @@ -329,7 +329,11 @@ 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()): diff --git a/tests/count_queries.py b/tests/count_queries.py new file mode 100644 index 000000000..4f207652d --- /dev/null +++ b/tests/count_queries.py @@ -0,0 +1,32 @@ +from django.db import DEFAULT_DB_ALIAS, connections +from django.test.utils import CaptureQueriesContext + + +def count_queries(func, verbose=False, *args, **kwargs) -> int: + context = CaptureQueriesContext(connections[DEFAULT_DB_ALIAS]) + with context: + func(*args, **kwargs) + + if verbose: + 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 assert_query_count(should_be, verbose=False): + """ + Decorator to easily count queries on any test you want. + should_be defines how many queries are to be expected + """ + + def outer(func): + def inner(*args, **kwargs): + assert count_queries(func, verbose, *args, **kwargs) == should_be + + return inner + + return outer diff --git a/tests/integration/agenda/test_viewset.py b/tests/integration/agenda/test_viewset.py index b0b898a3d..5f38a18f4 100644 --- a/tests/integration/agenda/test_viewset.py +++ b/tests/integration/agenda/test_viewset.py @@ -16,10 +16,10 @@ from openslides.motions.models import Motion from openslides.topics.models import Topic from openslides.users.models import Group from openslides.utils.autoupdate import inform_changed_data +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 class ContentObjects(TestCase): diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index acdc1d7ca..0a48ee504 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -18,10 +18,9 @@ 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 assert_query_count, count_queries from tests.test_case import TestCase -from ..helpers import count_queries - @pytest.mark.django_db(transaction=False) def test_assignment_poll_db_queries(): @@ -95,6 +94,8 @@ class CreateAssignmentPoll(TestCase): ) self.assignment.add_candidate(self.admin) + # TODO lower query count + @assert_query_count(47, True) def test_simple(self): response = self.client.post( reverse("assignmentpoll-list"), diff --git a/tests/integration/assignments/test_viewset.py b/tests/integration/assignments/test_viewset.py index eb837226c..9474f28c7 100644 --- a/tests/integration/assignments/test_viewset.py +++ b/tests/integration/assignments/test_viewset.py @@ -9,10 +9,9 @@ from openslides.assignments.models import Assignment from openslides.core.models import Tag from openslides.mediafiles.models import Mediafile from openslides.utils.autoupdate import inform_changed_data +from tests.count_queries import count_queries from tests.test_case import TestCase -from ..helpers import count_queries - @pytest.mark.django_db(transaction=False) def test_assignment_db_queries(): diff --git a/tests/integration/core/test_viewset.py b/tests/integration/core/test_viewset.py index a8a2e2111..568422df9 100644 --- a/tests/integration/core/test_viewset.py +++ b/tests/integration/core/test_viewset.py @@ -14,10 +14,9 @@ from openslides.users.models import User 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 -from ..helpers import count_queries - @pytest.mark.django_db(transaction=False) def test_projector_db_queries(): 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 eaa74bad1..f7c37d4c8 100644 --- a/tests/integration/mediafiles/test_viewset.py +++ b/tests/integration/mediafiles/test_viewset.py @@ -7,10 +7,9 @@ from rest_framework import status from rest_framework.test import APIClient from openslides.mediafiles.models import Mediafile +from tests.count_queries import count_queries from tests.test_case import TestCase -from ..helpers import count_queries - @pytest.mark.django_db(transaction=False) def test_mediafiles_db_queries(): diff --git a/tests/integration/motions/test_motions.py b/tests/integration/motions/test_motions.py index 026353ae9..b8dce6966 100644 --- a/tests/integration/motions/test_motions.py +++ b/tests/integration/motions/test_motions.py @@ -24,10 +24,9 @@ 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 assert_query_count, count_queries from tests.test_case import TestCase -from ..helpers import count_queries - @pytest.mark.django_db(transaction=False) def test_motion_db_queries(): @@ -126,10 +125,13 @@ class CreateMotion(TestCase): Tests motion creation. """ + maxDiff = None + def setUp(self): self.client = APIClient() self.client.login(username="admin", password="admin") + @assert_query_count(85, True) def test_simple(self): """ Tests that a motion is created with a specific title and text. @@ -146,6 +148,76 @@ class CreateMotion(TestCase): ) 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()) diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index 66c6a320d..25fad2578 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -12,10 +12,9 @@ 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 -from ..helpers import count_queries - @pytest.mark.django_db(transaction=False) def test_motion_poll_db_queries(): diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py index af91831eb..ac71efa21 100644 --- a/tests/integration/motions/test_viewset.py +++ b/tests/integration/motions/test_viewset.py @@ -19,10 +19,9 @@ from openslides.motions.models import ( 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, GROUP_STAFF_PK +from tests.count_queries import count_queries from tests.test_case import TestCase -from ..helpers import count_queries - @pytest.mark.django_db(transaction=False) def test_category_db_queries(): diff --git a/tests/integration/topics/test_viewset.py b/tests/integration/topics/test_viewset.py index 04a6f5641..c9d2cc8a0 100644 --- a/tests/integration/topics/test_viewset.py +++ b/tests/integration/topics/test_viewset.py @@ -4,10 +4,9 @@ from rest_framework import status from openslides.agenda.models import Item from openslides.topics.models import Topic +from tests.count_queries import count_queries from tests.test_case import TestCase -from ..helpers import count_queries - @pytest.mark.django_db(transaction=False) def test_topic_item_db_queries(): diff --git a/tests/integration/users/test_viewset.py b/tests/integration/users/test_viewset.py index f57322881..3a8ce3be6 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -8,6 +8,7 @@ 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 tests.count_queries import count_queries from tests.test_case import TestCase from ...common_groups import ( @@ -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) From 046a152ec5ac92d4bb724fe90ecb0109a666b73d Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Mon, 4 Nov 2019 14:56:01 +0100 Subject: [PATCH 06/55] generate less queries in the autoupdate system --- openslides/agenda/models.py | 31 +- openslides/assignments/models.py | 46 ++- openslides/core/models.py | 31 +- openslides/core/signals.py | 18 +- openslides/mediafiles/config.py | 4 +- openslides/mediafiles/models.py | 12 +- openslides/motions/models.py | 77 ++-- openslides/topics/models.py | 14 +- openslides/users/models.py | 38 +- openslides/users/views.py | 11 +- openslides/utils/autoupdate.py | 336 +++++++++--------- openslides/utils/consumers.py | 12 + openslides/utils/manager.py | 20 ++ openslides/utils/models.py | 18 +- tests/count_queries.py | 56 +-- tests/integration/agenda/test_viewset.py | 98 ++--- tests/integration/assignments/test_polls.py | 31 +- tests/integration/assignments/test_viewset.py | 18 +- tests/integration/core/test_viewset.py | 6 +- tests/integration/mediafiles/test_viewset.py | 2 +- tests/integration/motions/test_motions.py | 75 ++-- tests/integration/motions/test_polls.py | 4 +- tests/integration/motions/test_viewset.py | 11 +- tests/integration/topics/test_viewset.py | 2 +- tests/integration/users/test_viewset.py | 4 +- tests/integration/utils/test_consumers.py | 16 +- tests/test_case.py | 9 + 27 files changed, 525 insertions(+), 475 deletions(-) create mode 100644 openslides/utils/manager.py diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index d8f992401..8c6c709a1 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): diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 4ee22bc9a..a268c69e0 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -12,6 +12,7 @@ from openslides.mediafiles.models import Mediafile 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 @@ -63,24 +64,28 @@ 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", + ) ) @@ -233,17 +238,21 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model return {"title": self.title} -class AssignmentVoteManager(models.Manager): +class AssignmentVoteManager(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 assignment votes. In the background we join and prefetch all related models. """ - return self.get_queryset().select_related("user", "option", "option__poll") + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .select_related("user", "option", "option__poll") + ) class AssignmentVote(RESTModelMixin, BaseVote): @@ -276,18 +285,19 @@ class AssignmentOption(RESTModelMixin, BaseOption): return self.poll -class AssignmentPollManager(models.Manager): +class AssignmentPollManager(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 assignment polls. In the background we join and prefetch all related models. """ return ( - self.get_queryset() + super() + .get_prefetched_queryset(*args, **kwargs) .select_related("assignment") .prefetch_related( "options", "options__user", "options__votes", "groups", "voted" diff --git a/openslides/core/models.py b/openslides/core/models.py index b0a793924..ae108d570 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -1,13 +1,16 @@ +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.manager import BaseManager +from openslides.utils.models import SET_NULL_AND_AUTOUPDATE, RESTModelMixin + from .access_permissions import ( ConfigAccessPermissions, CountdownAccessPermissions, @@ -18,17 +21,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 +256,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,7 +273,7 @@ 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"] @@ -296,7 +303,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, 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..090472a9c 100644 --- a/openslides/mediafiles/models.py +++ b/openslides/mediafiles/models.py @@ -8,6 +8,8 @@ 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 from ..utils.models import RESTModelMixin @@ -23,18 +25,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/models.py b/openslides/motions/models.py index dbea8468c..05e58bfd0 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -11,6 +11,7 @@ from openslides.mediafiles.models import Mediafile 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 @@ -56,18 +57,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", @@ -76,12 +78,6 @@ class MotionManager(models.Manager): "comments__section__read_groups", "agenda_items", "lists_of_speakers", - "polls", - "polls__groups", - "polls__voted", - "polls__options", - "polls__options__votes", - "polls__options__votes__user", "attachments", "tags", "submitters", @@ -669,19 +665,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 @@ -689,8 +672,6 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model): access_permissions = MotionChangeRecommendationAccessPermissions() - objects = MotionChangeRecommendationManager() - motion = models.ForeignKey( Motion, on_delete=CASCADE_AND_AUTOUPDATE, related_name="change_recommendations" ) @@ -826,17 +807,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): @@ -867,17 +852,21 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode return {"title": self.title} -class MotionVoteManager(models.Manager): +class MotionVoteManager(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 votes. In the background we join and prefetch all related models. """ - return self.get_queryset().select_related("user", "option", "option__poll") + return ( + super() + .get_prefetched_queryset(*args, **kwargs) + .select_related("user", "option", "option__poll") + ) class MotionVote(RESTModelMixin, BaseVote): @@ -906,18 +895,19 @@ class MotionOption(RESTModelMixin, BaseOption): return self.poll -class MotionPollManager(models.Manager): +class MotionPollManager(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 polls. In the background we join and prefetch all related models. """ return ( - self.get_queryset() + super() + .get_prefetched_queryset(*args, **kwargs) .select_related("motion") .prefetch_related("options", "options__votes", "groups", "voted") ) @@ -1084,21 +1074,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/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..c4a27abe0 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( @@ -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/autoupdate.py b/openslides/utils/autoupdate.py index 6221a72d4..5aeabde67 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 @@ -11,23 +12,28 @@ from .projector import get_projector_data from .utils import get_model_from_collection_string -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 @@ -38,169 +44,75 @@ class Element(ElementBase, total=False): information: List[str] 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, - no_delete_on_restriction: 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, - user_id=user_id, - no_delete_on_restriction=no_delete_on_restriction, + 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, -) -> 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, - 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"]) - full_data = 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 @@ -208,12 +120,12 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: 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() @@ -233,27 +145,113 @@ 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, + 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 isinstance(instances, Iterable): + 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(), + 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/consumers.py b/openslides/utils/consumers.py index 610044dc6..3ad1029af 100644 --- a/openslides/utils/consumers.py +++ b/openslides/utils/consumers.py @@ -4,6 +4,7 @@ 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 @@ -16,6 +17,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 b9aa4cc8e..5c4a30fa9 100644 --- a/openslides/utils/models.py +++ b/openslides/utils/models.py @@ -6,7 +6,7 @@ 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, get_element_id @@ -142,18 +142,20 @@ 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()}") # 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() @@ -223,12 +225,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/tests/count_queries.py b/tests/count_queries.py index 4f207652d..7a4a5938a 100644 --- a/tests/count_queries.py +++ b/tests/count_queries.py @@ -1,32 +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, *args, **kwargs) -> int: - context = CaptureQueriesContext(connections[DEFAULT_DB_ALIAS]) - with context: - func(*args, **kwargs) +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: - 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}") + if verbose: + print(get_verbose_queries(context)) - return len(context) + return len(context) + + return wrapper -def assert_query_count(should_be, verbose=False): - """ - Decorator to easily count queries on any test you want. - should_be defines how many queries are to be expected - """ +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 outer(func): - def inner(*args, **kwargs): - assert count_queries(func, verbose, *args, **kwargs) == should_be + 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) - return inner - return outer +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_viewset.py b/tests/integration/agenda/test_viewset.py index 5f38a18f4..8f56f032e 100644 --- a/tests/integration/agenda/test_viewset.py +++ b/tests/integration/agenda/test_viewset.py @@ -12,7 +12,7 @@ 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 @@ -22,6 +22,56 @@ from tests.test_case import TestCase from ...common_groups import GROUP_DEFAULT_PK +@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): """ Tests content objects with Topic as a content object of items and @@ -233,52 +283,6 @@ 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. diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index 0a48ee504..f7a963acb 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -18,7 +18,7 @@ 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 assert_query_count, count_queries +from tests.count_queries import count_queries from tests.test_case import TestCase @@ -35,7 +35,7 @@ def test_assignment_poll_db_queries(): = 6 queries """ create_assignment_polls() - assert count_queries(AssignmentPoll.get_elements) == 6 + assert count_queries(AssignmentPoll.get_elements)() == 6 @pytest.mark.django_db(transaction=False) @@ -44,7 +44,7 @@ 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 + assert count_queries(AssignmentVote.get_elements)() == 1 def create_assignment_polls(): @@ -94,20 +94,19 @@ class CreateAssignmentPoll(TestCase): ) self.assignment.add_candidate(self.admin) - # TODO lower query count - @assert_query_count(47, True) def test_simple(self): - 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, - }, - ) + with self.assertNumQueries(35): + 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.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(AssignmentPoll.objects.exists()) poll = AssignmentPoll.objects.get() diff --git a/tests/integration/assignments/test_viewset.py b/tests/integration/assignments/test_viewset.py index 9474f28c7..2ebbba7ee 100644 --- a/tests/integration/assignments/test_viewset.py +++ b/tests/integration/assignments/test_viewset.py @@ -5,7 +5,7 @@ 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 @@ -21,18 +21,20 @@ 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. """ 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)() == 6 class CreateAssignment(TestCase): diff --git a/tests/integration/core/test_viewset.py b/tests/integration/core/test_viewset.py index 568422df9..500066713 100644 --- a/tests/integration/core/test_viewset.py +++ b/tests/integration/core/test_viewset.py @@ -28,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) @@ -40,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) @@ -51,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): diff --git a/tests/integration/mediafiles/test_viewset.py b/tests/integration/mediafiles/test_viewset.py index f7c37d4c8..40d674ae4 100644 --- a/tests/integration/mediafiles/test_viewset.py +++ b/tests/integration/mediafiles/test_viewset.py @@ -27,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): diff --git a/tests/integration/motions/test_motions.py b/tests/integration/motions/test_motions.py index b8dce6966..bb531004c 100644 --- a/tests/integration/motions/test_motions.py +++ b/tests/integration/motions/test_motions.py @@ -1,5 +1,4 @@ import json -from decimal import Decimal import pytest from django.contrib.auth import get_user_model @@ -12,11 +11,11 @@ from openslides.core.models import Tag from openslides.motions.models import ( Category, Motion, + MotionBlock, MotionChangeRecommendation, MotionComment, MotionCommentSection, MotionPoll, - MotionVote, Submitter, Workflow, ) @@ -24,7 +23,7 @@ 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 assert_query_count, count_queries +from tests.count_queries import count_queries from tests.test_case import TestCase @@ -39,12 +38,6 @@ def test_motion_db_queries(): * 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 all poll groups, - * 1 request to get all poll voted users, - * 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 the attachments, * 1 request to get the tags, * 2 requests to get the submitters and supporters, @@ -71,6 +64,10 @@ def test_motion_db_queries(): 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 ) @@ -78,46 +75,22 @@ def test_motion_db_queries(): comment="test_comment2", motion=motion, section=section2 ) - get_user_model().objects.create_user( - username=f"user_{index}", password="password" - ) + 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 some fully populated polls: - poll1 = MotionPoll.objects.create( + # Create a poll: + poll = MotionPoll.objects.create( motion=motion, title="test_title_XeejaeFez3chahpei9qu", pollmethod="YNA", type=BasePoll.TYPE_NAMED, ) - poll1.create_options() - option = poll1.options.get() - MotionVote.objects.create( - user=user1, option=option, value="Y", weight=Decimal(1) - ) - poll1.voted.add(user1) - MotionVote.objects.create( - user=user2, option=option, value="N", weight=Decimal(1) - ) - poll1.voted.add(user2) + poll.create_options() - poll2 = MotionPoll.objects.create( - motion=motion, - title="test_title_iecuW7eekeGh4uunow1e", - pollmethod="YNA", - type=BasePoll.TYPE_NAMED, - ) - poll2.create_options() - option = poll2.options.get() - MotionVote.objects.create( - user=user2, option=option, value="Y", weight=Decimal(1) - ) - poll2.voted.add(user2) - MotionVote.objects.create( - user=user3, option=option, value="N", weight=Decimal(1) - ) - poll2.voted.add(user3) - - assert count_queries(Motion.get_elements) == 18 + assert count_queries(Motion.get_elements)() == 12 class CreateMotion(TestCase): @@ -125,13 +98,10 @@ class CreateMotion(TestCase): Tests motion creation. """ - maxDiff = None - def setUp(self): self.client = APIClient() self.client.login(username="admin", password="admin") - @assert_query_count(85, True) def test_simple(self): """ Tests that a motion is created with a specific title and text. @@ -139,13 +109,14 @@ class CreateMotion(TestCase): 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", - }, - ) + with self.assertNumQueries(51, verbose=True): + 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() diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index 25fad2578..284d5d88d 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -28,7 +28,7 @@ def test_motion_poll_db_queries(): = 5 queries """ create_motion_polls() - assert count_queries(MotionPoll.get_elements) == 5 + assert count_queries(MotionPoll.get_elements)() == 5 @pytest.mark.django_db(transaction=False) @@ -37,7 +37,7 @@ 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 + assert count_queries(MotionVote.get_elements)() == 1 def create_motion_polls(): diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py index ac71efa21..b311ba86c 100644 --- a/tests/integration/motions/test_viewset.py +++ b/tests/integration/motions/test_viewset.py @@ -32,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) @@ -46,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): diff --git a/tests/integration/topics/test_viewset.py b/tests/integration/topics/test_viewset.py index c9d2cc8a0..04502da0b 100644 --- a/tests/integration/topics/test_viewset.py +++ b/tests/integration/topics/test_viewset.py @@ -20,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_viewset.py b/tests/integration/users/test_viewset.py index 3a8ce3be6..3a43d0b88 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -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): 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/test_case.py b/tests/test_case.py index fd3ffc0e2..6ec35208a 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -11,6 +11,7 @@ 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): @@ -70,6 +71,14 @@ class TestCase(_TestCase): 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) + """ Create Helper functions """ From 09ef3c50718b8cb86c87f59b7d7948f4f3396a2f Mon Sep 17 00:00:00 2001 From: jsangmeister Date: Tue, 5 Nov 2019 09:30:55 +0100 Subject: [PATCH 07/55] add settings variable ENABLE_ELECTRONIC_VOTING --- openslides/poll/serializers.py | 26 ++++++++++++- openslides/utils/settings.py.tpl | 4 ++ tests/integration/assignments/test_polls.py | 29 ++++++++++++++ tests/integration/motions/test_polls.py | 43 ++++++++++++++++++--- tests/settings.py | 2 + 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/openslides/poll/serializers.py b/openslides/poll/serializers.py index 74af68396..fa30e8453 100644 --- a/openslides/poll/serializers.py +++ b/openslides/poll/serializers.py @@ -1,3 +1,5 @@ +from django.conf import settings + from ..utils.auth import get_group_model from ..utils.rest_api import ( CharField, @@ -5,7 +7,9 @@ from ..utils.rest_api import ( IdPrimaryKeyRelatedField, ModelSerializer, SerializerMethodField, + ValidationError, ) +from .models import BasePoll BASE_VOTE_FIELDS = ("id", "weight", "value", "user", "option", "pollstate") @@ -77,10 +81,10 @@ class BasePollSerializer(ModelSerializer): 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 comobinations should be + 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 micht noght be + 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 @@ -95,6 +99,24 @@ class BasePollSerializer(ModelSerializer): 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 ( + "type" in data + and data["type"] != BasePoll.TYPE_ANALOG + and not ENABLE_ELECTRONIC_VOTING + ): + raise ValidationError( + { + "detail": "Electronic voting is disabled. Only analog polls are allowed" + } + ) + return data + def norm_100_percent_base_to_pollmethod( self, onehundred_percent_base, pollmethod, old_100_percent_base=None ): 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/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index f7a963acb..05473ddf3 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -3,6 +3,7 @@ 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 @@ -242,6 +243,23 @@ class CreateAssignmentPoll(TestCase): self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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"), @@ -415,6 +433,17 @@ class UpdateAssignmentPoll(TestCase): 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.type, BasePoll.TYPE_NAMED) + setattr(settings, "ENABLE_ELECTRONIC_VOTING", False) + def test_patch_groups_to_empty(self): response = self.client.patch( reverse("assignmentpoll-detail", args=[self.poll.pk]), {"groups_id": []}, diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index 284d5d88d..bae17cf8e 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -1,6 +1,7 @@ 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 @@ -151,11 +152,11 @@ class CreateMotionPoll(TestCase): reverse("motionpoll-list"), { "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", - "type": "named", + "pollmethod": MotionPoll.POLLMETHOD_YNA, + "type": MotionPoll.TYPE_NAMED, "motion_id": self.motion.id, - "onehundred_percent_base": "YN", - "majority_method": "simple", + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YN, + "majority_method": MotionPoll.MAJORITY_SIMPLE, "groups_id": [], }, ) @@ -168,14 +169,33 @@ class CreateMotionPoll(TestCase): reverse("motionpoll-list"), { "title": "test_title_yaiyeighoh0Iraet3Ahc", - "pollmethod": "YNA", + "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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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"), @@ -184,6 +204,8 @@ class CreateMotionPoll(TestCase): "pollmethod": "not_existing", "type": "named", "motion_id": self.motion.id, + "onehundred_percent_base": MotionPoll.PERCENT_BASE_YN, + "majority_method": MotionPoll.MAJORITY_SIMPLE, }, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -270,6 +292,17 @@ class UpdateMotionPoll(TestCase): 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.assertEqual(response.status_code, 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]), diff --git a/tests/settings.py b/tests/settings.py index 734f1ca6f..1366534df 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -75,3 +75,5 @@ PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] RESTRICTED_DATA_CACHE = False REST_FRAMEWORK = {"TEST_REQUEST_DEFAULT_FORMAT": "json"} + +ENABLE_ELECTRONIC_VOTING = True From 1b761d31c090a4805d1d61b61bb30aa73ab06c1c Mon Sep 17 00:00:00 2001 From: jsangmeister Date: Fri, 1 Nov 2019 16:15:06 +0100 Subject: [PATCH 08/55] added tests for user creation and try-catch for a probably race-based IntegrityError --- openslides/users/models.py | 2 +- tests/integration/users/test_viewset.py | 69 +++++++++++++++++-------- 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/openslides/users/models.py b/openslides/users/models.py index c4a27abe0..a1c05723a 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -96,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(): diff --git a/tests/integration/users/test_viewset.py b/tests/integration/users/test_viewset.py index 3a43d0b88..1a0ddb6b8 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -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( @@ -138,6 +134,12 @@ class UserCreate(TestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) 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): @@ -586,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]} - ) + 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): From 8d77c0495bd1f094a0c6b09300070db344a36cc5 Mon Sep 17 00:00:00 2001 From: GabrielMeyer Date: Tue, 29 Oct 2019 14:00:52 +0100 Subject: [PATCH 09/55] Initial polling --- .../agenda-content-object-form.component.html | 19 ++- .../attachment-control.component.html | 19 ++- .../attachment-control.component.ts | 88 +++++----- .../extension-field.component.html | 16 +- .../media-upload-content.component.html | 38 +++-- .../media-upload-content.component.ts | 5 +- .../search-value-selector.component.html | 29 ++-- .../search-value-selector.component.spec.ts | 8 +- .../search-value-selector.component.ts | 142 ++++++++------- .../shared/models/base/base-form-control.ts | 161 ++++++++++++++++++ .../app/shared/models/motions/motion-poll.ts | 16 +- .../src/app/shared/models/poll/base-poll.ts | 61 +++++++ .../list-of-speakers.component.html | 40 +++-- .../assignment-detail.component.html | 52 +++--- .../history-list/history-list.component.html | 19 ++- .../mediafile-list.component.html | 51 +++--- .../site/motions/models/view-motion-poll.ts | 9 +- ...motion-comment-section-list.component.html | 30 ++-- .../manage-submitters.component.html | 15 +- .../motion-detail.component.html | 69 ++++---- .../motion-detail.component.spec.ts | 4 +- .../motion-detail/motion-detail.component.ts | 4 +- .../motion-poll-preview.component.html | 56 ++++++ .../motion-poll-preview.component.scss | 7 + .../motion-poll-preview.component.spec.ts | 27 +++ .../motion-poll-preview.component.ts | 56 ++++++ .../motion-detail/motion-detail.module.ts | 2 + .../motion-poll-detail.component.html | 92 ++++++++++ .../motion-poll-detail.component.scss | 3 + .../motion-poll-detail.component.spec.ts | 27 +++ .../motion-poll-detail.component.ts | 152 +++++++++++++++++ .../motion-poll-list.component.html | 36 ++++ .../motion-poll-list.component.scss | 0 .../motion-poll-list.component.spec.ts | 27 +++ .../motion-poll-list.component.ts | 45 +++++ .../motion-poll/motion-poll-routing.module.ts | 17 ++ .../modules/motion-poll/motion-poll.module.ts | 13 ++ .../site/motions/motions-routing.module.ts | 5 + .../topic-detail/topic-detail.component.html | 17 +- .../user-detail/user-detail.component.html | 46 ++--- .../styles/global-components-style.scss | 9 + 41 files changed, 1164 insertions(+), 368 deletions(-) create mode 100644 client/src/app/shared/models/base/base-form-control.ts create mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.html create mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.scss create mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.spec.ts create mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.spec.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.html create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.scss create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.spec.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-routing.module.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts 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..2ea306771 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,68 @@ -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; + public get empty(): boolean { + return !this.contentForm.value.length; + } + public get controlType(): string { + return 'attachment-control'; + } + /** * Default constructor * * @param dialogService Reference to the `MatDialog` * @param mediaService Reference for the `MediaFileRepositoryService` */ - public constructor(private dialogService: MatDialog, private mediaService: MediafileRepositoryService) {} + public constructor( + fb: FormBuilder, + fm: FocusMonitor, + element: ElementRef, + @Optional() @Self() public ngControl: NgControl, + private dialogService: MatDialog, + private mediaService: MediafileRepositoryService + ) { + super(fb, fm, element, ngControl); + } /** * On init method @@ -64,11 +88,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(); } /** @@ -80,29 +102,13 @@ export class AttachmentControlComponent implements OnInit, ControlValueAccessor this.errorHandler.emit(error); } - /** - * Function to write a new value to the form. - * Satisfy the interface. - * - * @param value The new value for this form. - */ - public writeValue(value: any): void { - if (value && this.controlName) { - this.controlName.setValue(value); - } + public onContainerClick(event: MouseEvent): void { + // TODO: implement + } + protected initializeForm(): void { + this.contentForm = this.fb.control([]); + } + protected updateForm(value: ViewMediafile[]): void { + this.contentForm.setValue(value); } - - /** - * 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 {} } 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..3e98933f8 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,14 @@ (keydown)="keyDownFunction($event)" /> - + + + 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/search-value-selector/search-value-selector.component.html b/client/src/app/shared/components/search-value-selector/search-value-selector.component.html index e3f019d3c..e3bb6f2cb 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,12 @@ - - - -
- - {{ noneTitle | translate }} - - -
- - {{ selectedItem.getTitle() | translate }} + + + + + {{ noneTitle | translate }} - -
+ + + + {{ selectedItem.getTitle() | translate }} + + 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..7b5a046a9 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,38 @@ -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, + ChangeDetectorRef, + Component, + ElementRef, + Input, + Optional, + Self +} 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 { 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,24 +42,10 @@ 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 }], 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 { /** * Decide if this should be a single or multi-select-field */ @@ -83,55 +76,41 @@ 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.subscriptions.push( + 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 }); + 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 controlType = 'search-value-selector'; /** - * Name of the Form + * All items */ - @Input() - public formControl: FormControl; - - /** - * The MultiSelect Component - */ - @ViewChild('thisSelector', { static: true }) - public thisSelector: MatSelect; + private selectableItems: Selectable[]; /** * Empty constructor */ - public constructor(protected translate: TranslateService) {} - - /** - * Unsubscribe on destroing. - */ - public ngOnDestroy(): void { - if (this._inputListSubscription) { - this._inputListSubscription.unsubscribe(); - } + public constructor( + protected translate: TranslateService, + cd: ChangeDetectorRef, + fb: FormBuilder, + @Optional() @Self() public ngControl: NgControl, + fm: FocusMonitor, + element: ElementRef + ) { + super(fb, fm, element, ngControl); } /** @@ -141,29 +120,42 @@ 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 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 { + const nextValue = value; + this.contentForm.setValue(nextValue); } } 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..18624e980 --- /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; + 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/motions/motion-poll.ts b/client/src/app/shared/models/motions/motion-poll.ts index 01dd6287e..f9991450c 100644 --- a/client/src/app/shared/models/motions/motion-poll.ts +++ b/client/src/app/shared/models/motions/motion-poll.ts @@ -1,14 +1,18 @@ import { BasePoll, BasePollWithoutNestedModels } from '../poll/base-poll'; import { MotionOption } from './motion-option'; -export enum MotionPollmethods { - 'YN' = 'YN', - 'YNA' = 'YNA' +export enum MotionPollMethods { + YN = 'YN', + YNA = 'YNA' } +export const MotionPollMethodsVerbose = { + YN: 'Yes/No', + YNA: 'Yes/No/Abstain' +}; export interface MotionPollWithoutNestedModels extends BasePollWithoutNestedModels { motion_id: number; - pollmethod: MotionPollmethods; + pollmethod: MotionPollMethods; } /** @@ -22,5 +26,9 @@ export class MotionPoll extends BasePoll { public constructor(input?: any) { super(MotionPoll.COLLECTIONSTRING, input); } + + public get pollmethodVerbose(): string { + return MotionPollMethodsVerbose[this.pollmethod]; + } } export interface MotionPoll extends MotionPollWithoutNestedModels {} diff --git a/client/src/app/shared/models/poll/base-poll.ts b/client/src/app/shared/models/poll/base-poll.ts index 1fc1fe6fe..f5cb70763 100644 --- a/client/src/app/shared/models/poll/base-poll.ts +++ b/client/src/app/shared/models/poll/base-poll.ts @@ -8,12 +8,55 @@ export enum PollState { Published } +export const PollStateVerbose = { + 1: 'Created', + 2: 'Started', + 3: 'Finished', + 4: 'Published' +}; + export enum PollType { Analog = 'analog', Named = 'named', Pseudoanonymous = 'pseudoanonymous' } +export const PollTypeVerbose = { + analog: 'Analog', + named: 'Named', + pseudoanonymous: 'Pseudoanonymous' +}; + +export enum PercentBase { + YN = 'YN', + YNA = 'YNA', + Valid = 'valid', + Cast = 'cast', + Disabled = 'disabled' +} + +export const PercentBaseVerbose = { + YN: 'Yes/No', + YNA: 'Yes/No/Abstain', + valid: 'Valid votes', + cast: 'Casted votes', + disabled: 'Disabled' +}; + +export enum MajorityMethod { + Simple = 'simple', + TwoThirds = 'two_thirds', + ThreeQuarters = 'three_quarters', + Disabled = 'disabled' +} + +export const MajorityMethodVerbose = { + simple: 'Simple', + two_thirds: 'Two Thirds', + three_quarters: 'Three Quarters', + disabled: 'Disabled' +}; + export interface BasePollWithoutNestedModels { state: PollState; type: PollType; @@ -23,6 +66,8 @@ export interface BasePollWithoutNestedModels { votescast: number; groups_id: number[]; voted_id: number[]; + majority_method: MajorityMethod; + onehundred_percent_base: PercentBase; } export abstract class BasePoll> extends BaseDecimalModel { @@ -31,5 +76,21 @@ export abstract class BasePoll> extends BaseDecimal protected getDecimalFields(): (keyof BasePoll)[] { return ['votesvalid', 'votesinvalid', 'votescast']; } + + public get stateVerbose(): string { + return PollStateVerbose[this.state]; + } + + public get typeVerbose(): string { + return PollTypeVerbose[this.type]; + } + + public get majorityMethodVerbose(): string { + return MajorityMethodVerbose[this.majority_method]; + } + + public get percentBaseVerbose(): string { + return PercentBaseVerbose[this.onehundred_percent_base]; + } } export interface BasePoll> extends BasePollWithoutNestedModels {} diff --git a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html index 81ac75268..aac27ad5c 100644 --- a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html +++ b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html @@ -1,4 +1,10 @@ - +

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

@@ -77,12 +91,7 @@
- + @@ -124,13 +133,14 @@
- + + +
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 8ab276b7a..563566f66 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 @@ -145,10 +145,7 @@ *ngIf="assignment && assignment.polls && assignment.polls.length" > - + @@ -194,14 +191,15 @@ *ngIf="hasPerms('addOthers') && filteredCandidates && filteredCandidates.value.length > 0" [formGroup]="candidatesForm" > - + + +
@@ -238,11 +236,7 @@
- + {{ 'The title is required' | translate }}
@@ -256,22 +250,20 @@ > -
- -
+
+ + + - -
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..14670936f 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 @@ -459,14 +459,15 @@

- -
+ + +
@@ -601,15 +602,16 @@
-
- -
+ + + + +
@@ -783,13 +785,14 @@
- + + +
@@ -805,8 +808,8 @@
@@ -818,25 +821,27 @@
- + + +
- + + +
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..f68d81bf8 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 @@ -7,6 +7,7 @@ import { MotionCommentsComponent } from '../motion-comments/motion-comments.comp 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 { MotionPollPreviewComponent } from '../motion-poll/motion-poll-preview/motion-poll-preview.component'; import { MotionPollComponent } from '../motion-poll/motion-poll.component'; import { PersonalNoteComponent } from '../personal-note/personal-note.component'; @@ -24,7 +25,8 @@ describe('MotionDetailComponent', () => { ManageSubmittersComponent, MotionPollComponent, MotionDetailOriginalChangeRecommendationsComponent, - MotionDetailDiffComponent + MotionDetailDiffComponent, + MotionPollPreviewComponent ] }).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 7c7962aef..9cb6c1f46 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 @@ -1382,9 +1382,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, * Handler for creating a poll */ public createPoll(): void { - // TODO - // this.repo.createPoll({}).catch(this.raiseError); - throw new Error('TODO'); + this.router.navigate(['motions', 'polls', 'new'], { queryParams: { parent: this.motion.id || null } }); } /** diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.html b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.html new file mode 100644 index 000000000..78f801c34 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.html @@ -0,0 +1,56 @@ + + {{ poll.title }} + +
+
{{ 'Current state' | translate }}: {{ poll.stateVerbose }}
+
{{ 'Groups' | translate }}: {{ poll.groups }}
+
{{ 'Method' | translate }}: {{ poll.pollmethodVerbose }}
+
{{ 'Type' | translate }}: {{ poll.typeVerbose }}
+
+ + +
+
+ + + +

Enter votes

+
+
+

+ + + + Required + + +

+ + +

Statute paragraph

+ +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.scss b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.scss new file mode 100644 index 000000000..7eb491efc --- /dev/null +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.scss @@ -0,0 +1,7 @@ +.poll-content { + padding-bottom: 8px; +} + +.poll-footer { + text-align: end; +} diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.spec.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.spec.ts new file mode 100644 index 000000000..818e19927 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionPollPreviewComponent } from './motion-poll-preview.component'; + +describe('MotionPollPreviewComponent', () => { + let component: MotionPollPreviewComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionPollPreviewComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionPollPreviewComponent); + 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-preview/motion-poll-preview.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.ts new file mode 100644 index 000000000..01f9bc977 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.ts @@ -0,0 +1,56 @@ +import { Component, Input } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; +import { Router } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; + +import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { PollType } from 'app/shared/models/poll/base-poll'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; + +@Component({ + selector: 'os-motion-poll-preview', + templateUrl: './motion-poll-preview.component.html', + styleUrls: ['./motion-poll-preview.component.scss'] +}) +export class MotionPollPreviewComponent extends BaseViewComponent { + @Input() + public poll: ViewMotionPoll; + + public pollTypes = PollType; + + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + private repo: MotionPollRepositoryService, + private promptDialog: PromptService, + private router: Router + ) { + super(title, translate, matSnackbar); + } + + public openPoll(): void { + this.router.navigate(['motions', 'polls', this.poll.id]); + } + + public editPoll(): void { + this.router.navigate(['motions', 'polls', this.poll.id], { queryParams: { edit: true } }); + } + + public async deletePoll(): Promise { + const title = 'Delete poll'; + const text = 'Do you really want to delete the selected poll?'; + + if (await this.promptDialog.open(title, text)) { + await this.repo.delete(this.poll); + } + } + + public enterAnalogVotes(): void { + throw new Error('TODO'); + } +} 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..05cb39f66 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 @@ -11,6 +11,7 @@ import { MotionDetailOriginalChangeRecommendationsComponent } from './components 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 { MotionPollPreviewComponent } from './components/motion-poll/motion-poll-preview/motion-poll-preview.component'; import { MotionPollComponent } from './components/motion-poll/motion-poll.component'; import { MotionTitleChangeRecommendationDialogComponent } from './components/motion-title-change-recommendation-dialog/motion-title-change-recommendation-dialog.component'; import { PersonalNoteComponent } from './components/personal-note/personal-note.component'; @@ -24,6 +25,7 @@ import { PersonalNoteComponent } from './components/personal-note/personal-note. PersonalNoteComponent, ManageSubmittersComponent, MotionPollComponent, + MotionPollPreviewComponent, MotionPollDialogComponent, MotionDetailDiffComponent, MotionDetailOriginalChangeRecommendationsComponent, diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html new file mode 100644 index 000000000..c54d59d88 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html @@ -0,0 +1,92 @@ + +
+

New vote

+

{{ poll.title }}

+
+
+ + + + + + + + +

{{ poll.title }}

+ +
+
{{ 'Current state' | translate }}: {{ poll.stateVerbose | translate }}
+
+ {{ 'Groups' | translate }}: + {{ group.getTitle() | translate }} +
+
{{ 'Poll type' | translate }}: {{ poll.typeVerbose | translate }}
+
{{ 'Poll method' | translate }}: {{ poll.pollmethodVerbose | translate }}
+
{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}
+
{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}
+
+
+
+ + +
+ + + A title is required + + + + {{ + option.value | translate + }} + + This field is required + + + + + + + {{ + option.value + }} + + This field is required + + + + {{ + option.value | translate + }} + + + + + {{ + option.value | 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..621e890fd --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss @@ -0,0 +1,3 @@ +.poll-content { + padding-top: 10px; +} 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..e44c359c3 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts @@ -0,0 +1,152 @@ +import { Location } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + +import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; +import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; +import { MotionPoll, MotionPollMethodsVerbose } from 'app/shared/models/motions/motion-poll'; +import { + MajorityMethodVerbose, + PercentBaseVerbose, + PollStateVerbose, + PollTypeVerbose +} from 'app/shared/models/poll/base-poll'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { ViewGroup } from 'app/site/users/models/view-group'; + +@Component({ + selector: 'os-motion-poll-detail', + templateUrl: './motion-poll-detail.component.html', + styleUrls: ['./motion-poll-detail.component.scss'] +}) +export class MotionPollDetailComponent extends BaseViewComponent implements OnInit { + private pollId: number; + + public pollStates = PollStateVerbose; + public pollMethods = MotionPollMethodsVerbose; + public pollTypes = PollTypeVerbose; + public percentBases = PercentBaseVerbose; + public majorityMethods = MajorityMethodVerbose; + + public userGroups: ViewGroup[] = []; + + public groupObservable: Observable = null; + + public isNewPoll = false; + + public poll: ViewMotionPoll = null; + + public motionId: number; + + public isEditingPoll = false; + + public contentForm: FormGroup; + + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + private repo: MotionPollRepositoryService, + private route: ActivatedRoute, + private router: Router, + private fb: FormBuilder, + private groupRepo: GroupRepositoryService, + private location: Location + ) { + super(title, translate, matSnackbar); + } + + public ngOnInit(): void { + this.findComponentById(); + this.createPoll(); + + this.groupObservable = this.groupRepo.getViewModelListObservable(); + this.subscriptions.push( + this.groupRepo.getViewModelListObservable().subscribe(groups => (this.userGroups = groups)) + ); + } + + public savePoll(): void { + const pollValues = this.contentForm.value; + const poll: MotionPoll = this.isNewPoll ? new MotionPoll() : this.poll.poll; + Object.keys(pollValues).forEach(key => (poll[key] = pollValues[key])); + if (this.isNewPoll) { + poll.motion_id = this.motionId; + this.repo.create(poll).then(success => { + if (success && success.id) { + this.pollId = success.id; + this.router.navigate(['motions', 'polls', this.pollId]); + } + }, this.raiseError); + } else { + this.repo.update(pollValues, this.poll).then(() => (this.isEditingPoll = false), this.raiseError); + } + } + + public editPoll(): void { + this.isEditingPoll = true; + } + + public backToView(): void { + if (this.pollId) { + this.isEditingPoll = false; + } else { + // TODO + this.location.back(); + } + } + + private findComponentById(): void { + const params = this.route.snapshot.params; + const queryParams = this.route.snapshot.queryParams; + if (params && params.id) { + this.pollId = +params.id; + this.subscriptions.push( + this.repo.getViewModelObservable(this.pollId).subscribe(poll => { + if (poll) { + this.poll = poll; + this.updateForm(); + } + }) + ); + } else { + this.isNewPoll = true; + this.isEditingPoll = true; + if (queryParams && queryParams.parent) { + this.motionId = +queryParams.parent; + } + } + if (queryParams && queryParams.edit) { + this.isEditingPoll = true; + } + } + + private createPoll(): void { + this.contentForm = this.fb.group({ + title: ['', Validators.required], + type: ['', Validators.required], + pollmethod: ['', Validators.required], + onehundred_percent_base: ['', Validators.required], + majority_method: ['', Validators.required], + groups_id: [[]] + }); + if (this.poll) { + this.updateForm(); + } + } + + private updateForm(): void { + if (this.contentForm) { + Object.keys(this.contentForm.controls).forEach(key => { + this.contentForm.get(key).setValue(this.poll[key]); + }); + } + } +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.html new file mode 100644 index 000000000..d4f67dbb1 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.html @@ -0,0 +1,36 @@ + +
Motions poll list
+ + + +
+ + +
+ + {{ poll.title }} +
+
+ {{ poll.stateVerbose }} +
+
+ + + + + diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.scss b/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.spec.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.spec.ts new file mode 100644 index 000000000..819250015 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { MotionPollListComponent } from './motion-poll-list.component'; + +describe('MotionPollListComponent', () => { + let component: MotionPollListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionPollListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionPollListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.ts new file mode 100644 index 000000000..0f6cbefd8 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.ts @@ -0,0 +1,45 @@ +import { Component, OnInit } 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 { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; +import { BaseListViewComponent } from 'app/site/base/base-list-view'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; + +@Component({ + selector: 'os-motion-poll-list', + templateUrl: './motion-poll-list.component.html', + styleUrls: ['./motion-poll-list.component.scss'] +}) +export class MotionPollListComponent extends BaseListViewComponent implements OnInit { + public tableColumnDefinition: PblColumnDefinition[] = [ + { + prop: 'title', + width: 'auto' + }, + { + prop: 'state', + width: 'auto' + } + ]; + + public polls: ViewMotionPoll[] = []; + + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + storage: StorageService, + public repo: MotionPollRepositoryService + ) { + super(title, translate, matSnackbar, storage); + } + + public ngOnInit(): void { + this.subscriptions.push(this.repo.getViewModelListObservable().subscribe(polls => (this.polls = polls))); + } +} 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..120eac726 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { MotionPollDetailComponent } from './motion-poll-detail/motion-poll-detail.component'; +import { MotionPollListComponent } from './motion-poll-list/motion-poll-list.component'; + +const routes: Routes = [ + { path: '', component: MotionPollListComponent, pathMatch: 'full' }, + { 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.module.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts new file mode 100644 index 000000000..48c88631f --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts @@ -0,0 +1,13 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SharedModule } from 'app/shared/shared.module'; +import { MotionPollDetailComponent } from './motion-poll-detail/motion-poll-detail.component'; +import { MotionPollListComponent } from './motion-poll-list/motion-poll-list.component'; +import { MotionPollRoutingModule } from './motion-poll-routing.module'; + +@NgModule({ + declarations: [MotionPollDetailComponent, MotionPollListComponent], + imports: [CommonModule, SharedModule, MotionPollRoutingModule] +}) +export class MotionPollModule {} diff --git a/client/src/app/site/motions/motions-routing.module.ts b/client/src/app/site/motions/motions-routing.module.ts index 749328d72..a58a3f0f5 100644 --- a/client/src/app/site/motions/motions-routing.module.ts +++ b/client/src/app/site/motions/motions-routing.module.ts @@ -62,6 +62,11 @@ const routes: Routes = [ loadChildren: () => 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_manage_polls' } + }, { path: ':id', loadChildren: () => import('./modules/motion-detail/motion-detail.module').then(m => m.MotionDetailModule), diff --git a/client/src/app/site/topics/components/topic-detail/topic-detail.component.html b/client/src/app/site/topics/components/topic-detail/topic-detail.component.html index 6722d1b2c..c1d9660ee 100644 --- a/client/src/app/site/topics/components/topic-detail/topic-detail.component.html +++ b/client/src/app/site/topics/components/topic-detail/topic-detail.component.html @@ -70,7 +70,7 @@ @@ -88,13 +88,14 @@
- + + +
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/assets/styles/global-components-style.scss b/client/src/assets/styles/global-components-style.scss index df1853054..cdeb70a7a 100644 --- a/client/src/assets/styles/global-components-style.scss +++ b/client/src/assets/styles/global-components-style.scss @@ -136,6 +136,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); From 90b04366b528272a3676a02db6166132c8de9266 Mon Sep 17 00:00:00 2001 From: jsangmeister Date: Tue, 12 Nov 2019 09:20:10 +0100 Subject: [PATCH 10/55] added option to number poll cadidates --- .../shared/models/assignments/assignment.ts | 1 + .../assignment-detail.component.html | 7 +++++++ .../assignment-detail.component.ts | 3 ++- .../assignments/migrations/0008_voting_1.py | 5 +++++ openslides/assignments/models.py | 5 +++++ openslides/assignments/serializers.py | 1 + tests/integration/assignments/test_viewset.py | 18 ++++++++++++++---- 7 files changed, 35 insertions(+), 5 deletions(-) diff --git a/client/src/app/shared/models/assignments/assignment.ts b/client/src/app/shared/models/assignments/assignment.ts index 620126972..f58e01a38 100644 --- a/client/src/app/shared/models/assignments/assignment.ts +++ b/client/src/app/shared/models/assignments/assignment.ts @@ -10,6 +10,7 @@ export interface AssignmentWithoutNestedModels extends BaseModelWithAgendaItemAn default_poll_description: string; tags_id: number[]; attachments_id: number[]; + number_poll_candidates: boolean; } /** 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 563566f66..cd177e365 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 @@ -301,6 +301,13 @@ }}
+ + +
+ + Number poll candidates + +
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 bf795e250..a76a7eec1 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 @@ -193,7 +193,8 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn 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 diff --git a/openslides/assignments/migrations/0008_voting_1.py b/openslides/assignments/migrations/0008_voting_1.py index e220a4748..1e80fb5be 100644 --- a/openslides/assignments/migrations/0008_voting_1.py +++ b/openslides/assignments/migrations/0008_voting_1.py @@ -129,6 +129,11 @@ class Migration(migrations.Migration): ), 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", diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index a268c69e0..b040052df 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -153,6 +153,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 = ( diff --git a/openslides/assignments/serializers.py b/openslides/assignments/serializers.py index 13ed729d8..ec9755cf5 100644 --- a/openslides/assignments/serializers.py +++ b/openslides/assignments/serializers.py @@ -169,6 +169,7 @@ class AssignmentSerializer(ModelSerializer): "agenda_parent_id", "tags", "attachments", + "number_poll_candidates", ) validators = (posts_validator,) diff --git a/tests/integration/assignments/test_viewset.py b/tests/integration/assignments/test_viewset.py index 2ebbba7ee..db874641e 100644 --- a/tests/integration/assignments/test_viewset.py +++ b/tests/integration/assignments/test_viewset.py @@ -42,10 +42,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"), @@ -54,6 +50,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") @@ -75,6 +72,19 @@ 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 CandidatureSelf(TestCase): """ From b50cf425434bffdb83123fcba92176b5280d2a89 Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Wed, 13 Nov 2019 07:46:13 +0100 Subject: [PATCH 11/55] Prevent stopping an analog poll Fixed too much logging with the new autoupdate bundling --- openslides/poll/views.py | 7 +++++++ openslides/utils/models.py | 16 ++++++++++------ tests/integration/assignments/test_polls.py | 6 ++++++ tests/integration/motions/test_polls.py | 8 +++++++- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/openslides/poll/views.py b/openslides/poll/views.py index b8ab9f942..59064b398 100644 --- a/openslides/poll/views.py +++ b/openslides/poll/views.py @@ -58,6 +58,13 @@ class BasePollViewSet(ModelViewSet): @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"}) diff --git a/openslides/utils/models.py b/openslides/utils/models.py index 5c4a30fa9..8a531ac17 100644 --- a/openslides/utils/models.py +++ b/openslides/utils/models.py @@ -146,7 +146,10 @@ class RESTModelMixin: """ 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_prefetched_queryset(ids=ids) # type: ignore @@ -167,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 diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index 05473ddf3..0702409c1 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -582,6 +582,12 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(self.poll.state, AssignmentPoll.STATE_STARTED) + def test_vote(self): self.add_candidate() self.start_poll() diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index bae17cf8e..4bbe9ef40 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -407,6 +407,12 @@ class VoteMotionPollAnalog(TestCase): 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(self.poll.state, MotionPoll.STATE_STARTED) + def test_vote(self): self.start_poll() response = self.client.post( @@ -995,7 +1001,7 @@ class StopMotionPoll(TestCase): motion=self.motion, title="test_title_Hu9Miebopaighee3EDie", pollmethod="YNA", - type=BasePoll.TYPE_ANALOG, + type=BasePoll.TYPE_NAMED, ) self.poll.create_options() From fafb81daca9ea2d8492c024449dccfba800c9674 Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Tue, 5 Nov 2019 11:26:43 +0100 Subject: [PATCH 12/55] Fix assignment access permissions Also improves unnecessary history-savings of users in the list of speakers --- openslides/agenda/models.py | 10 ++++-- openslides/agenda/views.py | 3 +- openslides/assignments/access_permissions.py | 35 -------------------- openslides/utils/autoupdate.py | 6 ++-- openslides/utils/utils.py | 14 ++++++++ 5 files changed, 26 insertions(+), 42 deletions(-) diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index 8c6c709a1..e25f00498 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -426,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 = ( @@ -443,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/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 9a76c5bd5..33b684a9a 100644 --- a/openslides/assignments/access_permissions.py +++ b/openslides/assignments/access_permissions.py @@ -1,11 +1,8 @@ -from typing import Any, Dict, List - from ..poll.access_permissions import ( BasePollAccessPermissions, BaseVoteAccessPermissions, ) from ..utils.access_permissions import BaseAccessPermissions -from ..utils.auth import async_has_perm class AssignmentAccessPermissions(BaseAccessPermissions): @@ -15,38 +12,6 @@ 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" diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index 5aeabde67..7701b1598 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -9,7 +9,7 @@ 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 AutoupdateElementBase(TypedDict): @@ -150,6 +150,7 @@ 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: """ @@ -162,7 +163,7 @@ def inform_changed_data( """ if information is None: information = [] - if not isinstance(instances, Iterable): + if not is_iterable(instances): instances = (instances,) root_instances = set(instance.get_root_rest_element() for instance in instances) @@ -170,6 +171,7 @@ def inform_changed_data( 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, diff --git a/openslides/utils/utils.py b/openslides/utils/utils.py index f739f1304..f068730be 100644 --- a/openslides/utils/utils.py +++ b/openslides/utils/utils.py @@ -72,6 +72,20 @@ def is_int(obj: Any) -> bool: 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]] = {} From 72ff1b1f09d5757078110e0f8b52b3ad42b7275f Mon Sep 17 00:00:00 2001 From: jsangmeister Date: Wed, 27 Nov 2019 15:44:17 +0100 Subject: [PATCH 13/55] api changes to allow some edits on finished polls --- openslides/assignments/views.py | 16 ++++- openslides/motions/views.py | 12 +++- openslides/poll/views.py | 60 +++++++++++------ tests/integration/assignments/test_polls.py | 67 +++++++++++++++++++ tests/integration/motions/test_polls.py | 71 +++++++++++++++++++++ 5 files changed, 202 insertions(+), 24 deletions(-) diff --git a/openslides/assignments/views.py b/openslides/assignments/views.py index 5d60a5f59..15cf3e411 100644 --- a/openslides/assignments/views.py +++ b/openslides/assignments/views.py @@ -389,18 +389,28 @@ class AssignmentPollViewSet(BasePollViewSet): for option_id, vote in options_data.items(): option = options.get(pk=int(option_id)) Y = self.parse_decimal_value(vote["Y"], min_value=-2) - AssignmentVote.objects.create(option=option, value="Y", weight=Y) + vote_obj, _ = AssignmentVote.objects.get_or_create(option=option, value="Y") + vote_obj.weight = Y + vote_obj.save() if poll.pollmethod in ( AssignmentPoll.POLLMETHOD_YN, AssignmentPoll.POLLMETHOD_YNA, ): N = self.parse_decimal_value(vote["N"], min_value=-2) - AssignmentVote.objects.create(option=option, value="N", weight=N) + vote_obj, _ = AssignmentVote.objects.get_or_create( + option=option, value="N" + ) + vote_obj.weight = N + vote_obj.save() if poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA: A = self.parse_decimal_value(vote["A"], min_value=-2) - AssignmentVote.objects.create(option=option, value="A", weight=A) + vote_obj, _ = AssignmentVote.objects.get_or_create( + option=option, value="A" + ) + vote_obj.weight = A + vote_obj.save() # Create votes for global no and global abstain first_option = options.first() diff --git a/openslides/motions/views.py b/openslides/motions/views.py index bc98d956b..db473cf78 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -1180,10 +1180,16 @@ class MotionPollViewSet(BasePollViewSet): A = self.parse_decimal_value(data.get("A"), min_value=-2) option = poll.options.get() - MotionVote.objects.create(option=option, value="Y", weight=Y) - MotionVote.objects.create(option=option, value="N", weight=N) + vote, _ = MotionVote.objects.get_or_create(option=option, value="Y") + vote.weight = Y + vote.save() + vote, _ = MotionVote.objects.get_or_create(option=option, value="N") + vote.weight = N + vote.save() if poll.pollmethod == MotionPoll.POLLMETHOD_YNA: - MotionVote.objects.create(option=option, value="A", weight=A) + vote, _ = MotionVote.objects.get_or_create(option=option, value="A") + vote.weight = A + vote.save() if "votesvalid" in data: poll.votesvalid = self.parse_decimal_value(data["votesvalid"], min_value=-2) diff --git a/openslides/poll/views.py b/openslides/poll/views.py index 59064b398..be8d3c8d5 100644 --- a/openslides/poll/views.py +++ b/openslides/poll/views.py @@ -17,6 +17,8 @@ from .models import BasePoll class BasePollViewSet(ModelViewSet): + valid_update_keys = ["majority_method", "onehundred_percent_base"] + def check_view_permissions(self): """ the vote view is checked seperately. For all other views manage permissions @@ -31,18 +33,28 @@ class BasePollViewSet(ModelViewSet): poll = serializer.save() poll.create_options() - def update(self, *args, **kwargs): + def update(self, request, *args, **kwargs): """ Customized view endpoint to update a motion poll. """ poll = self.get_object() - if poll.state != BasePoll.STATE_CREATED: - raise ValidationError( - {"detail": "You can just edit a poll if it was not started."} - ) + partial = kwargs.get("partial", False) + serializer = self.get_serializer(poll, data=request.data, partial=partial) + serializer.is_valid(raise_exception=False) - return super().update(*args, **kwargs) + 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": f"The poll is not in the created state. You can only edit: {', '.join(self.valid_update_keys)}" + } + ) + + return super().update(request, *args, **kwargs) @detail_route(methods=["POST"]) def start(self, request, pk): @@ -118,8 +130,6 @@ class BasePollViewSet(ModelViewSet): For motion polls: Just "Y", "N" or "A" (if pollmethod is "YNA") """ poll = self.get_object() - if poll.state != BasePoll.STATE_STARTED: - raise ValidationError({"detail": "You cannot vote for an unstarted poll"}) if isinstance(request.user, AnonymousUser): self.permission_denied(request) @@ -129,23 +139,37 @@ class BasePollViewSet(ModelViewSet): if not self.has_manage_permissions(): self.permission_denied(request) + if ( + poll.state != BasePoll.STATE_STARTED + and poll.state != BasePoll.STATE_FINISHED + ): + raise ValidationError( + {"detail": "You cannot vote for a poll in this state"} + ) + self.handle_analog_vote(request.data, poll, request.user) # special: change the poll state to finished. poll.state = BasePoll.STATE_FINISHED poll.save() - elif poll.type == BasePoll.TYPE_NAMED: - self.assert_can_vote(poll, request) - self.handle_named_vote(request.data, poll, request.user) - poll.voted.add(request.user) + else: + if poll.state != BasePoll.STATE_STARTED: + raise ValidationError( + {"detail": "You cannot vote for an unstarted poll"} + ) - elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: - self.assert_can_vote(poll, request) + if poll.type == BasePoll.TYPE_NAMED: + self.assert_can_vote(poll, request) + self.handle_named_vote(request.data, poll, request.user) + poll.voted.add(request.user) - if request.user in poll.voted.all(): - self.permission_denied(request) - self.handle_pseudoanonymous_vote(request.data, poll) - poll.voted.add(request.user) + elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: + self.assert_can_vote(poll, request) + + if request.user in poll.voted.all(): + self.permission_denied(request) + self.handle_pseudoanonymous_vote(request.data, poll) + poll.voted.add(request.user) inform_changed_data(poll) # needed for the changed voted relation return Response() diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index 0702409c1..cc8fc0fe9 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -535,6 +535,40 @@ class UpdateAssignmentPoll(TestCase): 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "YN") + class VoteAssignmentPollBaseTestClass(TestCase): def advancedSetUp(self): @@ -721,6 +755,39 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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): diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index 4bbe9ef40..6e9c60340 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -369,6 +369,40 @@ class UpdateMotionPoll(TestCase): poll = MotionPoll.objects.get() self.assertEqual(poll.title, "test_title_beeFaihuNae1vej2ai8m") + 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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.assertEqual(response.status_code, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "YN") + class VoteMotionPollAnalog(TestCase): def setUp(self): @@ -470,6 +504,43 @@ class VoteMotionPollAnalog(TestCase): self.assertEqual(response.status_code, 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.assertEqual(response.status_code, 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): From 96aa3b008446a440c7c44e7f0179d5b98463d0e6 Mon Sep 17 00:00:00 2001 From: GabrielMeyer Date: Tue, 12 Nov 2019 18:30:26 +0100 Subject: [PATCH 14/55] Adds the chart and dialog for analog voting --- client/package.json | 76 +-- client/src/app/app.component.scss | 1 + client/src/app/app.component.ts | 39 +- .../app/core/core-services/offline.service.ts | 24 +- .../openslides-status.service.ts | 8 +- .../core-services/relation-manager.service.ts | 14 +- .../has-view-model-list-observable.ts | 5 + ...signment-option-repository.service.spec.ts | 14 + .../assignment-option-repository.service.ts | 75 +++ .../assignment-poll-repository.service.ts | 74 ++- .../assignment-vote-repository.service.ts | 11 + .../app/core/repositories/base-repository.ts | 11 +- .../motion-option-repository.service.spec.ts | 14 + .../motion-option-repository.service.ts | 68 ++ .../motions/motion-poll-repository.service.ts | 47 +- .../motions/motion-vote-repository.service.ts | 7 + .../users/group-repository.service.ts | 7 + .../core/ui-services/banner.service.spec.ts | 12 + .../app/core/ui-services/banner.service.ts | 68 ++ .../ui-services/base-filter-list.service.ts | 2 + .../ui-services/base-poll-dialog.service.ts | 67 ++ .../app/core/ui-services/poll.service.spec.ts | 18 - .../src/app/core/ui-services/poll.service.ts | 216 ------- .../ui-services/voting-banner.service.spec.ts | 18 + .../core/ui-services/voting-banner.service.ts | 65 ++ .../core/ui-services/voting.service.spec.ts | 18 + .../app/core/ui-services/voting.service.ts | 70 ++ .../attachment-control.component.ts | 4 +- .../components/banner/banner.component.html | 18 + .../components/banner/banner.component.scss | 43 ++ .../banner/banner.component.spec.ts | 26 + .../components/banner/banner.component.ts | 40 ++ .../breadcrumb/breadcrumb.component.html | 17 + .../breadcrumb/breadcrumb.component.scss | 25 + .../breadcrumb/breadcrumb.component.spec.ts | 26 + .../breadcrumb/breadcrumb.component.ts | 79 +++ .../components/charts/charts.component.html | 23 + .../components/charts/charts.component.scss | 4 + .../charts/charts.component.spec.ts | 24 + .../components/charts/charts.component.ts | 189 ++++++ .../check-input/check-input.component.html | 21 + .../check-input/check-input.component.scss | 10 + .../check-input/check-input.component.spec.ts | 26 + .../check-input/check-input.component.ts | 147 +++++ .../extension-field.component.html | 1 - .../list-view-table.component.ts | 28 +- .../search-value-selector.component.html | 20 +- .../search-value-selector.component.scss | 21 + .../search-value-selector.component.ts | 39 +- .../models/assignments/assignment-poll.ts | 27 +- .../shared/models/base/base-form-control.ts | 2 +- .../app/shared/models/motions/motion-poll.ts | 18 +- .../src/app/shared/models/poll/base-option.ts | 1 + .../src/app/shared/models/poll/base-poll.ts | 82 +-- .../src/app/shared/models/poll/base-vote.ts | 22 +- client/src/app/shared/shared.module.ts | 38 +- .../agenda-list/agenda-list.component.html | 2 +- .../assignments/assignments-routing.module.ts | 4 +- .../site/assignments/assignments.config.ts | 8 + .../site/assignments/assignments.module.ts | 11 +- .../assignment-detail.component.html | 189 +++--- .../assignment-detail.component.spec.ts | 3 +- .../assignment-detail.component.ts | 13 +- .../assignment-list.component.html | 2 +- .../assignment-list.component.ts | 2 +- .../assignment-poll-detail.component.html | 78 +++ .../assignment-poll-detail.component.scss | 0 .../assignment-poll-detail.component.spec.ts | 27 + .../assignment-poll-detail.component.ts | 59 ++ .../assignment-poll-dialog.component.html | 81 ++- .../assignment-poll-dialog.component.scss | 30 +- .../assignment-poll-dialog.component.ts | 150 +++-- .../assignment-poll-vote.component.html | 47 ++ .../assignment-poll-vote.component.scss | 12 + .../assignment-poll-vote.component.spec.ts | 27 + .../assignment-poll-vote.component.ts | 86 +++ .../assignment-poll.component.html | 76 ++- .../assignment-poll.component.scss | 46 ++ .../assignment-poll.component.spec.ts | 3 +- .../assignment-poll.component.ts | 146 +---- .../models/view-assignment-option.ts | 6 +- .../models/view-assignment-poll.ts | 45 +- .../models/view-assignment-vote.ts | 4 +- ...e.ts => assignment-filter-list.service.ts} | 0 .../assignment-poll-dialog.service.spec.ts | 18 + .../assignment-poll-dialog.service.ts | 22 + .../services/assignment-poll.service.spec.ts | 18 + .../services/assignment-poll.service.ts | 56 ++ .../site/motions/models/view-motion-option.ts | 2 + .../site/motions/models/view-motion-poll.ts | 49 +- .../site/motions/models/view-motion-vote.ts | 2 + .../amendment-list.component.html | 2 +- .../category-list.component.html | 2 +- .../motion-block-detail.component.html | 2 +- .../motion-block-list.component.html | 2 +- .../motion-detail.component.html | 14 +- .../motion-detail.component.scss | 16 +- .../motion-detail.component.spec.ts | 6 +- .../motion-detail/motion-detail.component.ts | 24 +- .../motion-poll-dialog.component.html | 20 - .../motion-poll-dialog.component.scss | 14 - .../motion-poll-dialog.component.ts | 75 --- .../motion-poll-preview.component.html | 56 -- .../motion-poll-preview.component.scss | 7 - .../motion-poll-preview.component.ts | 56 -- .../motion-poll/motion-poll.component.html | 73 --- .../motion-poll/motion-poll.component.scss | 3 - .../motion-poll/motion-poll.component.spec.ts | 27 - .../motion-poll/motion-poll.component.ts | 237 ------- .../motion-detail/motion-detail.module.ts | 10 +- .../motion-list/motion-list.component.html | 2 +- .../motion-poll-detail.component.html | 111 ++-- .../motion-poll-detail.component.scss | 10 + .../motion-poll-detail.component.ts | 145 +---- .../motion-poll-dialog.component.html | 68 ++ .../motion-poll-dialog.component.scss | 0 .../motion-poll-dialog.component.spec.ts | 34 + .../motion-poll-dialog.component.ts | 73 +++ .../motion-poll-list.component.html | 2 +- .../motion-poll-vote.component.html | 28 + .../motion-poll-vote.component.scss | 0 .../motion-poll-vote.component.spec.ts} | 12 +- .../motion-poll-vote.component.ts | 73 +++ .../modules/motion-poll/motion-poll.module.ts | 7 +- .../motion-poll/motion-poll.component.html | 73 +++ .../motion-poll/motion-poll.component.scss | 90 +++ .../motion-poll/motion-poll.component.spec.ts | 25 + .../motion-poll/motion-poll.component.ts | 90 +++ .../workflow-list.component.html | 2 +- client/src/app/site/motions/motions.config.ts | 4 + .../motion-poll-dialog.service.spec.ts | 18 + .../services/motion-poll-dialog.service.ts | 22 + .../services/motion-poll.service.spec.ts | 7 +- .../motions/services/motion-poll.service.ts | 172 +---- .../components/base-poll-detail.component.ts | 240 +++++++ .../components/base-poll-dialog.component.ts | 103 +++ .../components/base-poll-vote.component.ts | 39 ++ .../polls/components/base-poll.component.ts | 54 ++ .../poll-form/poll-form.component.html | 71 +++ .../poll-form/poll-form.component.scss | 32 + .../poll-form/poll-form.component.spec.ts | 26 + .../poll-form/poll-form.component.ts | 155 +++++ .../poll-list/poll-list.component.html | 45 ++ .../poll-list/poll-list.component.scss | 0 .../poll-list/poll-list.component.spec.ts | 27 + .../poll-list/poll-list.component.ts | 52 ++ .../app/site/polls/models/view-base-poll.ts | 111 ++++ .../app/site/polls/polls-routing.module.ts | 18 + client/src/app/site/polls/polls.module.ts | 16 + .../services/base-poll-repository.service.ts | 87 +++ .../services/poll-filter-list.service.spec.ts | 18 + .../services/poll-filter-list.service.ts | 57 ++ .../poll-list-observable.service.spec.ts | 18 + .../services/poll-list-observable.service.ts | 44 ++ .../site/polls/services/poll.service.spec.ts | 17 + .../app/site/polls/services/poll.service.ts | 313 +++++++++ client/src/app/site/site-routing.module.ts | 5 + client/src/app/site/site.component.html | 7 +- client/src/app/site/site.component.scss | 34 +- .../app/site/site.component.scss-theme.scss | 25 - client/src/app/site/site.component.ts | 11 - .../tag-list/tag-list.component.html | 2 +- .../user-list/user-list.component.html | 2 +- .../assignments/poll/poll-slide-data.ts | 2 +- .../assignments/poll/poll-slide.component.ts | 2 +- .../styles/global-components-style.scss | 8 + openslides/assignments/access_permissions.py | 6 + openslides/assignments/apps.py | 12 +- openslides/assignments/models.py | 14 +- openslides/assignments/serializers.py | 1 - openslides/assignments/views.py | 396 ++++++------ openslides/core/apps.py | 1 + openslides/motions/access_permissions.py | 6 + openslides/motions/apps.py | 5 + .../motions/migrations/0034_voting_2.py | 5 +- openslides/motions/models.py | 5 +- openslides/motions/serializers.py | 2 - openslides/motions/views.py | 78 ++- openslides/poll/access_permissions.py | 30 +- openslides/poll/models.py | 11 +- openslides/poll/serializers.py | 8 +- openslides/poll/views.py | 189 ++++-- tests/integration/assignments/test_polls.py | 598 +++++++++++++----- tests/integration/motions/test_polls.py | 367 +++++++---- tests/test_case.py | 7 + 185 files changed, 6060 insertions(+), 2430 deletions(-) create mode 100644 client/src/app/core/definitions/has-view-model-list-observable.ts create mode 100644 client/src/app/core/repositories/assignments/assignment-option-repository.service.spec.ts create mode 100644 client/src/app/core/repositories/assignments/assignment-option-repository.service.ts create mode 100644 client/src/app/core/repositories/motions/motion-option-repository.service.spec.ts create mode 100644 client/src/app/core/repositories/motions/motion-option-repository.service.ts create mode 100644 client/src/app/core/ui-services/banner.service.spec.ts create mode 100644 client/src/app/core/ui-services/banner.service.ts create mode 100644 client/src/app/core/ui-services/base-poll-dialog.service.ts delete mode 100644 client/src/app/core/ui-services/poll.service.spec.ts delete mode 100644 client/src/app/core/ui-services/poll.service.ts create mode 100644 client/src/app/core/ui-services/voting-banner.service.spec.ts create mode 100644 client/src/app/core/ui-services/voting-banner.service.ts create mode 100644 client/src/app/core/ui-services/voting.service.spec.ts create mode 100644 client/src/app/core/ui-services/voting.service.ts create mode 100644 client/src/app/shared/components/banner/banner.component.html create mode 100644 client/src/app/shared/components/banner/banner.component.scss create mode 100644 client/src/app/shared/components/banner/banner.component.spec.ts create mode 100644 client/src/app/shared/components/banner/banner.component.ts create mode 100644 client/src/app/shared/components/breadcrumb/breadcrumb.component.html create mode 100644 client/src/app/shared/components/breadcrumb/breadcrumb.component.scss create mode 100644 client/src/app/shared/components/breadcrumb/breadcrumb.component.spec.ts create mode 100644 client/src/app/shared/components/breadcrumb/breadcrumb.component.ts create mode 100644 client/src/app/shared/components/charts/charts.component.html create mode 100644 client/src/app/shared/components/charts/charts.component.scss create mode 100644 client/src/app/shared/components/charts/charts.component.spec.ts create mode 100644 client/src/app/shared/components/charts/charts.component.ts create mode 100644 client/src/app/shared/components/check-input/check-input.component.html create mode 100644 client/src/app/shared/components/check-input/check-input.component.scss create mode 100644 client/src/app/shared/components/check-input/check-input.component.spec.ts create mode 100644 client/src/app/shared/components/check-input/check-input.component.ts create mode 100644 client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html create mode 100644 client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.scss create mode 100644 client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts create mode 100644 client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts create mode 100644 client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.html create mode 100644 client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.scss create mode 100644 client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts create mode 100644 client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.ts rename client/src/app/site/assignments/services/{assignment-filter.service.ts => assignment-filter-list.service.ts} (100%) create mode 100644 client/src/app/site/assignments/services/assignment-poll-dialog.service.spec.ts create mode 100644 client/src/app/site/assignments/services/assignment-poll-dialog.service.ts create mode 100644 client/src/app/site/assignments/services/assignment-poll.service.spec.ts create mode 100644 client/src/app/site/assignments/services/assignment-poll.service.ts delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.html delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.scss delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.html delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.scss delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.ts delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.html delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.scss delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.spec.ts delete mode 100644 client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.html create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.scss create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.spec.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.html create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.scss rename client/src/app/site/motions/modules/{motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.spec.ts => motion-poll/motion-poll-vote/motion-poll-vote.component.spec.ts} (57%) create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.spec.ts create mode 100644 client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts create mode 100644 client/src/app/site/motions/services/motion-poll-dialog.service.spec.ts create mode 100644 client/src/app/site/motions/services/motion-poll-dialog.service.ts create mode 100644 client/src/app/site/polls/components/base-poll-detail.component.ts create mode 100644 client/src/app/site/polls/components/base-poll-dialog.component.ts create mode 100644 client/src/app/site/polls/components/base-poll-vote.component.ts create mode 100644 client/src/app/site/polls/components/base-poll.component.ts create mode 100644 client/src/app/site/polls/components/poll-form/poll-form.component.html create mode 100644 client/src/app/site/polls/components/poll-form/poll-form.component.scss create mode 100644 client/src/app/site/polls/components/poll-form/poll-form.component.spec.ts create mode 100644 client/src/app/site/polls/components/poll-form/poll-form.component.ts create mode 100644 client/src/app/site/polls/components/poll-list/poll-list.component.html create mode 100644 client/src/app/site/polls/components/poll-list/poll-list.component.scss create mode 100644 client/src/app/site/polls/components/poll-list/poll-list.component.spec.ts create mode 100644 client/src/app/site/polls/components/poll-list/poll-list.component.ts create mode 100644 client/src/app/site/polls/models/view-base-poll.ts create mode 100644 client/src/app/site/polls/polls-routing.module.ts create mode 100644 client/src/app/site/polls/polls.module.ts create mode 100644 client/src/app/site/polls/services/base-poll-repository.service.ts create mode 100644 client/src/app/site/polls/services/poll-filter-list.service.spec.ts create mode 100644 client/src/app/site/polls/services/poll-filter-list.service.ts create mode 100644 client/src/app/site/polls/services/poll-list-observable.service.spec.ts create mode 100644 client/src/app/site/polls/services/poll-list-observable.service.ts create mode 100644 client/src/app/site/polls/services/poll.service.spec.ts create mode 100644 client/src/app/site/polls/services/poll.service.ts 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..e24171a4f 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,8 @@ declare global { */ interface Array { flatMap(o: any): any[]; + intersect(a: T[]): T[]; + mapToObject(f: (item: T) => { [key: string]: any }): { [key: string]: any }; } /** @@ -79,7 +82,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']); @@ -92,7 +96,7 @@ export class AppComponent { // change default JS functions this.overloadArrayToString(); - this.overloadFlatMap(); + this.overloadArrayFunctions(); this.overloadModulo(); // Wait until the App reaches a stable state. @@ -138,8 +142,7 @@ export class AppComponent { } /** - * Adds an implementation of flatMap. - * TODO: Remove once flatMap made its way into official JS/TS (ES 2019?) + * Adds an implementation of flatMap and intersect. */ private overloadFlatMap(): void { Object.defineProperty(Array.prototype, 'flatMap', { @@ -150,6 +153,34 @@ export class AppComponent { }, enumerable: false }); + + Object.defineProperty(Array.prototype, 'intersect', { + value: function(other: T[]): T[] { + let a = this, + 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 + }); } /** diff --git a/client/src/app/core/core-services/offline.service.ts b/client/src/app/core/core-services/offline.service.ts index b8d6a238f..bad80218f 100644 --- a/client/src/app/core/core-services/offline.service.ts +++ b/client/src/app/core/core-services/offline.service.ts @@ -1,7 +1,10 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Observable } from 'rxjs'; +import { BannerDefinition, BannerService } from '../ui-services/banner.service'; + /** * This service handles everything connected with being offline. * @@ -16,6 +19,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 +46,7 @@ export class OfflineService { if (!this.offline.getValue()) { console.log('offline because whoami failed.'); } - this.offline.next(true); + this.goOffline(); } /** @@ -43,7 +56,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 +72,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..53e86b96b 100644 --- a/client/src/app/core/core-services/relation-manager.service.ts +++ b/client/src/app/core/core-services/relation-manager.service.ts @@ -187,12 +187,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/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.ts b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts index 78257cb08..81bfe5389 100644 --- a/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts @@ -3,17 +3,18 @@ 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 { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; +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 { ViewAssignmentVote } from 'app/site/assignments/models/view-assignment-vote'; +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 { BaseRepository, NestedModelDescriptors } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { DataStoreService } from '../../core-services/data-store.service'; @@ -29,36 +30,35 @@ const AssignmentPollRelations: RelationDefinition[] = [ ownIdKey: 'voted_id', ownKey: 'voted', foreignViewModel: ViewUser + }, + { + type: 'O2M', + ownIdKey: 'options_id', + ownKey: 'options', + foreignViewModel: ViewAssignmentOption + }, + { + type: 'M2O', + ownIdKey: 'assignment_id', + ownKey: 'assignment', + foreignViewModel: ViewAssignment } ]; -const AssignmentPollNestedModelDescriptors: NestedModelDescriptors = { - 'assignments/assignment-poll': [ - { - ownKey: 'options', - foreignViewModel: ViewAssignmentOption, - foreignModel: AssignmentOption, - order: 'weight', - relationDefinitionsByKey: { - user: { - type: 'M2O', - ownIdKey: 'user_id', - ownKey: 'user', - foreignViewModel: ViewUser - }, - votes: { - type: 'O2M', - foreignIdKey: 'option_id', - ownKey: 'votes', - foreignViewModel: ViewAssignmentVote - } - }, - titles: { - getTitle: (viewOption: ViewAssignmentOption) => (viewOption.user ? viewOption.user.getTitle() : '') - } - } - ] -}; +export interface AssignmentAnalogVoteData { + options: { + [key: number]: { + Y: number; + N?: number; + A?: number; + }; + }; + votesvalid?: number; + votesinvalid?: number; + votescast?: number; + global_no?: number; + global_abstain?: number; +} /** * Repository Service for Assignments. @@ -68,7 +68,7 @@ const AssignmentPollNestedModelDescriptors: NestedModelDescriptors = { @Injectable({ providedIn: 'root' }) -export class AssignmentPollRepositoryService extends BaseRepository< +export class AssignmentPollRepositoryService extends BasePollRepositoryService< ViewAssignmentPoll, AssignmentPoll, AssignmentPollTitleInformation @@ -89,7 +89,9 @@ export class AssignmentPollRepositoryService extends BaseRepository< mapperService: CollectionStringMapperService, viewModelStoreService: ViewModelStoreService, translate: TranslateService, - relationManager: RelationManagerService + relationManager: RelationManagerService, + votingService: VotingService, + http: HttpService ) { super( DS, @@ -100,7 +102,9 @@ export class AssignmentPollRepositoryService extends BaseRepository< relationManager, AssignmentPoll, AssignmentPollRelations, - AssignmentPollNestedModelDescriptors + {}, + votingService, + http ); } @@ -111,4 +115,8 @@ export class AssignmentPollRepositoryService extends BaseRepository< public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Polls' : 'Poll'); }; + + public vote(data: any, poll_id: number): Promise { + return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, data); + } } 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 index 3e16559f3..a77c14f4c 100644 --- a/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-vote-repository.service.ts @@ -7,6 +7,7 @@ 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 { 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'; @@ -19,6 +20,12 @@ const AssignmentVoteRelations: RelationDefinition[] = [ ownIdKey: 'user_id', ownKey: 'user', foreignViewModel: ViewUser + }, + { + type: 'M2O', + ownIdKey: 'option_id', + ownKey: 'option', + foreignViewModel: ViewAssignmentOption } ]; @@ -66,4 +73,8 @@ export class AssignmentVoteRepositoryService extends BaseRepository { 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.ts b/client/src/app/core/repositories/motions/motion-poll-repository.service.ts index 4ebbcd424..a35f2c1c3 100644 --- a/client/src/app/core/repositories/motions/motion-poll-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-poll-repository.service.ts @@ -3,17 +3,17 @@ 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 { MotionOption } from 'app/shared/models/motions/motion-option'; +import { VotingService } from 'app/core/ui-services/voting.service'; import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; -import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote'; +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 { BaseRepository, NestedModelDescriptors } from '../base-repository'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { DataStoreService } from '../../core-services/data-store.service'; @@ -29,30 +29,15 @@ const MotionPollRelations: RelationDefinition[] = [ ownIdKey: 'voted_id', ownKey: 'voted', foreignViewModel: ViewUser + }, + { + type: 'O2M', + ownIdKey: 'options_id', + ownKey: 'options', + foreignViewModel: ViewMotionOption } ]; -const MotionPollNestedModelDescriptors: NestedModelDescriptors = { - 'motions/motion-poll': [ - { - ownKey: 'options', - foreignViewModel: ViewMotionOption, - foreignModel: MotionOption, - relationDefinitionsByKey: { - votes: { - type: 'O2M', - foreignIdKey: 'option_id', - ownKey: 'votes', - foreignViewModel: ViewMotionVote - } - }, - titles: { - getTitle: (viewOption: ViewMotionOption) => '' - } - } - ] -}; - /** * Repository Service for Assignments. * @@ -61,7 +46,7 @@ const MotionPollNestedModelDescriptors: NestedModelDescriptors = { @Injectable({ providedIn: 'root' }) -export class MotionPollRepositoryService extends BaseRepository< +export class MotionPollRepositoryService extends BasePollRepositoryService< ViewMotionPoll, MotionPoll, MotionPollTitleInformation @@ -72,7 +57,9 @@ export class MotionPollRepositoryService extends BaseRepository< mapperService: CollectionStringMapperService, viewModelStoreService: ViewModelStoreService, translate: TranslateService, - relationManager: RelationManagerService + relationManager: RelationManagerService, + votingService: VotingService, + http: HttpService ) { super( DS, @@ -83,7 +70,9 @@ export class MotionPollRepositoryService extends BaseRepository< relationManager, MotionPoll, MotionPollRelations, - MotionPollNestedModelDescriptors + {}, + votingService, + http ); } @@ -94,4 +83,8 @@ export class MotionPollRepositoryService extends BaseRepository< public getVerboseName = (plural: boolean = false) => { return this.translate.instant(plural ? 'Polls' : 'Poll'); }; + + public vote(vote: 'Y' | 'N' | 'A', 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-vote-repository.service.ts b/client/src/app/core/repositories/motions/motion-vote-repository.service.ts index 801659fa0..f60037806 100644 --- a/client/src/app/core/repositories/motions/motion-vote-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-vote-repository.service.ts @@ -7,6 +7,7 @@ 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 { 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'; @@ -19,6 +20,12 @@ const MotionVoteRelations: RelationDefinition[] = [ ownIdKey: 'user_id', ownKey: 'user', foreignViewModel: ViewUser + }, + { + type: 'M2O', + ownIdKey: 'option_id', + ownKey: 'option', + foreignViewModel: ViewMotionOption } ]; 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..a0c40a638 100644 --- a/client/src/app/core/repositories/users/group-repository.service.ts +++ b/client/src/app/core/repositories/users/group-repository.service.ts @@ -72,6 +72,13 @@ export class GroupRepositoryService extends BaseRepository ids.includes(group.id)) + .map(group => this.translate.instant(group.getTitle())) + .join(', '); + } + /** * Toggles the given permisson. * 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..daf5e372c --- /dev/null +++ b/client/src/app/core/ui-services/banner.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; + +export interface BannerDefinition { + type?: string; + class?: string; + icon?: string; + text?: string; + bgColor?: string; + color?: string; + link?: string; +} + +/** + * 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 readonly BANNER_HEIGHT = 20; + + 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..cff02ba23 --- /dev/null +++ b/client/src/app/core/ui-services/base-poll-dialog.service.ts @@ -0,0 +1,67 @@ +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'; +import { PollService } from '../../site/polls/services/poll.service'; + +/** + * 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, + private service: PollService + ) {} + + /** + * 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(poll: Partial & Collection): Promise { + if (!poll.poll) { + this.service.fillDefaultPollData(poll); + } + const dialogRef = this.dialog.open(this.dialogComponent, { + data: poll, + ...mediumDialogSettings + }); + const result = await dialogRef.afterClosed().toPromise(); + if (result) { + const repo = this.mapper.getRepository(poll.collectionString); + if (!poll.poll) { + await repo.create(result); + } else { + let update = result; + if (poll.state !== PollState.Created) { + update = { + title: result.title, + onehundred_percent_base: result.onehundred_percent_base, + majority_method: result.majority_method, + description: result.description + }; + if (poll.type === PollType.Analog) { + update = { + ...update, + votes: result.votes, + publish_immediately: result.publish_immediately + }; + } + } + await repo.patch(update, poll); + } + } + } +} 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..4bf3c60c5 --- /dev/null +++ b/client/src/app/core/ui-services/voting-banner.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +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; + + 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 + if (this.OSStatus.isInHistoryMode && this.currentBanner) { + this.banner.removeBanner(this.currentBanner); + this.currentBanner = null; + return; + } + + const pollsToVote = polls.filter(poll => this.votingService.canVote(poll) && !poll.user_has_voted); + if (pollsToVote.length === 1) { + const poll = pollsToVote[0]; + const banner = { + text: this.translate.instant('Click here to vote on the poll') + ` "${poll.title}"!`, + link: poll.parentLink, + bgColor: 'green' + }; + this.banner.replaceBanner(this.currentBanner, banner); + this.currentBanner = banner; + } else if (pollsToVote.length > 1) { + const banner = { + text: + this.translate.instant('You have') + + ` ${pollsToVote.length} ` + + this.translate.instant('polls to vote on!'), + link: '/polls/', + bgColor: 'green' + }; + this.banner.replaceBanner(this.currentBanner, banner); + this.currentBanner = banner; + } else { + this.banner.removeBanner(this.currentBanner); + this.currentBanner = 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..a413a10c8 --- /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, + USER_HAS_VOTED +} + +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 { + return !this.getVotePermissionError(poll); + } + + /** + * 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; + } + if (poll.type === PollType.Pseudoanonymous && poll.user_has_voted) { + return VotingError.USER_HAS_VOTED; + } + } + + public getVotePermissionErrorVerbose(poll: ViewBasePoll): string | void { + const error = this.getVotePermissionError(poll); + if (error) { + return VotingErrorVerbose[error]; + } + } +} 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 2ea306771..8eac84d1a 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 @@ -108,7 +108,7 @@ export class AttachmentControlComponent extends BaseFormControlComponent
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..1a07eab76 --- /dev/null +++ b/client/src/app/shared/components/banner/banner.component.scss @@ -0,0 +1,43 @@ +.banner { + position: relative; // was fixed before to prevent the overflow + height: 20px; + line-height: 20px; + width: 100%; + text-align: center; + background-color: blue; + display: flex; + align-items: center; + justify-content: center; + + a { + text-decoration: none; + color: white; + } + + 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.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/breadcrumb/breadcrumb.component.html b/client/src/app/shared/components/breadcrumb/breadcrumb.component.html new file mode 100644 index 000000000..d9c21bde4 --- /dev/null +++ b/client/src/app/shared/components/breadcrumb/breadcrumb.component.html @@ -0,0 +1,17 @@ + diff --git a/client/src/app/shared/components/breadcrumb/breadcrumb.component.scss b/client/src/app/shared/components/breadcrumb/breadcrumb.component.scss new file mode 100644 index 000000000..a7e5f50a3 --- /dev/null +++ b/client/src/app/shared/components/breadcrumb/breadcrumb.component.scss @@ -0,0 +1,25 @@ +$breadcrumb-content: var(--breadcrumb-content); + +.breadcrumb-list { + display: flex; + flex-wrap: wrap; + list-style: none; +} + +.breadcrumb { + & + & { + padding-left: 8px; + &::before { + padding-right: 8px; + content: $breadcrumb-content; + } + } + + span.has-action { + cursor: pointer; + } + + &.active { + color: inherit; + } +} diff --git a/client/src/app/shared/components/breadcrumb/breadcrumb.component.spec.ts b/client/src/app/shared/components/breadcrumb/breadcrumb.component.spec.ts new file mode 100644 index 000000000..7739ffcba --- /dev/null +++ b/client/src/app/shared/components/breadcrumb/breadcrumb.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { BreadcrumbComponent } from './breadcrumb.component'; + +describe('BreadcrumbComponent', () => { + let component: BreadcrumbComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BreadcrumbComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/breadcrumb/breadcrumb.component.ts b/client/src/app/shared/components/breadcrumb/breadcrumb.component.ts new file mode 100644 index 000000000..47c2ad5b0 --- /dev/null +++ b/client/src/app/shared/components/breadcrumb/breadcrumb.component.ts @@ -0,0 +1,79 @@ +import { Component, Input, OnInit } from '@angular/core'; + +/** + * Describes, how one breadcrumb can look like. + */ +export interface Breadcrumb { + label: string; + action: () => any; + active?: boolean; +} + +@Component({ + selector: 'os-breadcrumb', + templateUrl: './breadcrumb.component.html', + styleUrls: ['./breadcrumb.component.scss'] +}) +export class BreadcrumbComponent implements OnInit { + /** + * A list of all breadcrumbs, that should be rendered. + * + * @param labels A list of strings or the interface `Breadcrumb`. + */ + @Input() + public set breadcrumbs(labels: string[] | Breadcrumb[]) { + this.breadcrumbList = []; + for (const breadcrumb of labels) { + if (typeof breadcrumb === 'string') { + this.breadcrumbList.push({ label: breadcrumb, action: null }); + } else { + this.breadcrumbList.push(breadcrumb); + } + } + } + + /** + * The current active index, if not the last one. + * + * @param index The index as number. + */ + @Input() + public set activeIndex(index: number) { + for (const breadcrumb of this.breadcrumbList) { + breadcrumb.active = false; + } + this.breadcrumbList[index].active = true; + } + + /** + * Sets the separator for the breadcrumbs. + * + * @param style The new separator as string (character). + */ + @Input() + public set breadcrumbStyle(style: string) { + document.documentElement.style.setProperty('--breadcrumb-content', `'${style}'`); + } + + /** + * The list of the breadcrumbs built by the input. + */ + public breadcrumbList: Breadcrumb[] = []; + + /** + * Default constructor. + */ + public constructor() { + this.breadcrumbStyle = '/'; + } + + /** + * OnInit. + * Sets the last breadcrumb as the active breadcrumb if not defined before. + */ + public ngOnInit(): void { + if (this.breadcrumbList.length && !this.breadcrumbList.some(breadcrumb => breadcrumb.active)) { + this.breadcrumbList[this.breadcrumbList.length - 1].active = true; + } + } +} 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..22f4dc940 --- /dev/null +++ b/client/src/app/shared/components/charts/charts.component.html @@ -0,0 +1,23 @@ +
+ + + +
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..b8cc16c05 --- /dev/null +++ b/client/src/app/shared/components/charts/charts.component.scss @@ -0,0 +1,4 @@ +.charts-wrapper { + position: relative; + display: block; +} 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..5aefc5421 --- /dev/null +++ b/client/src/app/shared/components/charts/charts.component.ts @@ -0,0 +1,189 @@ +import { 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'; + +/** + * Describes the events the chart is fired, when hovering or clicking on it. + */ +interface ChartEvent { + event: MouseEvent; + active: {}[]; +} + +/** + * One single collection in an arry. + */ +export interface ChartDate { + data: number[]; + label: string; + backgroundColor?: string; + hoverBackgroundColor?: string; +} + +/** + * An alias for an array of `ChartDate`. + */ +export type ChartData = ChartDate[]; + +/** + * 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'] +}) +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 => { + this.chartData = data; + this.circleData = data.flatMap((date: ChartDate) => date.data); + this.circleLabels = data.map(date => date.label); + this.circleColors = [ + { + backgroundColor: data.map(date => date.backgroundColor), + hoverBackgroundColor: data.map(date => date.hoverBackgroundColor) + } + ]; + }) + ); + } + + /** + * The type of the chart. Defaults to `'bar'`. + */ + @Input() + public set type(type: ChartType) { + if (type === 'horizontalBar') { + this.setupHorizontalBar(); + } + this._type = type; + } + + public get type(): ChartType { + return this._type; + } + + /** + * 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; + } + + /** + * 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(); + + /** + * 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: { + fontSize: 14 + } + }, + scales: { xAxes: [{}], yAxes: [{ ticks: { beginAtZero: true } }] }, + plugins: { + datalabels: { + anchor: 'end', + align: 'end' + } + } + }; + + /** + * Holds the type of the chart - defaults to `bar`. + */ + private _type: ChartType = 'bar'; + + /** + * Constructor. + * + * @param title + * @param translate + * @param matSnackbar + * @param cd + */ + public constructor(title: Title, protected translate: TranslateService, matSnackbar: MatSnackBar) { + super(title, translate, matSnackbar); + } + + /** + * Changes the chart-options, if the `horizontalBar` is used. + */ + private setupHorizontalBar(): void { + this.chartOptions.scales = Object.assign(this.chartOptions.scales, { + xAxes: [{ stacked: true }], + yAxes: [{ stacked: true }] + }); + } +} 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..beebc3a8e --- /dev/null +++ b/client/src/app/shared/components/check-input/check-input.component.html @@ -0,0 +1,21 @@ +
+ + + + + {{ 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..0b4a29356 --- /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) { + 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 3e98933f8..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 @@ -41,7 +41,6 @@ diff --git a/client/src/app/shared/components/list-view-table/list-view-table.component.ts b/client/src/app/shared/components/list-view-table/list-view-table.component.ts index c3524e0b4..7c75ec931 100644 --- a/client/src/app/shared/components/list-view-table/list-view-table.component.ts +++ b/client/src/app/shared/components/list-view-table/list-view-table.component.ts @@ -20,7 +20,7 @@ import { distinctUntilChanged, filter } from 'rxjs/operators'; import { OperatorService, Permission } from 'app/core/core-services/operator.service'; import { StorageService } from 'app/core/core-services/storage.service'; -import { BaseRepository } from 'app/core/repositories/base-repository'; +import { HasViewModelListObservable } from 'app/core/definitions/has-view-model-list-observable'; import { BaseFilterListService } from 'app/core/ui-services/base-filter-list.service'; import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service'; import { ViewportService } from 'app/core/ui-services/viewport.service'; @@ -63,7 +63,7 @@ export interface ColumnRestriction { * @example * ```html * ; + public listObservableProvider: HasViewModelListObservable; /** * The currently active sorting service for the list view @@ -109,7 +109,7 @@ export class ListViewTableComponent; @@ -322,15 +322,6 @@ export class ListViewTableComponent { - if (this.repo) { - return this.repo.getViewModelListObservable(); - } - } - /** * Define which columns to hide. Uses the input-property * "hide" to hide individual columns @@ -477,23 +468,24 @@ export class ListViewTableComponent + + +
+
+ + {{ item.getTitle() }} cancel + +
+
+
+
- + {{ noneTitle | 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.ts b/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts index 7b5a046a9..c746607bd 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,12 +1,13 @@ import { FocusMonitor } from '@angular/cdk/a11y'; import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, ElementRef, Input, Optional, - Self + Self, + ViewChild, + ViewEncapsulation } from '@angular/core'; import { FormBuilder, FormControl, NgControl } from '@angular/forms'; import { MatFormFieldControl } from '@angular/material'; @@ -43,9 +44,13 @@ import { Selectable } from '../selectable'; 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 extends BaseFormControlComponent { + @ViewChild('chipPlaceholder', { static: false }) + public chipPlaceholder: ElementRef; + /** * Decide if this should be a single or multi-select-field */ @@ -59,13 +64,10 @@ export class SearchValueSelectorComponent extends BaseFormControlComponent 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%'; + } + /** * All items */ @@ -104,7 +116,6 @@ export class SearchValueSelectorComponent extends BaseFormControlComponentthis.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(); @@ -155,7 +175,6 @@ export class SearchValueSelectorComponent extends BaseFormControlComponent { public static COLLECTIONSTRING = 'assignments/assignment-poll'; public id: number; + public assignment_id: number; + public pollmethod: AssignmentPollMethods; + public votes_amount: number; + public allow_multiple_votes_per_candidate: boolean; + public global_no: boolean; + public global_abstain: boolean; + public description: string; public constructor(input?: any) { super(AssignmentPoll.COLLECTIONSTRING, input); } } -export interface AssignmentPoll extends AssignmentPollWithoutNestedModels {} diff --git a/client/src/app/shared/models/base/base-form-control.ts b/client/src/app/shared/models/base/base-form-control.ts index 18624e980..865dda574 100644 --- a/client/src/app/shared/models/base/base-form-control.ts +++ b/client/src/app/shared/models/base/base-form-control.ts @@ -109,7 +109,7 @@ export abstract class BaseFormControlComponent extends MatFormFieldControl this.subscriptions.push( fm.monitor(element.nativeElement, true).subscribe(origin => { - this.focused = !!origin; + this.focused = origin === 'mouse' || origin === 'touch'; this.stateChanges.next(); }), this.contentForm.valueChanges.subscribe(nextValue => this.push(nextValue)) diff --git a/client/src/app/shared/models/motions/motion-poll.ts b/client/src/app/shared/models/motions/motion-poll.ts index f9991450c..44ab74e67 100644 --- a/client/src/app/shared/models/motions/motion-poll.ts +++ b/client/src/app/shared/models/motions/motion-poll.ts @@ -1,19 +1,10 @@ -import { BasePoll, BasePollWithoutNestedModels } from '../poll/base-poll'; +import { BasePoll } from '../poll/base-poll'; import { MotionOption } from './motion-option'; export enum MotionPollMethods { YN = 'YN', YNA = 'YNA' } -export const MotionPollMethodsVerbose = { - YN: 'Yes/No', - YNA: 'Yes/No/Abstain' -}; - -export interface MotionPollWithoutNestedModels extends BasePollWithoutNestedModels { - motion_id: number; - pollmethod: MotionPollMethods; -} /** * Class representing a poll for a motion. @@ -22,13 +13,10 @@ export class MotionPoll extends BasePoll { public static COLLECTIONSTRING = 'motions/motion-poll'; public id: number; + public motion_id: number; + public pollmethod: MotionPollMethods; public constructor(input?: any) { super(MotionPoll.COLLECTIONSTRING, input); } - - public get pollmethodVerbose(): string { - return MotionPollMethodsVerbose[this.pollmethod]; - } } -export interface MotionPoll extends MotionPollWithoutNestedModels {} diff --git a/client/src/app/shared/models/poll/base-option.ts b/client/src/app/shared/models/poll/base-option.ts index 53da72281..d6b46bbc9 100644 --- a/client/src/app/shared/models/poll/base-option.ts +++ b/client/src/app/shared/models/poll/base-option.ts @@ -5,6 +5,7 @@ export abstract class BaseOption extends BaseDecimalModel { public yes: number; public no: number; public abstain: number; + public poll_id: number; protected getDecimalFields(): (keyof BaseOption)[] { 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 index f5cb70763..75b35cf03 100644 --- a/client/src/app/shared/models/poll/base-poll.ts +++ b/client/src/app/shared/models/poll/base-poll.ts @@ -1,6 +1,15 @@ import { BaseDecimalModel } from '../base/base-decimal-model'; import { BaseOption } from './base-option'; +export enum PollColor { + yes = '#9fd773', + no = '#cc6c5b', + abstain = '#a6a6a6', + votesvalid = '#e2e2e2', + votesinvalid = '#e2e2e2', + votescast = '#e2e2e2' +} + export enum PollState { Created = 1, Started, @@ -8,41 +17,21 @@ export enum PollState { Published } -export const PollStateVerbose = { - 1: 'Created', - 2: 'Started', - 3: 'Finished', - 4: 'Published' -}; - export enum PollType { Analog = 'analog', Named = 'named', Pseudoanonymous = 'pseudoanonymous' } -export const PollTypeVerbose = { - analog: 'Analog', - named: 'Named', - pseudoanonymous: 'Pseudoanonymous' -}; - export enum PercentBase { YN = 'YN', YNA = 'YNA', Valid = 'valid', + Votes = 'votes', Cast = 'cast', Disabled = 'disabled' } -export const PercentBaseVerbose = { - YN: 'Yes/No', - YNA: 'Yes/No/Abstain', - valid: 'Valid votes', - cast: 'Casted votes', - disabled: 'Disabled' -}; - export enum MajorityMethod { Simple = 'simple', TwoThirds = 'two_thirds', @@ -50,47 +39,20 @@ export enum MajorityMethod { Disabled = 'disabled' } -export const MajorityMethodVerbose = { - simple: 'Simple', - two_thirds: 'Two Thirds', - three_quarters: 'Three Quarters', - disabled: 'Disabled' -}; - -export interface BasePollWithoutNestedModels { - state: PollState; - type: PollType; - title: string; - votesvalid: number; - votesinvalid: number; - votescast: number; - groups_id: number[]; - voted_id: number[]; - majority_method: MajorityMethod; - onehundred_percent_base: PercentBase; -} - -export abstract class BasePoll> extends BaseDecimalModel { - public options: O[]; +export abstract class BasePoll = any> 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 voted_id: number[]; + public majority_method: MajorityMethod; + public onehundred_percent_base: PercentBase; + public user_has_voted: boolean; protected getDecimalFields(): (keyof BasePoll)[] { return ['votesvalid', 'votesinvalid', 'votescast']; } - - public get stateVerbose(): string { - return PollStateVerbose[this.state]; - } - - public get typeVerbose(): string { - return PollTypeVerbose[this.type]; - } - - public get majorityMethodVerbose(): string { - return MajorityMethodVerbose[this.majority_method]; - } - - public get percentBaseVerbose(): string { - return PercentBaseVerbose[this.onehundred_percent_base]; - } } -export interface BasePoll> extends BasePollWithoutNestedModels {} diff --git a/client/src/app/shared/models/poll/base-vote.ts b/client/src/app/shared/models/poll/base-vote.ts index 849b7d0a5..91325f4bc 100644 --- a/client/src/app/shared/models/poll/base-vote.ts +++ b/client/src/app/shared/models/poll/base-vote.ts @@ -1,11 +1,31 @@ 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: 'Votes valid', + votesinvalid: 'Votes invalid', + votescast: 'Votes cast', + votesno: 'Votes No', + votesabstain: 'Votes abstain' +}; + export abstract class BaseVote extends BaseDecimalModel { public weight: number; - public value: 'Y' | 'N' | 'A'; + public value: VoteValue; public option_id: number; public user_id?: number; + public get valueVerbose(): string { + return VoteValueVerbose[this.value]; + } + protected getDecimalFields(): (keyof BaseVote)[] { return ['weight']; } diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index ab33cbef2..696293c3c 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -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,14 @@ 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 { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component'; +import { ChartsComponent } from './components/charts/charts.component'; +import { CheckInputComponent } from './components/check-input/check-input.component'; +import { BannerComponent } from './components/banner/banner.component'; +import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.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'; /** * Share Module for all "dumb" components and pipes. @@ -158,6 +168,7 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; MatStepperModule, MatTabsModule, MatSliderModule, + MatSlideToggleModule, MatDividerModule, DragDropModule, OpenSlidesTranslateModule.forChild(), @@ -171,7 +182,8 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; PblNgridMaterialModule, PblNgridTargetEventsModule, PdfViewerModule, - NgxMaterialTimepickerModule + NgxMaterialTimepickerModule, + ChartsModule ], exports: [ FormsModule, @@ -205,6 +217,7 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; MatButtonToggleModule, MatStepperModule, MatSliderModule, + MatSlideToggleModule, MatDividerModule, DragDropModule, NgxMatSelectSearchModule, @@ -257,8 +270,16 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; OverlayComponent, PreviewComponent, NgxMaterialTimepickerModule, + ChartsModule, TrustPipe, - LocalizedDatePipe + LocalizedDatePipe, + BreadcrumbComponent, + ChartsComponent, + CheckInputComponent, + BannerComponent, + PollFormComponent, + MotionPollDialogComponent, + AssignmentPollDialogComponent ], declarations: [ PermsDirective, @@ -305,7 +326,14 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; PreviewComponent, HeightResizingDirective, TrustPipe, - LocalizedDatePipe + LocalizedDatePipe, + BreadcrumbComponent, + ChartsComponent, + CheckInputComponent, + BannerComponent, + PollFormComponent, + MotionPollDialogComponent, + AssignmentPollDialogComponent ], providers: [ { @@ -330,7 +358,9 @@ import { LocalizedDatePipe } from './pipes/localized-date.pipe'; ChoiceDialogComponent, ProjectionDialogComponent, ProgressSnackBarComponent, - SuperSearchComponent + SuperSearchComponent, + MotionPollDialogComponent, + AssignmentPollDialogComponent ] }) export class SharedModule {} 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 @@
+ - + + + + + + + + + + + + + + + + + +
@@ -116,112 +134,79 @@ - - - - - - - - - - - - -
-
-

Election result

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

Candidates

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

Candidates

+
+
- - - - -
+ + + + + + + + +
- -
-
- - +
+ +
+
+ + + +
+
+ + +
+
+ + +
+
-
-
- +
+ +
+ + 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..681e83e39 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,6 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 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'; @@ -11,7 +12,7 @@ describe('AssignmentDetailComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [E2EImportsModule], - declarations: [AssignmentDetailComponent, AssignmentPollComponent] + declarations: [AssignmentDetailComponent, AssignmentPollComponent, AssignmentPollVoteComponent] }).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 a76a7eec1..5d8e60068 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 @@ -22,7 +22,9 @@ 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 { AssignmentPhases, ViewAssignment } from '../../models/view-assignment'; +import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user'; /** @@ -173,7 +175,8 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn private tagRepo: TagRepositoryService, private promptService: PromptService, private pdfService: AssignmentPdfExportService, - private mediafileRepo: MediafileRepositoryService + private mediafileRepo: MediafileRepositoryService, + private pollDialog: AssignmentPollDialogService ) { super(title, translate, matSnackBar); this.subscriptions.push( @@ -302,8 +305,12 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn /** * Creates a new Poll */ - public async createPoll(): Promise { - // await this.repo.createPoll(this.assignment).catch(this.raiseError); + public openDialog(): void { + this.pollDialog.openDialog({ + collectionString: ViewAssignmentPoll.COLLECTIONSTRING, + assignment_id: this.assignment.id, + assignment: this.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 }}

+ + +
+
{{ 'Current state' | translate }}: {{ poll.stateVerbose | translate }}
+
+ {{ 'Groups' | translate }}: + {{ group.getTitle() | translate }} +
+
{{ 'Poll type' | translate }}: {{ poll.typeVerbose | translate }}
+
{{ 'Poll method' | translate }}: {{ poll.pollmethodVerbose | translate }}
+
{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}
+
{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}
+
+ +
+

Result

+ +
+ +
+ + +
{{ option.user.full_name }}
+
{{ "Unknown user" | translate}}
+
+ + +
{{ obj.value.user.full_name }}
+
{{ "Unknown user" | translate}}
+ +
{{ obj.value.votes[option.user_id]}}
+
+
+
+
+
+
+ + + + + + + + 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..e69de29bb 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..29d7ed222 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts @@ -0,0 +1,59 @@ +import { Component } 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 { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; +import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; +import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; +import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; +import { ViewAssignmentVote } from '../../models/view-assignment-vote'; + +@Component({ + selector: 'os-assignment-poll-detail', + templateUrl: './assignment-poll-detail.component.html', + styleUrls: ['./assignment-poll-detail.component.scss'] +}) +export class AssignmentPollDetailComponent extends BasePollDetailComponent { + public votesByUser: { [key: number]: { user: ViewUser; votes: { [key: number]: ViewAssignmentVote } } }; + + public constructor( + title: Title, + translate: TranslateService, + matSnackbar: MatSnackBar, + repo: AssignmentPollRepositoryService, + route: ActivatedRoute, + groupRepo: GroupRepositoryService, + prompt: PromptService, + pollDialog: AssignmentPollDialogService + ) { + super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog); + } + + public onPollLoaded(): void { + const votes = {}; + + setTimeout(() => { + for (const option of this.poll.options) { + for (const vote of option.votes) { + if (!votes[vote.user_id]) { + votes[vote.user_id] = { + user: vote.user, + votes: {} + }; + } + votes[vote.user_id].votes[option.user_id] = + this.poll.pollmethod === AssignmentPollMethods.Votes ? vote.weight : vote.valueVerbose; + } + } + console.log(votes, this.poll, this.poll.options); + this.votesByUser = votes; + }, 1000); + } +} 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..e984aca1f 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,30 +1,52 @@ -

Voting result

-
- Special values:
- -1 =  majority  - -2 =  - undocumented -
-
-
+ + -
-
- {{ candidate.user.full_name }} +
+
+
+
+ {{ option.user.getFullName() }} + No user {{ option.candidate_id }} +
+ +
+
+ +
+
+
-
- - - {{ key | translate }} - +
+
+
+ + +
+ + Publish immediately + + + If you want to publish after creating, you have to fill at least one of the fields. +
-
+ + + +
+ +
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.ts b/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts index 4233a142e..ead48430a 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,14 +1,22 @@ -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 { Title } from '@angular/platform-browser'; -import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service'; +import { TranslateService } from '@ngx-translate/core'; + +import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; +import { GeneralValueVerbose, VoteValue, VoteValueVerbose } from 'app/shared/models/poll/base-vote'; +import { AssignmentPollMethodsVerbose } 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 { CalculablePollKey, PollVoteValue } from 'app/site/polls/services/poll.service'; +import { ViewUser } from 'app/site/users/models/view-user'; import { ViewAssignmentOption } from '../../models/view-assignment-option'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; -/** - * 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. @@ -18,7 +26,7 @@ type summaryPollKey = 'votescast' | 'votesvalid' | 'votesinvalid' | 'votesno' | templateUrl: './assignment-poll-dialog.component.html', styleUrls: ['./assignment-poll-dialog.component.scss'] }) -export class AssignmentPollDialogComponent { +export class AssignmentPollDialogComponent extends BasePollDialogComponent implements OnInit { /** * The actual poll data to work on */ @@ -27,13 +35,8 @@ export class AssignmentPollDialogComponent { /** * 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']; } /** @@ -42,39 +45,118 @@ 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 assignmentPollMethods = AssignmentPollMethodsVerbose; + + public options: OptionsObject; /** * Constructor. Retrieves necessary metadata from the pollService, * injects the poll itself */ public constructor( - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: ViewAssignmentPoll + private fb: FormBuilder, + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public pollData: Partial ) { - switch (this.data.pollmethod) { - case 'votes': - this.optionPollKeys = ['Votes']; - break; - case 'yn': - this.optionPollKeys = ['Yes', 'No']; - break; - case 'yna': - this.optionPollKeys = ['Yes', 'No', 'Abstain']; - break; + super(title, translate, matSnackbar, dialogRef); + } + + public ngOnInit(): void { + // on new poll creation, poll.options does not exist, so we have to build a substitute from the assignment candidates + this.options = this.pollData.options + ? this.pollData.options + : this.pollData.assignment.candidates.map( + user => ({ + user_id: user.id, + user: user + }), + {} + ); + + this.subscriptions.push( + this.pollForm.contentForm.get('pollmethod').valueChanges.subscribe(() => { + this.createDialog(); + }) + ); + } + + private setAnalogPollValues(): void { + const pollmethod = this.pollForm.contentForm.get('pollmethod').value; + this.analogPollValues = ['Y']; + if (pollmethod !== AssignmentPollMethods.Votes) { + this.analogPollValues.push('N'); + } + if (pollmethod === AssignmentPollMethods.YNA) { + this.analogPollValues.push('A'); + } + } + + private updateDialogVoteForm(data: Partial): void { + const update = { + options: {}, + votesvalid: data.votesvalid, + votesinvalid: data.votesinvalid, + votescast: data.votescast + }; + for (const option of data.options) { + const votes: any = {}; + votes.Y = option.yes; + if (data.pollmethod !== AssignmentPollMethods.Votes) { + votes.N = option.no; + } + if (data.pollmethod === AssignmentPollMethods.YNA) { + votes.A = option.abstain; + } + update.options[option.user_id] = votes; + } + + if (this.dialogVoteForm) { + const result = this.undoReplaceEmptyValues(update); + this.dialogVoteForm.setValue(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(); + + 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(-2)]] + })) + ) + })) + ), + // insert all used global fields + ...this.sumValues.mapToObject(sumValue => ({ + [sumValue]: ['', [Validators.min(-2)]] + })) + }); + if (this.pollData.poll) { + this.updateDialogVoteForm(this.pollData); + } } /** @@ -163,10 +245,6 @@ export class AssignmentPollDialogComponent { * @param weight */ public setSumValue(value: any /*SummaryPollKey*/, weight: string): void { - this.data[value] = parseFloat(weight); - } - - public getGridClass(): string { - return `votes-grid-${this.optionPollKeys.length}`; + this.pollData[value] = parseFloat(weight); } } 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..a33f4480b --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.html @@ -0,0 +1,47 @@ + + + {{ "You can distribute" | translate }} {{ poll.votes_amount }} {{ "votes" | translate }}. +
+ +
+ {{ option.user.getFullName() }} + No user {{ option.candidate_id }} +
+ +
+ + ({{ "Current" | translate }}: {{ getCurrentVoteVerbose(option.user_id) | translate }}) + +
+ + + + Yes + + + No + + + Abstain + + + + + + +
+
+
+ +
+
+ + {{ vmanager.getVotePermissionErrorVerbose(poll) | translate }} + +
\ No newline at end of file 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..4e6b74093 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.scss @@ -0,0 +1,12 @@ +.current-vote { + color: #777; + margin-right: 10px; +} + +.voting-grid { + display: grid; + grid-gap: 5px; + padding: 5px; + align-items: baseline; + grid-template-columns: auto max-content max-content; +} 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..f5b0fb3cc --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.ts @@ -0,0 +1,86 @@ +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +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 } from 'app/core/repositories/assignments/assignment-poll-repository.service'; +import { AssignmentVoteRepositoryService } from 'app/core/repositories/assignments/assignment-vote-repository.service'; +import { VotingService } from 'app/core/ui-services/voting.service'; +import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; +import { VoteValueVerbose } 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'; +import { ViewAssignmentVote } from '../../models/view-assignment-vote'; + +@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 pollMethods = AssignmentPollMethods; + + public voteForm: FormGroup; + + /** holds the currently saved votes */ + public currentVotes: { [key: number]: string | number | null } = {}; + + private votes: ViewAssignmentVote[]; + + public constructor( + title: Title, + translate: TranslateService, + matSnackbar: MatSnackBar, + vmanager: VotingService, + operator: OperatorService, + private voteRepo: AssignmentVoteRepositoryService, + private pollRepo: AssignmentPollRepositoryService, + private formBuilder: FormBuilder + ) { + super(title, translate, matSnackbar, vmanager, operator); + } + + public ngOnInit(): void { + this.subscriptions.push( + this.voteRepo.getViewModelListObservable().subscribe(votes => { + this.votes = votes; + this.updateVotes(); + }) + ); + } + + protected updateVotes(): void { + if (this.user && this.votes && this.poll) { + const filtered = this.votes.filter( + vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id + ); + this.voteForm = this.formBuilder.group( + this.poll.options.reduce((obj, option) => { + obj[option.id] = ['', [Validators.required]]; + return obj; + }, {}) + ); + for (const option of this.poll.options) { + const curr_vote = filtered.find(vote => vote.option.id === option.id); + this.currentVotes[option.user_id] = curr_vote + ? this.poll.pollmethod === AssignmentPollMethods.Votes + ? curr_vote.weight + : curr_vote.value + : null; + this.voteForm.get(option.id.toString()).setValue(this.currentVotes[option.user_id]); + } + } + } + + public saveVotes(): void { + this.pollRepo.vote(this.voteForm.value, this.poll.id).catch(this.raiseError); + } + + public getCurrentVoteVerbose(user_id: number): string { + const curr_vote = this.currentVotes[user_id]; + return this.poll.pollmethod === AssignmentPollMethods.Votes ? curr_vote : VoteValueVerbose[curr_vote]; + } +} 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..b0a7fe7c8 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 @@ -3,7 +3,7 @@
- +
@@ -40,8 +40,38 @@
-
-
+
+ + + {{ poll.stateVerbose }} + + + +
+ +

+ + {{ poll.title }} + +

+ + + +
+
Candidates
@@ -78,7 +108,7 @@ type="button" mat-icon-button (click)="toggleElected(option)" - [disabled]="!canManage || assignment.isFinished" + [disabled]="!canManage || poll.assignment.isFinished" disableRipple > check_box @@ -176,13 +206,13 @@
-
+ -
+
+ + + + + + + + + + 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..7c8b40b80 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 @@ -17,6 +17,52 @@ } } + .right-align { + text-align: right; + } + + .vote-input { + .mat-form-field-wrapper { + // padding-bottom: 0; + + .mat-form-field-infix { + width: 60px; + border-top: 0; + } + } + } + + .poll-properties { + margin: 4px 0; + + .mat-chip { + margin: 0 4px; + + &.active { + cursor: pointer; + } + } + + .poll-state { + &.created { + background-color: #2196f3; + color: white; + } + &.started { + background-color: #4caf50; + color: white; + } + &.finished { + background-color: #ff5252; + color: white; + } + &.published { + background-color: #ffd800; + color: black; + } + } + } + .poll-menu { position: absolute; top: 0; 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..13c83adb6 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,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { E2EImportsModule } from 'e2e-imports.module'; +import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component'; import { AssignmentPollComponent } from './assignment-poll.component'; describe('AssignmentPollComponent', () => { @@ -10,7 +11,7 @@ describe('AssignmentPollComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [AssignmentPollComponent], + declarations: [AssignmentPollComponent, AssignmentPollVoteComponent], imports: [E2EImportsModule] }).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 edc25f584..f2b98c86e 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,5 +1,5 @@ -import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; -import { FormGroup } from '@angular/forms'; +import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Title } from '@angular/platform-browser'; @@ -7,10 +7,10 @@ import { Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; import { OperatorService } from 'app/core/core-services/operator.service'; -import { CalculablePollKey, MajorityMethod } from 'app/core/ui-services/poll.service'; -import { BaseViewComponent } from 'app/site/base/base-view'; -import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service'; -import { ViewAssignment } from '../../models/view-assignment'; +import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; +import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { ViewAssignmentOption } from '../../models/view-assignment-option'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; @@ -23,30 +23,12 @@ import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; styleUrls: ['./assignment-poll.component.scss'], encapsulation: ViewEncapsulation.None }) -export class AssignmentPollComponent extends BaseViewComponent implements OnInit { - /** - * The related assignment (used for metainfos, e.g. related user names) - */ - @Input() - public assignment: ViewAssignment; - - /** - * The poll represented in this component - */ - @Input() - public poll: ViewAssignmentPoll; - +export class AssignmentPollComponent extends BasePollComponent implements OnInit { /** * 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 @@ -57,93 +39,38 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit 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); - throw new Error('TODO'); + public get canSee(): boolean { + return this.operator.hasPerms('assignments.can_see'); } /** * @returns true if the description on the form differs from the poll's description */ public get dirtyDescription(): boolean { - // return this.descriptionForm.get('description').value !== this.poll.description; - throw new Error('TODO'); - } - - /** - * @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;*/ - throw new Error('TODO'); - } - - /** - * 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 ''; - } + return this.descriptionForm.get('description').value !== this.poll.description; } public constructor( titleService: Title, matSnackBar: MatSnackBar, + translate: TranslateService, + dialog: MatDialog, + promptService: PromptService, + repo: AssignmentPollRepositoryService, + pollDialog: AssignmentPollDialogService, private operator: OperatorService, - public translate: TranslateService, - public dialog: MatDialog, - private pdfService: AssignmentPollPdfService + private formBuilder: FormBuilder ) { - 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; + 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); - }*/ + }); } /** @@ -151,7 +78,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit * */ public printBallot(): void { - this.pdfService.printBallots(this.poll); + throw new Error('TODO'); + // this.pdfService.printBallots(this.poll); } /** @@ -173,38 +101,6 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit throw new Error('TODO'); } - /** - * 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.copy(), - ...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 * diff --git a/client/src/app/site/assignments/models/view-assignment-option.ts b/client/src/app/site/assignments/models/view-assignment-option.ts index d364cbfbe..92470f361 100644 --- a/client/src/app/site/assignments/models/view-assignment-option.ts +++ b/client/src/app/site/assignments/models/view-assignment-option.ts @@ -1,6 +1,7 @@ import { AssignmentOption } from 'app/shared/models/assignments/assignment-option'; import { ViewUser } from 'app/site/users/models/view-user'; import { BaseViewModel } from '../../base/base-view-model'; +import { ViewAssignmentPoll } from './view-assignment-poll'; import { ViewAssignmentVote } from './view-assignment-vote'; export class ViewAssignmentOption extends BaseViewModel { @@ -11,9 +12,10 @@ export class ViewAssignmentOption extends BaseViewModel { protected _collectionString = AssignmentOption.COLLECTIONSTRING; } -interface TIMotionOptionRelations { +interface TIAssignmentOptionRelations { votes: ViewAssignmentVote[]; user: ViewUser; + poll: ViewAssignmentPoll; } -export interface ViewAssignmentOption extends AssignmentOption, TIMotionOptionRelations {} +export interface ViewAssignmentOption extends AssignmentOption, TIAssignmentOptionRelations {} 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 5306912dd..2535db0fc 100644 --- a/client/src/app/site/assignments/models/view-assignment-poll.ts +++ b/client/src/app/site/assignments/models/view-assignment-poll.ts @@ -1,25 +1,29 @@ -import { AssignmentPoll, AssignmentPollWithoutNestedModels } from 'app/shared/models/assignments/assignment-poll'; -import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; +import { ChartData } from 'app/shared/components/charts/charts.component'; +import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; 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 { 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 class ViewAssignmentPoll extends BaseProjectableViewModel - implements AssignmentPollTitleInformation { +export const AssignmentPollMethodsVerbose = { + votes: 'Fixed Amount of votes for all candidates', + YN: 'Yes/No per candidate', + YNA: 'Yes/No/Abstain per candidate' +}; + +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 pollClassType: 'assignment' | 'motion' = 'assignment'; public getSlide(): ProjectorElementBuildDeskriptor { - /*return { + // TODO: update to new voting system? + return { getBasicProjectorElement: options => ({ name: 'assignments/assignment-poll', assignment_id: this.assignment_id, @@ -27,17 +31,22 @@ export class ViewAssignmentPoll extends BaseProjectableViewModel getIdentifiers: () => ['name', 'assignment_id', 'poll_id'] }), slideOptions: [], - projectionDefaultName: 'assignments', + projectionDefaultName: 'assignment-poll', getDialogTitle: () => 'TODO' - };*/ - throw new Error('TODO'); + }; + } + + public get pollmethodVerbose(): string { + return AssignmentPollMethodsVerbose[this.pollmethod]; + } + + // TODO + public generateChartData(): ChartData { + return []; } } -interface TIAssignmentPollRelations { +export interface ViewAssignmentPoll extends AssignmentPoll { options: ViewAssignmentOption[]; - voted: ViewUser[]; - groups: ViewGroup[]; + assignment: ViewAssignment; } - -export interface ViewAssignmentPoll extends AssignmentPollWithoutNestedModels, TIAssignmentPollRelations {} diff --git a/client/src/app/site/assignments/models/view-assignment-vote.ts b/client/src/app/site/assignments/models/view-assignment-vote.ts index 2bd367bcc..dc8c6e75f 100644 --- a/client/src/app/site/assignments/models/view-assignment-vote.ts +++ b/client/src/app/site/assignments/models/view-assignment-vote.ts @@ -1,6 +1,7 @@ import { AssignmentVote } from 'app/shared/models/assignments/assignment-vote'; import { ViewUser } from 'app/site/users/models/view-user'; import { BaseViewModel } from '../../base/base-view-model'; +import { ViewAssignmentOption } from './view-assignment-option'; export class ViewAssignmentVote extends BaseViewModel { public get vote(): AssignmentVote { @@ -11,7 +12,8 @@ export class ViewAssignmentVote extends BaseViewModel { } interface TIAssignmentVoteRelations { - user?: ViewUser; + user: ViewUser; + option: ViewAssignmentOption; } export interface ViewAssignmentVote extends AssignmentVote, TIAssignmentVoteRelations {} 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-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..848402688 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-poll-dialog.service.ts @@ -0,0 +1,22 @@ +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 { AssignmentPollService } from './assignment-poll.service'; +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, service: AssignmentPollService) { + super(dialog, mapper, service); + } +} 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 new file mode 100644 index 000000000..7bfd1f954 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-poll.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { AssignmentPollService } from './assignment-poll.service'; + +describe('AssignmentPollService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: AssignmentPollService = TestBed.get(AssignmentPollService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/services/assignment-poll.service.ts b/client/src/app/site/assignments/services/assignment-poll.service.ts new file mode 100644 index 000000000..d3ad17b52 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-poll.service.ts @@ -0,0 +1,56 @@ +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 { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; +import { Collection } from 'app/shared/models/base/collection'; +import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll'; +import { PollService } from 'app/site/polls/services/poll.service'; +import { ViewAssignmentPoll } from '../models/view-assignment-poll'; + +@Injectable({ + providedIn: 'root' +}) +export class AssignmentPollService extends PollService { + /** + * The default percentage base + */ + public defaultPercentBase: PercentBase; + + /** + * The default majority method + */ + public defaultMajorityMethod: MajorityMethod; + + /** + * Constructor. Subscribes to the configuration values needed + * @param config ConfigService + */ + public constructor( + config: ConfigService, + constants: ConstantsService, + private translate: TranslateService, + private pollRepo: AssignmentPollRepositoryService + ) { + super(constants); + config + .get('motion_poll_default_100_percent_base') + .subscribe(base => (this.defaultPercentBase = base)); + config + .get('motion_poll_default_majority_method') + .subscribe(method => (this.defaultMajorityMethod = method)); + } + + public fillDefaultPollData(poll: Partial & Collection): void { + super.fillDefaultPollData(poll); + const length = this.pollRepo.getViewModelList().filter(item => item.assignment_id === poll.assignment_id) + .length; + + poll.title = !length ? this.translate.instant('Vote') : `${this.translate.instant('Vote')} (${length + 1})`; + poll.pollmethod = AssignmentPollMethods.YN; + poll.assignment_id = poll.assignment_id; + } +} diff --git a/client/src/app/site/motions/models/view-motion-option.ts b/client/src/app/site/motions/models/view-motion-option.ts index f5e1f7165..b380648cb 100644 --- a/client/src/app/site/motions/models/view-motion-option.ts +++ b/client/src/app/site/motions/models/view-motion-option.ts @@ -1,5 +1,6 @@ import { MotionOption } from 'app/shared/models/motions/motion-option'; import { BaseViewModel } from '../../base/base-view-model'; +import { ViewMotionPoll } from './view-motion-poll'; import { ViewMotionVote } from './view-motion-vote'; export class ViewMotionOption extends BaseViewModel { @@ -12,6 +13,7 @@ export class ViewMotionOption extends BaseViewModel { interface TIMotionOptionRelations { votes: ViewMotionVote[]; + poll: ViewMotionPoll; } export interface ViewMotionOption extends MotionOption, TIMotionOptionRelations {} diff --git a/client/src/app/site/motions/models/view-motion-poll.ts b/client/src/app/site/motions/models/view-motion-poll.ts index 5fc734621..af28f82bd 100644 --- a/client/src/app/site/motions/models/view-motion-poll.ts +++ b/client/src/app/site/motions/models/view-motion-poll.ts @@ -1,20 +1,45 @@ -import { MotionPoll, MotionPollWithoutNestedModels } from 'app/shared/models/motions/motion-poll'; -import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; +import { ChartData } from 'app/shared/components/charts/charts.component'; +import { MotionPoll, MotionPollMethods } from 'app/shared/models/motions/motion-poll'; +import { PollColor } from 'app/shared/models/poll/base-poll'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; -import { ViewGroup } from 'app/site/users/models/view-group'; -import { ViewUser } from 'app/site/users/models/view-user'; +import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; export interface MotionPollTitleInformation { title: string; } -export class ViewMotionPoll extends BaseProjectableViewModel implements MotionPollTitleInformation { +export const MotionPollMethodsVerbose = { + YN: 'Yes/No', + YNA: 'Yes/No/Abstain' +}; + +export class ViewMotionPoll extends ViewBasePoll implements MotionPollTitleInformation { public static COLLECTIONSTRING = MotionPoll.COLLECTIONSTRING; protected _collectionString = MotionPoll.COLLECTIONSTRING; - public get poll(): MotionPoll { - return this._model; + public readonly pollClassType: 'assignment' | 'motion' = 'motion'; + + public generateChartData(): ChartData { + const fields = ['yes', 'no']; + if (this.pollmethod === MotionPollMethods.YNA) { + fields.push('abstain'); + } + const data: ChartData = fields.map(key => ({ + label: key.toUpperCase(), + data: [this.options[0][key]], + backgroundColor: PollColor[key], + hoverBackgroundColor: PollColor[key] + })); + + data.push({ + label: 'Votes invalid', + data: [this.votesinvalid], + backgroundColor: PollColor.votesinvalid, + hoverBackgroundColor: PollColor.votesinvalid + }); + + return data; } public getSlide(): ProjectorElementBuildDeskriptor { @@ -29,12 +54,12 @@ export class ViewMotionPoll extends BaseProjectableViewModel impleme getDialogTitle: this.getTitle }; } + + public get pollmethodVerbose(): string { + return MotionPollMethodsVerbose[this.pollmethod]; + } } -interface TIMotionPollRelations { +export interface ViewMotionPoll extends MotionPoll { options: ViewMotionOption[]; - voted: ViewUser[]; - groups: ViewGroup[]; } - -export interface ViewMotionPoll extends MotionPollWithoutNestedModels, TIMotionPollRelations {} diff --git a/client/src/app/site/motions/models/view-motion-vote.ts b/client/src/app/site/motions/models/view-motion-vote.ts index 4130b563e..164de6592 100644 --- a/client/src/app/site/motions/models/view-motion-vote.ts +++ b/client/src/app/site/motions/models/view-motion-vote.ts @@ -1,6 +1,7 @@ import { MotionVote } from 'app/shared/models/motions/motion-vote'; import { ViewUser } from 'app/site/users/models/view-user'; import { BaseViewModel } from '../../base/base-view-model'; +import { ViewMotionOption } from './view-motion-option'; export class ViewMotionVote extends BaseViewModel { public get vote(): MotionVote { @@ -12,6 +13,7 @@ export class ViewMotionVote extends BaseViewModel { interface TIMotionVoteRelations { user?: ViewUser; + option: ViewMotionOption; } export interface ViewMotionVote extends MotionVote, TIMotionVoteRelations {} diff --git a/client/src/app/site/motions/modules/amendment-list/amendment-list.component.html b/client/src/app/site/motions/modules/amendment-list/amendment-list.component.html index 3b3acf27b..7a5ddeb48 100644 --- a/client/src/app/site/motions/modules/amendment-list/amendment-list.component.html +++ b/client/src/app/site/motions/modules/amendment-list/amendment-list.component.html @@ -25,7 +25,7 @@
-
-
- - - + +
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..535e88b18 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,15 @@ span { margin: 0; } +.create-poll-button { + margin-top: 10px; + padding: 0px !important; + button { + display: block; + width: 100%; + } +} + .extra-controls-slot { div { padding: 0px; @@ -207,13 +216,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 f68d81bf8..1ed28ddf9 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 @@ -7,8 +7,8 @@ import { MotionCommentsComponent } from '../motion-comments/motion-comments.comp 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 { MotionPollPreviewComponent } from '../motion-poll/motion-poll-preview/motion-poll-preview.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', () => { @@ -26,7 +26,7 @@ describe('MotionDetailComponent', () => { MotionPollComponent, MotionDetailOriginalChangeRecommendationsComponent, MotionDetailDiffComponent, - MotionPollPreviewComponent + MotionPollVoteComponent ] }).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 9cb6c1f46..e3abf2a0e 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,7 @@ 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 { 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 +466,8 @@ 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 ) { super(title, translate, matSnackBar); } @@ -1378,13 +1381,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, window.open(attachment.url); } - /** - * Handler for creating a poll - */ - public createPoll(): void { - this.router.navigate(['motions', 'polls', 'new'], { queryParams: { parent: this.motion.id || null } }); - } - /** * Check if a recommendation can be followed. Checks for permissions and additionally if a recommentadion is present */ @@ -1568,12 +1564,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 +1624,10 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, public detectChanges(): void { this.cd.markForCheck(); } + + public openDialog(poll?: ViewMotionPoll): void { + this.pollDialog.openDialog( + poll ? poll : { collectionString: ViewMotionPoll.COLLECTIONSTRING, motion_id: this.motion.id } + ); + } } 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 53ff0ed4c..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Component, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; - -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 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-preview/motion-poll-preview.component.html b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.html deleted file mode 100644 index 78f801c34..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.html +++ /dev/null @@ -1,56 +0,0 @@ - - {{ poll.title }} - -
-
{{ 'Current state' | translate }}: {{ poll.stateVerbose }}
-
{{ 'Groups' | translate }}: {{ poll.groups }}
-
{{ 'Method' | translate }}: {{ poll.pollmethodVerbose }}
-
{{ 'Type' | translate }}: {{ poll.typeVerbose }}
-
- - -
-
- - - -

Enter votes

-
-
-

- - - - Required - - -

- - -

Statute paragraph

- -
-
-
-
- - -
-
\ No newline at end of file diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.scss b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.scss deleted file mode 100644 index 7eb491efc..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -.poll-content { - padding-bottom: 8px; -} - -.poll-footer { - text-align: end; -} diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.ts deleted file mode 100644 index 01f9bc977..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component, Input } from '@angular/core'; -import { MatSnackBar } from '@angular/material'; -import { Title } from '@angular/platform-browser'; -import { Router } from '@angular/router'; - -import { TranslateService } from '@ngx-translate/core'; - -import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; -import { PromptService } from 'app/core/ui-services/prompt.service'; -import { PollType } from 'app/shared/models/poll/base-poll'; -import { BaseViewComponent } from 'app/site/base/base-view'; -import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; - -@Component({ - selector: 'os-motion-poll-preview', - templateUrl: './motion-poll-preview.component.html', - styleUrls: ['./motion-poll-preview.component.scss'] -}) -export class MotionPollPreviewComponent extends BaseViewComponent { - @Input() - public poll: ViewMotionPoll; - - public pollTypes = PollType; - - public constructor( - title: Title, - protected translate: TranslateService, - matSnackbar: MatSnackBar, - private repo: MotionPollRepositoryService, - private promptDialog: PromptService, - private router: Router - ) { - super(title, translate, matSnackbar); - } - - public openPoll(): void { - this.router.navigate(['motions', 'polls', this.poll.id]); - } - - public editPoll(): void { - this.router.navigate(['motions', 'polls', this.poll.id], { queryParams: { edit: true } }); - } - - public async deletePoll(): Promise { - const title = 'Delete poll'; - const text = 'Do you really want to delete the selected poll?'; - - if (await this.promptDialog.open(title, text)) { - await this.repo.delete(this.poll); - } - } - - public enterAnalogVotes(): void { - throw new Error('TODO'); - } -} 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 fdc2e527f..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 43b66a711..000000000 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; -import { MatDialog, MatSnackBar } from '@angular/material'; -import { Title } from '@angular/platform-browser'; - -import { TranslateService } from '@ngx-translate/core'; - -import { ConstantsService } from 'app/core/core-services/constants.service'; -import { CalculablePollKey } from 'app/core/ui-services/poll.service'; -import { MotionPoll } from 'app/shared/models/motions/motion-poll'; -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'; - -/** - * 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 constants: ConstantsService, - 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); - }*/ - throw new Error('TODO'); - } - - /** - * @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); - } - });*/ - throw new Error('TODO'); - } - - /** - * 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; - return false; - } - - /** - * 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 05cb39f66..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,23 +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 { MotionPollPreviewComponent } from './components/motion-poll/motion-poll-preview/motion-poll-preview.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, - MotionPollPreviewComponent, - MotionPollDialogComponent, MotionDetailDiffComponent, MotionDetailOriginalChangeRecommendationsComponent, MotionChangeRecommendationDialogComponent, @@ -36,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 @@
-

New vote

-

{{ poll.title }}

+

{{ poll.title }}

+
+ + - - - + + +

{{ poll.title }}

+
{{ 'Current state' | translate }}: {{ poll.stateVerbose | translate }}
-
+
{{ 'Groups' | translate }}: {{ group.getTitle() | translate }}
@@ -36,57 +38,40 @@
{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}
{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}
+ +
+

Result

+ + +
+ +
{{ vote.user.full_name }}
+
{{ "Unknown user" | translate}}
+
{{ vote.valueVerbose }}
+
+
+
- -
- - - A title is required - - - - {{ - option.value | translate - }} - - This field is required - - - - - - - {{ - option.value - }} - - This field is required - - - - {{ - option.value | translate - }} - - - - - {{ - option.value | 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 index 621e890fd..72c49b249 100644 --- 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 @@ -1,3 +1,13 @@ .poll-content { padding-top: 10px; } + +.chart-wrapper { + display: flex; + justify-content: space-around; + align-items: center; + * { + flex: 1; + max-width: 200px; + } +} 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 index e44c359c3..c0f8934d9 100644 --- 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 @@ -1,152 +1,33 @@ -import { Location } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Component } from '@angular/core'; import { MatSnackBar } from '@angular/material'; import { Title } from '@angular/platform-browser'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; -import { MotionPoll, MotionPollMethodsVerbose } from 'app/shared/models/motions/motion-poll'; -import { - MajorityMethodVerbose, - PercentBaseVerbose, - PollStateVerbose, - PollTypeVerbose -} from 'app/shared/models/poll/base-poll'; -import { BaseViewComponent } from 'app/site/base/base-view'; +import { PromptService } from 'app/core/ui-services/prompt.service'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; -import { ViewGroup } from 'app/site/users/models/view-group'; +import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.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'] }) -export class MotionPollDetailComponent extends BaseViewComponent implements OnInit { - private pollId: number; - - public pollStates = PollStateVerbose; - public pollMethods = MotionPollMethodsVerbose; - public pollTypes = PollTypeVerbose; - public percentBases = PercentBaseVerbose; - public majorityMethods = MajorityMethodVerbose; - - public userGroups: ViewGroup[] = []; - - public groupObservable: Observable = null; - - public isNewPoll = false; - - public poll: ViewMotionPoll = null; - - public motionId: number; - - public isEditingPoll = false; - - public contentForm: FormGroup; - +export class MotionPollDetailComponent extends BasePollDetailComponent { public constructor( title: Title, - protected translate: TranslateService, + translate: TranslateService, matSnackbar: MatSnackBar, - private repo: MotionPollRepositoryService, - private route: ActivatedRoute, - private router: Router, - private fb: FormBuilder, - private groupRepo: GroupRepositoryService, - private location: Location + repo: MotionPollRepositoryService, + route: ActivatedRoute, + groupRepo: GroupRepositoryService, + prompt: PromptService, + pollDialog: MotionPollDialogService ) { - super(title, translate, matSnackbar); - } - - public ngOnInit(): void { - this.findComponentById(); - this.createPoll(); - - this.groupObservable = this.groupRepo.getViewModelListObservable(); - this.subscriptions.push( - this.groupRepo.getViewModelListObservable().subscribe(groups => (this.userGroups = groups)) - ); - } - - public savePoll(): void { - const pollValues = this.contentForm.value; - const poll: MotionPoll = this.isNewPoll ? new MotionPoll() : this.poll.poll; - Object.keys(pollValues).forEach(key => (poll[key] = pollValues[key])); - if (this.isNewPoll) { - poll.motion_id = this.motionId; - this.repo.create(poll).then(success => { - if (success && success.id) { - this.pollId = success.id; - this.router.navigate(['motions', 'polls', this.pollId]); - } - }, this.raiseError); - } else { - this.repo.update(pollValues, this.poll).then(() => (this.isEditingPoll = false), this.raiseError); - } - } - - public editPoll(): void { - this.isEditingPoll = true; - } - - public backToView(): void { - if (this.pollId) { - this.isEditingPoll = false; - } else { - // TODO - this.location.back(); - } - } - - private findComponentById(): void { - const params = this.route.snapshot.params; - const queryParams = this.route.snapshot.queryParams; - if (params && params.id) { - this.pollId = +params.id; - this.subscriptions.push( - this.repo.getViewModelObservable(this.pollId).subscribe(poll => { - if (poll) { - this.poll = poll; - this.updateForm(); - } - }) - ); - } else { - this.isNewPoll = true; - this.isEditingPoll = true; - if (queryParams && queryParams.parent) { - this.motionId = +queryParams.parent; - } - } - if (queryParams && queryParams.edit) { - this.isEditingPoll = true; - } - } - - private createPoll(): void { - this.contentForm = this.fb.group({ - title: ['', Validators.required], - type: ['', Validators.required], - pollmethod: ['', Validators.required], - onehundred_percent_base: ['', Validators.required], - majority_method: ['', Validators.required], - groups_id: [[]] - }); - if (this.poll) { - this.updateForm(); - } - } - - private updateForm(): void { - if (this.contentForm) { - Object.keys(this.contentForm.controls).forEach(key => { - this.contentForm.get(key).setValue(this.poll[key]); - }); - } + super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog); } } 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..121a638d8 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component.html @@ -0,0 +1,68 @@ + + + +
+
+ + + + + + +
+
+ +
+ + Publish immediately + + + If you want to publish after creating, you have to fill at least one of the fields. + +
+
+ +
+ + +
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..17da30260 --- /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'; + +fdescribe('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..6eaf0dc45 --- /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, 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 { MotionPollMethods } from 'app/shared/models/motions/motion-poll'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { MotionPollMethodsVerbose } 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'; + +@Component({ + selector: 'os-motion-poll-dialog', + templateUrl: './motion-poll-dialog.component.html', + styleUrls: ['./motion-poll-dialog.component.scss'] +}) +export class MotionPollDialogComponent extends BasePollDialogComponent { + public motionPollMethods = MotionPollMethodsVerbose; + + @ViewChild('pollForm', { static: false }) + protected pollForm: PollFormComponent; + + public constructor( + private fb: FormBuilder, + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public pollData: Partial + ) { + super(title, translate, matSnackbar, dialogRef); + this.createDialog(); + } + + private updateDialogVoteForm(data: Partial): void { + const update: any = { + Y: data.options[0].yes, + N: data.options[0].no, + votesvalid: data.votesvalid, + votesinvalid: data.votesinvalid, + votescast: data.votescast + }; + if (data.pollmethod === 'YNA') { + update.A = data.options[0].abstain; + } + + 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.fb.group({ + Y: ['', [Validators.min(-2)]], + N: ['', [Validators.min(-2)]], + votesvalid: ['', [Validators.min(-2)]], + votesinvalid: ['', [Validators.min(-2)]], + votescast: ['', [Validators.min(-2)]] + }); + if (this.pollData.pollmethod === MotionPollMethods.YNA) { + this.dialogVoteForm.addControl('A', this.fb.control('', [Validators.min(-2)])); + } + if (this.pollData.poll) { + this.updateDialogVoteForm(this.pollData); + } + } +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.html index d4f67dbb1..3e3abd6d9 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.html +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-list/motion-poll-list.component.html @@ -8,7 +8,7 @@ + +
Your current vote is '{{ currentVote.valueVerbose | translate }}'
+
You have not voted yet.
+ + + + Yes + + + No + + + Abstain + + + +
+ + + {{ vmanager.getVotePermissionErrorVerbose(poll) | 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..e69de29bb diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.spec.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.spec.ts similarity index 57% rename from client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.spec.ts rename to client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.spec.ts index 818e19927..baaa1e2e1 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-preview/motion-poll-preview.component.spec.ts +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.spec.ts @@ -2,21 +2,21 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { E2EImportsModule } from 'e2e-imports.module'; -import { MotionPollPreviewComponent } from './motion-poll-preview.component'; +import { MotionPollVoteComponent } from './motion-poll-vote.component'; -describe('MotionPollPreviewComponent', () => { - let component: MotionPollPreviewComponent; - let fixture: ComponentFixture; +describe('MotionPollVoteComponent', () => { + let component: MotionPollVoteComponent; + let fixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [E2EImportsModule], - declarations: [MotionPollPreviewComponent] + declarations: [MotionPollVoteComponent] }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(MotionPollPreviewComponent); + fixture = TestBed.createComponent(MotionPollVoteComponent); component = fixture.componentInstance; fixture.detectChanges(); }); 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..563091522 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts @@ -0,0 +1,73 @@ +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 { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; +import { MotionVoteRepositoryService } from 'app/core/repositories/motions/motion-vote-repository.service'; +import { VotingService } from 'app/core/ui-services/voting.service'; +import { MotionPollMethods } from 'app/shared/models/motions/motion-poll'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote'; +import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component'; + +@Component({ + selector: 'os-motion-poll-vote', + templateUrl: './motion-poll-vote.component.html', + styleUrls: ['./motion-poll-vote.component.scss'] +}) +export class MotionPollVoteComponent extends BasePollVoteComponent implements OnInit { + // holds the currently selected vote + public selectedVote: 'Y' | 'N' | 'A' = null; + // holds the last saved vote + public currentVote: ViewMotionVote; + + public pollMethods = MotionPollMethods; + + private votes: ViewMotionVote[]; + + public constructor( + title: Title, + translate: TranslateService, + matSnackbar: MatSnackBar, + vmanager: VotingService, + operator: OperatorService, + private voteRepo: MotionVoteRepositoryService, + private pollRepo: MotionPollRepositoryService + ) { + super(title, translate, matSnackbar, vmanager, operator); + } + + public ngOnInit(): void { + this.subscriptions.push( + this.voteRepo.getViewModelListObservable().subscribe(votes => { + this.votes = votes; + this.updateVotes(); + }) + ); + } + + protected updateVotes(): void { + if (this.user && this.votes && this.poll) { + const filtered = this.votes.filter( + vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id + ); + if (filtered.length) { + if (filtered.length > 1) { + // output warning and continue to keep the error case user friendly + console.error('A user should never have more than one vote on the same poll.'); + } + this.currentVote = filtered[0]; + this.selectedVote = filtered[0].value; + } + } + } + + public saveVote(): void { + if (this.selectedVote) { + this.pollRepo.vote(this.selectedVote, 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 index 48c88631f..acb7ca28a 100644 --- 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 @@ -5,9 +5,12 @@ import { SharedModule } from 'app/shared/shared.module'; import { MotionPollDetailComponent } from './motion-poll-detail/motion-poll-detail.component'; import { MotionPollListComponent } from './motion-poll-list/motion-poll-list.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({ - declarations: [MotionPollDetailComponent, MotionPollListComponent], - imports: [CommonModule, SharedModule, MotionPollRoutingModule] + imports: [CommonModule, SharedModule, MotionPollRoutingModule], + exports: [MotionPollComponent], + declarations: [MotionPollComponent, MotionPollDetailComponent, MotionPollListComponent, 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..25c84af54 --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html @@ -0,0 +1,73 @@ +
+
+ + {{ poll.title }} + + + + +
+ {{ poll.typeVerbose }} + + {{ poll.stateVerbose }} + + + +
+
+
+ + + + + +
+
+
+ + +
+ close + : {{ voteNo }} +
+
+ +
+
+ check + : {{ voteYes }} +
+
+ + +
+ An empty poll - you have to enter votes. +
+
+ + + + + + 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..9c6e7371d --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss @@ -0,0 +1,90 @@ +.poll-preview-wrapper { + padding: 8px; + background: white; + border: 1px solid rgba(0, 0, 0, 0.12); + + .poll-title { + color: black; + text-decoration: none; + } + + .poll-title-actions { + float: right; + } + + .poll-properties { + margin: 4px 0; + + .mat-chip { + margin: 0 4px; + + &.active { + cursor: pointer; + } + } + + .poll-state { + &.created { + background-color: #2196f3; + color: white; + } + &.started { + background-color: #4caf50; + color: white; + } + &.finished { + background-color: #ff5252; + color: white; + } + &.published { + background-color: #ffd800; + color: black; + } + } + } + + .poll-chart-wrapper { + cursor: pointer; + margin: 4px auto; + display: flex; + justify-content: center; + + div { + flex: 1; + } + + .chart-wrapper-left, + .chart-wrapper-right { + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + } + + .chart-wrapper-left { + color: #cc6c5b; + } + + .chart-wrapper-right { + color: #9fc773; + } + + .doughnut-chart { + max-width: 35%; + } + } +} + +.poll-preview-meta-info { + span { + padding: 0 5px; + } +} + +.poll-content { + padding-bottom: 8px; +} + +.poll-footer { + text-align: end; +} 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..a1006418f --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +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] + }).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..7f65317dd --- /dev/null +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts @@ -0,0 +1,90 @@ +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 { BehaviorSubject } from 'rxjs'; + +import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { ChartData } from 'app/shared/components/charts/charts.component'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service'; +import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; +import { PollService } 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 { + /** + * The dedicated `ViewMotionPoll`. + * TODO: shadows superclass `poll`. Maybe change when chart data is generated? + */ + @Input() + public set poll(value: ViewMotionPoll) { + this._poll = value; + + const chartData = this.poll.generateChartData(); + for (const data of chartData) { + if (data.label === 'YES') { + this.voteYes = data.data[0]; + } + if (data.label === 'NO') { + this.voteNo = data.data[0]; + } + } + this.chartDataSubject.next(chartData); + } + + public get poll(): ViewMotionPoll { + return this._poll; + } + + /** + * Subject to holding the data needed for the chart. + */ + public chartDataSubject: BehaviorSubject = new BehaviorSubject([]); + + /** + * Number of votes for `Yes`. + */ + public voteYes = 0; + + /** + * Number of votes for `No`. + */ + public voteNo = 0; + + /** + * The motion-poll. + */ + private _poll: ViewMotionPoll; + + /** + * Constructor. + * + * @param title + * @param translate + * @param matSnackbar + * @param router + * @param motionRepo + */ + public constructor( + titleService: Title, + matSnackBar: MatSnackBar, + translate: TranslateService, + dialog: MatDialog, + promptService: PromptService, + public repo: MotionPollRepositoryService, + pollDialog: MotionPollDialogService, + public pollService: PollService + ) { + super(titleService, matSnackBar, translate, dialog, promptService, repo, pollDialog); + } +} 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 @@ { + 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..520db12f2 --- /dev/null +++ b/client/src/app/site/motions/services/motion-poll-dialog.service.ts @@ -0,0 +1,22 @@ +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 { MotionPollService } from './motion-poll.service'; +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, service: MotionPollService) { + super(dialog, mapper, service); + } +} 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 28757936b..fb5f19dd9 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,15 @@ 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 { Collection } from 'app/shared/models/base/collection'; +import { MotionPollMethods } from 'app/shared/models/motions/motion-poll'; +import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { PollService } from 'app/site/polls/services/poll.service'; /** * Service class for motion polls. @@ -12,153 +19,40 @@ 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; /** * 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, + private translate: TranslateService, + private pollRepo: MotionPollRepositoryService + ) { + super(constants); 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)); } - /** - * 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; - } - 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; - } + public fillDefaultPollData(poll: Partial & Collection): void { + super.fillDefaultPollData(poll); + const length = this.pollRepo.getViewModelList().filter(item => item.motion_id === poll.motion_id).length; - /** - * 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; - }*/ - return 0; - } - - /** - * 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; - } - - /** - * 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; - } - break; - case 'YES_NO_ABSTAIN': - if (['votescast', 'votesinvalid', 'votesvalid'].includes(value)) { - return true; - } - break; - case 'VALID': - if (['votesinvalid', 'votescast'].includes(value)) { - return true; - } - break; - } - if (poll[value] < 0) { - return true; - } - return false; + poll.title = !length ? this.translate.instant('Vote') : `${this.translate.instant('Vote')} (${length + 1})`; + poll.pollmethod = MotionPollMethods.YN; + poll.motion_id = poll.motion_id; } } 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..1972118f5 --- /dev/null +++ b/client/src/app/site/polls/components/base-poll-detail.component.ts @@ -0,0 +1,240 @@ +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, Observable } from 'rxjs'; + +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 { Breadcrumb } from 'app/shared/components/breadcrumb/breadcrumb.component'; +import { ChartData } from 'app/shared/components/charts/charts.component'; +import { PollState } from 'app/shared/models/poll/base-poll'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { BasePollRepositoryService } from '../services/base-poll-repository.service'; +import { ViewBasePoll } from '../models/view-base-poll'; + +export class BasePollDetailComponent extends BaseViewComponent implements OnInit { + /** + * All the groups of users. + */ + public userGroups: ViewGroup[] = []; + + /** + * Holding all groups. + */ + public groupObservable: Observable = null; + + /** + * The reference to the poll. + */ + public poll: V = null; + + /** + * The breadcrumbs for the poll-states. + */ + public breadcrumbs: Breadcrumb[] = []; + + /** + * Sets the type of the shown chart, if votes are entered. + */ + public chartType = 'horizontalBar'; + + /** + * 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); + + /** + * Constructor + * + * @param title + * @param translate + * @param matSnackbar + * @param repo + * @param route + * @param router + * @param fb + * @param groupRepo + * @param location + * @param promptDialog + * @param dialog + */ + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + protected repo: BasePollRepositoryService, + protected route: ActivatedRoute, + protected groupRepo: GroupRepositoryService, + protected promptDialog: PromptService, + protected pollDialog: BasePollDialogService + ) { + super(title, translate, matSnackbar); + } + + /** + * 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 = 'Delete poll'; + const text = 'Do you really want to delete the selected poll?'; + + if (await this.promptDialog.open(title, text)) { + await this.repo.delete(this.poll); + } + } + + public async pseudoanonymizePoll(): Promise { + const title = 'Pseudoanonymize poll'; + const text = 'Do you really want to pseudoanonymize the selected poll?'; + + if (await this.promptDialog.open(title, text)) { + await this.repo.pseudoanonymize(this.poll); + } + } + + /** + * This changes the data for the chart depending on the switch in the detail-view. + * + * @param isChecked boolean, if the chart should show the amount of entered votes. + */ + public changeChart(): void { + this.chartDataSubject.next(this.poll.generateChartData()); + } + + /** + * This checks, if the poll has votes. + */ + private checkData(): void { + if (this.poll.state === 3 || this.poll.state === 4) { + // this.chartDataSubject.next(this.poll.generateChartData()); + } + } + + /** + * 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.updateBreadcrumbs(); + this.checkData(); + this.labels = this.createChartLabels(); + this.onPollLoaded(); + } + }) + ); + } + } + + /** + * Opens dialog for editing the poll + */ + public openDialog(): void { + this.pollDialog.openDialog(this.poll); + } + + /** + * Called after the poll has been loaded. Meant to be overwritten by subclasses who need initial access to the poll + */ + public onPollLoaded(): void {} + + /** + * Action for the different breadcrumbs. + */ + private async changeState(): Promise { + this.actionWrapper(this.repo.changePollState(this.poll)); + } + + /** + * Resets the state of a motion-poll. + */ + private async resetState(): Promise { + this.actionWrapper(this.repo.resetPoll(this.poll)); + } + + /** + * Used to execute same logic after fullfilling a promise. + * + * @param action Any promise to execute. + * + * @returns Any promise-like. + */ + private actionWrapper(action: Promise): any { + action.then(() => this.checkData()).catch(this.raiseError); + } + + /** + * Function to create the labels for the chart. + * + * @returns An array of `Label`. + */ + private createChartLabels(): Label[] { + return ['Number of votes']; + } + + /** + * Used to change the breadcrumbs depending on the state of the given motion-poll. + */ + private updateBreadcrumbs(): void { + this.breadcrumbs = Object.values(PollState) + .filter(state => typeof state === 'string') + .map((state: string) => ({ + label: state, + action: this.getBreadcrumbAction(PollState[state]), + active: this.poll ? this.poll.state === PollState[state] : false + })); + } + + /** + * Depending on the state of the motion-poll, the breadcrumb has another action and state. + * + * @param state The state of the motion-poll as number. + * + * @returns An action, that is executed, if the breadcrumb is clicked, or null. + */ + private getBreadcrumbAction(state: number): () => any | null { + if (!this.poll) { + return null; + } + switch (this.poll.state) { + case PollState.Created: + return state === 2 ? () => this.changeState() : null; + case PollState.Started: + return null; + case PollState.Finished: + if (state === 1) { + return () => this.resetState(); + } else if (state === 4) { + return () => this.changeState(); + } else { + return null; + } + case PollState.Published: + return state === 1 ? () => this.resetState() : null; + } + } +} 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..7c252c907 --- /dev/null +++ b/client/src/app/site/polls/components/base-poll-dialog.component.ts @@ -0,0 +1,103 @@ +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 { OneOfValidator } from 'app/shared/validators/one-of-validator'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { PollFormComponent } from './poll-form/poll-form.component'; + +/** + * A dialog for updating the values of a poll. + */ +export abstract class BasePollDialogComponent extends BaseViewComponent { + 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); + } + + /** + * 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 '-2' 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 '-2' + * @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] === -2 ? null : voteData[key]; + } else { + result[key] = !!voteData[key] ? voteData[key] : -2; + } + } + } + return result; + } + + /** + * reverses the replacement of empty values by '-2'; replaces each '-2' 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..43e618e03 --- /dev/null +++ b/client/src/app/site/polls/components/base-poll-vote.component.ts @@ -0,0 +1,39 @@ +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, VotingService } 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, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + public vmanager: VotingService, + protected operator: OperatorService + ) { + super(title, translate, matSnackbar); + + this.subscriptions.push( + this.operator.getViewUserObservable().subscribe(user => { + this.user = user; + this.updateVotes(); + }) + ); + } + + protected abstract updateVotes(): void; +} 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..533fda44a --- /dev/null +++ b/client/src/app/site/polls/components/base-poll.component.ts @@ -0,0 +1,54 @@ +import { Input } from '@angular/core'; +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 { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { PollState } 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 class BasePollComponent extends BaseViewComponent { + /** + * The poll represented in this component + */ + @Input() + public poll: V; + + public constructor( + titleService: Title, + matSnackBar: MatSnackBar, + public translate: TranslateService, + public dialog: MatDialog, + protected promptService: PromptService, + public repo: BasePollRepositoryService, + protected pollDialog: BasePollDialogService + ) { + super(titleService, translate, matSnackBar); + } + + public changeState(key: PollState): void { + key === PollState.Created ? this.repo.resetPoll(this.poll) : this.repo.changePollState(this.poll); + } + + /** + * Handler for the 'delete poll' button + */ + public async onDeletePoll(): Promise { + const title = this.translate.instant('Are you sure you want to delete this poll?'); + 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); + } +} 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..b57d11d21 --- /dev/null +++ b/client/src/app/site/polls/components/poll-form/poll-form.component.html @@ -0,0 +1,71 @@ +
+
+ +

+ +

+
+
+
+ + + {{ value[0] }} + + + {{ value[1] }} + + +
+
+ + + + + {{ option.value | translate }} + + + This field is required + + + + + + + + {{ option.value }} + + + This field is required + + + + + + + + {{ option.value | translate }} + + + + + + + + {{ option.value | translate }} + + + +
+
+ \ No newline at end of file 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..15a5b0dd2 --- /dev/null +++ b/client/src/app/site/polls/components/poll-form/poll-form.component.scss @@ -0,0 +1,32 @@ +.poll-preview--title { + margin: 0; +} + +.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..daaecc412 --- /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..5c7cb5de5 --- /dev/null +++ b/client/src/app/site/polls/components/poll-form/poll-form.component.ts @@ -0,0 +1,155 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { 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 { PercentBase } from 'app/shared/models/poll/base-poll'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { + MajorityMethodVerbose, + PercentBaseVerbose, + 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; + + /** + * The different methods for this poll. + */ + @Input() + public pollMethods: { [key: string]: string }; + + @Input() + public data: Partial; + + /** + * The different types the poll can accept. + */ + public pollTypes = PollTypeVerbose; + + /** + * The percent base for the poll. + */ + public percentBases: { [key: string]: string } = PercentBaseVerbose; + + /** + * The majority methods for the poll. + */ + public majorityMethods = MajorityMethodVerbose; + + /** + * 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][] = []; + + /** + * 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, + private pollService: PollService + ) { + super(title, translate, snackbar); + + this.contentForm = this.fb.group({ + title: ['', Validators.required], + type: ['', Validators.required], + pollmethod: ['', Validators.required], + onehundred_percent_base: ['', Validators.required], + majority_method: ['', Validators.required], + groups_id: [[]] + }); + } + + /** + * OnInit. + * Sets the observable for groups. + */ + public ngOnInit(): void { + this.groupObservable = this.groupRepo.getViewModelListObservable(); + + if (this.data) { + Object.keys(this.contentForm.controls).forEach(key => { + if (this.data[key]) { + this.contentForm.get(key).setValue(this.data[key]); + } + }); + } + this.updatePollValues(this.contentForm.value); + + this.subscriptions.push( + this.contentForm.valueChanges.subscribe(values => { + this.updatePollValues(values); + }) + ); + + // update the percent bases when the poll method changes + this.contentForm.get('pollmethod').valueChanges.subscribe(method => { + let forbiddenBases: string[]; + if (method === 'YN') { + forbiddenBases = [PercentBase.YNA, PercentBase.Votes]; + } else if (method === 'YNA') { + forbiddenBases = [PercentBase.Votes]; + } else if (method === 'votes') { + forbiddenBases = [PercentBase.YN, PercentBase.YNA]; + } + + this.percentBases = {}; + for (const [key, value] of Object.entries(PercentBaseVerbose)) { + if (!forbiddenBases.includes(key)) { + this.percentBases[key] = value; + } + } + }); + } + + 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 { + this.pollValues = Object.entries(data) + .filter(([key, _]) => key === 'type' || key === 'pollmethod') + .map(([key, value]) => [ + this.pollService.getVerboseNameForKey(key), + this.pollService.getVerboseNameForValue(key, value as string) + ]); + if (data.type === 'named') { + this.pollValues.push([ + this.pollService.getVerboseNameForKey('groups'), + this.groupRepo.getNameForIds(...data.groups_id) + ]); + } + } +} 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..1ea1802b4 --- /dev/null +++ b/client/src/app/site/polls/components/poll-list/poll-list.component.html @@ -0,0 +1,45 @@ + +
Poll list
+ + + +
+ + +
+ + {{ poll.title }} +
+
+ {{ poll.pollClassTypeVerbose }} +
+
+ {{ poll.stateVerbose }} +
+
+ 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..f91e113f6 --- /dev/null +++ b/client/src/app/site/polls/components/poll-list/poll-list.component.ts @@ -0,0 +1,52 @@ +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: '70px' + }, + { + 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); + } +} 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..6e3623e03 --- /dev/null +++ b/client/src/app/site/polls/models/view-base-poll.ts @@ -0,0 +1,111 @@ +import { ChartData } from 'app/shared/components/charts/charts.component'; +import { BasePoll, PollState } from 'app/shared/models/poll/base-poll'; +import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; +import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; +import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; +import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; +import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { ViewUser } from 'app/site/users/models/view-user'; + +export const PollClassTypeVerbose = { + motion: 'Motion poll', + assignment: 'Assignment poll' +}; + +export const PollStateVerbose = { + 1: 'Created', + 2: 'Started', + 3: 'Finished', + 4: 'Published' +}; + +export const PollTypeVerbose = { + analog: 'Analog', + named: 'Named', + pseudoanonymous: 'Pseudoanonymous' +}; + +export const PollPropertyVerbose = { + majority_method: 'Majority method', + onehundred_percent_base: '100% base', + type: 'Poll type', + pollmethod: 'Poll method', + state: 'State', + groups: 'Entitled to vote' +}; + +export const MajorityMethodVerbose = { + simple: 'Simple', + two_thirds: 'Two Thirds', + three_quarters: 'Three Quarters', + disabled: 'Disabled' +}; + +export const PercentBaseVerbose = { + YN: 'Yes/No', + YNA: 'Yes/No/Abstain', + votes: 'All votes', + valid: 'Valid votes', + cast: 'Cast votes', + disabled: 'Disabled' +}; + +export abstract class ViewBasePoll = any> extends BaseProjectableViewModel { + public get poll(): M { + return this._model; + } + + public get pollClassTypeVerbose(): string { + return PollClassTypeVerbose[this.pollClassType]; + } + + public get parentLink(): string { + return this.pollClassType === 'motion' + ? `/motions/${((this)).poll.motion_id}` + : `/assignments/${((this)).poll.assignment_id}/`; + } + + public get stateVerbose(): string { + return PollStateVerbose[this.state]; + } + + public get typeVerbose(): string { + return PollTypeVerbose[this.type]; + } + + public get majorityMethodVerbose(): string { + return MajorityMethodVerbose[this.majority_method]; + } + + public get percentBaseVerbose(): string { + return PercentBaseVerbose[this.onehundred_percent_base]; + } + + /** + * returns a mapping "verbose_state" -> "state_id" for all valid next states + */ + public get nextStates(): { [key: number]: string } { + const next_state = (this.state % Object.keys(PollStateVerbose).length) + 1; + const states = {}; + states[PollStateVerbose[next_state]] = next_state; + if (this.state === PollState.Finished) { + states[PollStateVerbose[PollState.Created]] = PollState.Created; + } + return states; + } + public abstract readonly pollClassType: 'motion' | 'assignment'; + + public canBeVotedFor: () => boolean; + + public abstract getSlide(): ProjectorElementBuildDeskriptor; + + public abstract generateChartData(): ChartData; +} + +export interface ViewBasePoll = any> extends BasePoll { + voted: ViewUser[]; + groups: ViewGroup[]; + options: ViewMotionOption[] | ViewAssignmentOption[]; // TODO find a better solution. but works for the moment +} 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..ddfc01c45 --- /dev/null +++ b/client/src/app/site/polls/polls.module.ts @@ -0,0 +1,16 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { PollListComponent } from './components/poll-list/poll-list.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], + declarations: [PollListComponent] +}) +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..cf113db14 --- /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') }, + { condition: PollState.Published, label: this.translate.instant('Published') } + ] + }, + { + property: 'canBeVotedFor', + label: this.translate.instant('Votability'), + options: [ + { condition: true, label: this.translate.instant('Can be voted for') }, + { condition: false, label: this.translate.instant('Cannot be voted for') } + ] + }, + { + property: 'user_has_voted', + label: this.translate.instant('Vote state'), + 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..94170ab48 --- /dev/null +++ b/client/src/app/site/polls/services/poll-list-observable.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Observable } from 'rxjs'; + +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 { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; +import { 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 + ) { + motionPollRepo + .getViewModelListObservable() + .subscribe(polls => this.adjustViewModelListObservable(polls, 'motion')); + assignmentPollRepo + .getViewModelListObservable() + .subscribe(polls => this.adjustViewModelListObservable(polls, 'assignment')); + } + + private adjustViewModelListObservable(polls: ViewBasePoll[], mode: 'motion' | 'assignment'): 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(); + } +} 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..79a0fb72b --- /dev/null +++ b/client/src/app/site/polls/services/poll.service.ts @@ -0,0 +1,313 @@ +import { Injectable } from '@angular/core'; + +import { _ } from 'app/core/translate/translation-marker'; +import { Collection } from 'app/shared/models/base/collection'; +import { MajorityMethod, PercentBase, PollType } from 'app/shared/models/poll/base-poll'; +import { AssignmentPollMethodsVerbose } 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'; + +/** + * 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 * 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 + } +]; + +interface OpenSlidesSettings { + ENABLE_ELECTRONIC_VOTING: 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: PercentBase; + + /** + * The default majority method + */ + public abstract defaultMajorityMethod: MajorityMethod; + + /** + * 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']; + + /** + * empty constructor + * + */ + public constructor(constants: ConstantsService) { + constants + .get('Settings') + .subscribe(settings => (this.isElectronicVotingEnabled = settings.ENABLE_ELECTRONIC_VOTING)); + } + + /** + * 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'; + return ''; + } + + /** + * Assigns the default poll data to the object. To be extended in subclasses + * @param poll the poll/object to fill + */ + public fillDefaultPollData(poll: Partial & Collection): void { + poll.onehundred_percent_base = this.defaultPercentBase; + poll.majority_method = this.defaultMajorityMethod; + poll.type = PollType.Analog; + } + + /** + * 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: ViewBasePoll, key: CalculablePollKey): number | null { + const baseNumber = this.getBaseAmount(poll); + if (!baseNumber) { + return null; + } + switch (key) { + case 'abstain': + if (poll.onehundred_percent_base === PercentBase.YN) { + return null; + } + break; + case 'votesinvalid': + if (poll.onehundred_percent_base !== PercentBase.Cast) { + return null; + } + break; + case 'votesvalid': + if (![PercentBase.Cast, PercentBase.Valid].includes(poll.onehundred_percent_base)) { + return null; + } + break; + case 'votescast': + if (poll.onehundred_percent_base !== PercentBase.Cast) { + return null; + } + } + return Math.round(((poll[key] * 100) / baseNumber) * 100) / 100; + } + + /** + * 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: ViewBasePoll): 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; + }*/ + return 0; + } + + /** + * 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: ViewBasePoll, 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; + } + + /** + * 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: ViewBasePoll, 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; + // } + // break; + // case 'YES_NO_ABSTAIN': + // if (['votescast', 'votesinvalid', 'votesvalid'].includes(value)) { + // return true; + // } + // break; + // case 'VALID': + // if (['votesinvalid', 'votescast'].includes(value)) { + // return true; + // } + // break; + // } + // if (poll[value] < 0) { + // return true; + // } + return false; + } + + 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 AssignmentPollMethodsVerbose[value]; + case 'type': + return PollTypeVerbose[value]; + } + } + + public getVerboseNameForKey(key: string): string { + return PollPropertyVerbose[key]; + } +} 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 -
+ : {"Y": , ["N": ], ["A": ] }}, - ["votesvalid": ], ["votesinvalid": ], ["votescast": ], - ["global_no": ], ["global_abstain": ] - } - All amounts are decimals as strings - - required fields per pollmethod: - - votes: Y - - YN: YN - - YNA: YNA - """ - 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"}) - self.parse_decimal_value(value.get("Y"), min_value=-2) - if poll.pollmethod in ( - AssignmentPoll.POLLMETHOD_YN, - AssignmentPoll.POLLMETHOD_YNA, - ): - self.parse_decimal_value(value.get("N"), min_value=-2) - if poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA: - self.parse_decimal_value(value.get("A"), min_value=-2) - - # Check and set votes* values here, because this might raise errors. - if "votesvalid" in data: - poll.votesvalid = self.parse_decimal_value(data["votesvalid"], min_value=-2) - if "votesinvalid" in data: - poll.votesinvalid = self.parse_decimal_value( - data["votesinvalid"], min_value=-2 - ) - if "votescast" in data: - poll.votescast = self.parse_decimal_value(data["votescast"], min_value=-2) + for field in ["votesvalid", "votesinvalid", "votescast"]: + setattr(poll, field, data[field]) global_no_enabled = ( poll.global_no and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES @@ -370,135 +334,190 @@ class AssignmentPollViewSet(BasePollViewSet): global_abstain_enabled = ( poll.global_abstain and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES ) - if "global_no" in data and global_no_enabled: - self.parse_decimal_value(data["votescast"], min_value=-2) - if "global_abstain" in data and global_abstain_enabled: - self.parse_decimal_value(data["votescast"], min_value=-2) options = poll.get_options() + options_data = data.get("options") - # Check, if all options were given - db_option_ids = set(option.id for option in 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() + + # Create votes for global no and global abstain + first_option = options.first() + if "global_no" in data and global_no_enabled: + vote_obj, _ = AssignmentVote.objects.get_or_create( + option=first_option, value="N" + ) + vote_obj.weight = data["votescast"] + vote_obj.save() + if "global_abstain" in data and global_abstain_enabled: + vote_obj, _ = AssignmentVote.objects.get_or_create( + option=first_option, value="A" + ) + vote_obj.weight = data["votescast"] + vote_obj.save() + poll.save() + + def validate_vote_data(self, data, poll): + """ + Request data: + analog: + { + "options": {: {"Y": , ["N": ], ["A": ] }}, + ["votesvalid": ], ["votesinvalid": ], ["votescast": ], + ["global_no": ], ["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 poll.votes_amount votes + + YN/YNA: + {: 'Y' | 'N' [|'A']} + - all option_ids must be given + - 'A' is only allowed in YNA pollmethod + + Votes for all options have to be given + """ + 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 ("global_no" in data and global_no_enabled) or ( + "global_abstain" in data and global_abstain_enabled + ): + data["votescast"] = self.parse_vote_value(data, "votescast") + + 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 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 have to give exactly {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 ( + poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA + and value not in ("Y", "N", "A",) + ): + raise ValidationError("Every value must be Y, N or A") + elif ( + poll.pollmethod == AssignmentPoll.POLLMETHOD_YN + and value not in ("Y", "N",) + ): + raise ValidationError("Every value must be Y or N") + + options_data = data + + # Check if all options were given + db_option_ids = set(option.id for option in poll.get_options()) data_option_ids = set(int(option_id) for option_id in options_data.keys()) if data_option_ids != db_option_ids: raise ValidationError( {"error": "You have to provide values for all options"} ) - # TODO: make this atomic - for option_id, vote in options_data.items(): - option = options.get(pk=int(option_id)) - Y = self.parse_decimal_value(vote["Y"], min_value=-2) - vote_obj, _ = AssignmentVote.objects.get_or_create(option=option, value="Y") - vote_obj.weight = Y - vote_obj.save() - - if poll.pollmethod in ( - AssignmentPoll.POLLMETHOD_YN, - AssignmentPoll.POLLMETHOD_YNA, - ): - N = self.parse_decimal_value(vote["N"], min_value=-2) - vote_obj, _ = AssignmentVote.objects.get_or_create( - option=option, value="N" - ) - vote_obj.weight = N - vote_obj.save() - - if poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA: - A = self.parse_decimal_value(vote["A"], min_value=-2) - vote_obj, _ = AssignmentVote.objects.get_or_create( - option=option, value="A" - ) - vote_obj.weight = A - vote_obj.save() - - # Create votes for global no and global abstain - first_option = options.first() - if "global_no" in data and global_no_enabled: - global_no = self.parse_decimal_value(data["votescast"], min_value=-2) - AssignmentVote.objects.create( - option=first_option, value="N", weight=global_no - ) - if "global_abstain" in data and global_abstain_enabled: - global_abstain = self.parse_decimal_value(data["votescast"], min_value=-2) - AssignmentVote.objects.create( - option=first_option, value="A", weight=global_abstain - ) - - poll.state = AssignmentPoll.STATE_FINISHED # directly stop the poll - poll.save() - - def validate_vote_data(self, data, poll): - 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 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 have to give exactly {0} votes", - "args": [poll.votes_amount], - } - ) - # Check, if all options are valid - db_option_ids = set(option.id for option in poll.get_options()) - data_option_ids = set(int(option_id) for option_id in data.keys()) - if len(data_option_ids - db_option_ids): - raise ValidationError({"error": "There are invalid option ids."}) - elif data == "N" and poll.global_no: - pass - elif data == "A" and poll.global_abstain: - pass - 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 poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA and value not in ( - "Y", - "N", - "A", - ): - raise ValidationError("Every value must be Y, N or A") - elif poll.pollmethod == AssignmentPoll.POLLMETHOD_YN and value not in ( - "Y", - "N", - ): - raise ValidationError("Every value must be Y or N") - - # Check, if all options were given - db_option_ids = set(option.id for option in poll.get_options()) - data_option_ids = set(int(option_id) for option_id in data.keys()) - if data_option_ids != db_option_ids: - raise ValidationError( - {"error": "You have to provide values for all options"} - ) - def create_votes(self, data, poll, user=None): """ Helper function for handle_(named|pseudoanonymous)_vote @@ -537,35 +556,36 @@ class AssignmentPollViewSet(BasePollViewSet): inform_changed_data(vote, no_delete_on_restriction=True) def handle_named_vote(self, data, poll, user): - """ - Request data for votes pollmethod: - {: } | '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 >= 1. - - 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 poll.votes_amount votes - - Request data for YN/YNA pollmethod: - {: 'Y' | 'N' [|'A']} - - all option_ids must be given - - 'A' is only allowed in YNA pollmethod - """ - self.validate_vote_data(data, poll) # Instead of reusing all existing votes for the user, delete all previous votes for vote in poll.get_votes().filter(user=user): vote.delete() self.create_votes(data, poll, user) def handle_pseudoanonymous_vote(self, data, poll): - """ - For request data see handle_named_vote - """ - self.validate_vote_data(data, poll) self.create_votes(data, poll) + 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() 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/motions/access_permissions.py b/openslides/motions/access_permissions.py index 797f81d25..6000b552d 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -2,6 +2,7 @@ import json from typing import Any, Dict, List from ..poll.access_permissions import ( + BaseOptionAccessPermissions, BasePollAccessPermissions, BaseVoteAccessPermissions, ) @@ -190,6 +191,11 @@ class MotionPollAccessPermissions(BasePollAccessPermissions): 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 ebd922a19..193c66c8f 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -23,6 +23,7 @@ class MotionsAppConfig(AppConfig): MotionVoteViewSet, MotionBlockViewSet, MotionPollViewSet, + MotionOptionViewSet, MotionChangeRecommendationViewSet, StateViewSet, WorkflowViewSet, @@ -67,6 +68,9 @@ 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 ) @@ -97,6 +101,7 @@ class MotionsAppConfig(AppConfig): "MotionChangeRecommendation", "MotionCommentSection", "MotionPoll", + "MotionOption", "MotionVote", ): yield self.get_model(model_name) diff --git a/openslides/motions/migrations/0034_voting_2.py b/openslides/motions/migrations/0034_voting_2.py index 7d1f8fb56..1fb620d86 100644 --- a/openslides/motions/migrations/0034_voting_2.py +++ b/openslides/motions/migrations/0034_voting_2.py @@ -77,8 +77,9 @@ def convert_votes(apps, schema_editor): "Abstain": "A", } for vote in MotionVote.objects.all(): - vote.value = value_map[vote.value] - vote.save(skip_autoupdate=True) + 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): diff --git a/openslides/motions/models.py b/openslides/motions/models.py index 05e58bfd0..dbc9b40f5 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -22,6 +22,7 @@ from .access_permissions import ( MotionBlockAccessPermissions, MotionChangeRecommendationAccessPermissions, MotionCommentSectionAccessPermissions, + MotionOptionAccessPermissions, MotionPollAccessPermissions, MotionVoteAccessPermissions, StateAccessPermissions, @@ -882,6 +883,7 @@ class MotionVote(RESTModelMixin, BaseVote): class MotionOption(RESTModelMixin, BaseOption): + access_permissions = MotionOptionAccessPermissions() vote_class = MotionVote poll = models.ForeignKey( @@ -891,9 +893,6 @@ class MotionOption(RESTModelMixin, BaseOption): class Meta: default_permissions = () - def get_root_rest_element(self): - return self.poll - class MotionPollManager(BaseManager): """ diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index 2a526abd7..8a4f7d40f 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -245,8 +245,6 @@ class MotionPollSerializer(BasePollSerializer): Serializer for motion.models.MotionPoll objects. """ - options = MotionOptionSerializer(many=True, read_only=True) - class Meta: model = MotionPoll fields = ("motion", "pollmethod") + BASE_POLL_FIELDS diff --git a/openslides/motions/views.py b/openslides/motions/views.py index db473cf78..0d1ef4987 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -9,7 +9,7 @@ from django.db.models.deletion import ProtectedError from django.http.request import QueryDict from rest_framework import status -from openslides.poll.views import BasePollViewSet, BaseVoteViewSet +from openslides.poll.views import BaseOptionViewSet, BasePollViewSet, BaseVoteViewSet from ..core.config import config from ..core.models import Tag @@ -41,6 +41,7 @@ from .models import ( MotionChangeRecommendation, MotionComment, MotionCommentSection, + MotionOption, MotionPoll, MotionVote, State, @@ -1115,6 +1116,8 @@ class MotionPollViewSet(BasePollViewSet): queryset = MotionPoll.objects.all() + required_analog_fields = ["Y", "N", "votescast", "votesvalid", "votesinvalid"] + def has_manage_permissions(self): """ Returns True if the user has required permissions. @@ -1166,62 +1169,62 @@ class MotionPollViewSet(BasePollViewSet): return result def handle_analog_vote(self, data, poll, user): - """ - Request data: - { "Y": , "N": , ["A": ], - ["votesvalid": ], ["votesinvalid": ], ["votescast": ]} - All amounts are decimals as strings - """ - if not isinstance(data, dict): - raise ValidationError({"detail": "Data must be a dict"}) - Y = self.parse_decimal_value(data.get("Y"), min_value=-2) - N = self.parse_decimal_value(data.get("N"), min_value=-2) - if poll.pollmethod == MotionPoll.POLLMETHOD_YNA: - A = self.parse_decimal_value(data.get("A"), min_value=-2) - option = poll.options.get() vote, _ = MotionVote.objects.get_or_create(option=option, value="Y") - vote.weight = Y + vote.weight = data["Y"] vote.save() vote, _ = MotionVote.objects.get_or_create(option=option, value="N") - vote.weight = 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 = A + vote.weight = data["A"] vote.save() + inform_changed_data(option) - if "votesvalid" in data: - poll.votesvalid = self.parse_decimal_value(data["votesvalid"], min_value=-2) - if "votesinvalid" in data: - poll.votesinvalid = self.parse_decimal_value( - data["votesinvalid"], min_value=-2 - ) - if "votescast" in data: - poll.votescast = self.parse_decimal_value(data["votescast"], min_value=-2) + for field in ["votesvalid", "votesinvalid", "votescast"]: + setattr(poll, field, data.get(field)) - poll.state = MotionPoll.STATE_FINISHED # directly stop the poll poll.save() def validate_vote_data(self, data, poll): - 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") + """ + 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") def handle_named_vote(self, data, poll, user): - self.validate_vote_data(data, poll) - option = poll.options.get() vote, _ = MotionVote.objects.get_or_create(user=user, option=option) self.set_vote_data(data, vote, poll) + inform_changed_data(option) def handle_pseudoanonymous_vote(self, data, poll): - self.validate_vote_data(data, poll) - option = poll.options.get() vote = MotionVote.objects.create(option=option) self.set_vote_data(data, vote, poll) + inform_changed_data(option) def set_vote_data(self, data, vote, poll): vote.value = data @@ -1229,6 +1232,13 @@ class MotionPollViewSet(BasePollViewSet): vote.save(no_delete_on_restriction=True) +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() diff --git a/openslides/poll/access_permissions.py b/openslides/poll/access_permissions.py index 86d1820e2..e95352585 100644 --- a/openslides/poll/access_permissions.py +++ b/openslides/poll/access_permissions.py @@ -23,6 +23,9 @@ class BasePollAccessPermissions(BaseAccessPermissions): - Remove voted_id field from the poll - Remove fields given in self.assitional_fields from the poll """ + # add hast_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 @@ -39,10 +42,6 @@ class BasePollAccessPermissions(BaseAccessPermissions): del poll["voted_id"] for field in self.additional_fields: del poll[field] - for option in poll["options"]: - del option["yes"] - del option["no"] - del option["abstain"] data.append(poll) return data @@ -69,3 +68,26 @@ class BaseVoteAccessPermissions(BaseAccessPermissions): 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 diff --git a/openslides/poll/models.py b/openslides/poll/models.py index 6415583a5..54377c4c6 100644 --- a/openslides/poll/models.py +++ b/openslides/poll/models.py @@ -5,7 +5,7 @@ from django.conf import settings from django.core.validators import MinValueValidator from django.db import models -from ..utils.autoupdate import inform_deleted_data +from ..utils.autoupdate import inform_changed_data, inform_deleted_data from ..utils.models import SET_NULL_AND_AUTOUPDATE @@ -32,9 +32,6 @@ class BaseVote(models.Model): class Meta: abstract = True - def get_root_rest_element(self): - return self.option.get_root_rest_element() - class BaseOption(models.Model): """ @@ -76,9 +73,6 @@ class BaseOption(models.Model): ) return cls.vote_class - def get_root_rest_element(self): - return self.poll.get_root_rest_element() - class BasePoll(models.Model): option_class: Optional[Type["BaseOption"]] = None @@ -249,6 +243,9 @@ class BasePoll(models.Model): collection = self.get_vote_class().get_collection_string() inform_deleted_data((collection, id) for id in votes_id) + # update options + inform_changed_data(self.get_options()) + # Reset state self.state = BasePoll.STATE_CREATED if self.type == self.TYPE_ANALOG: diff --git a/openslides/poll/serializers.py b/openslides/poll/serializers.py index fa30e8453..48dbaf833 100644 --- a/openslides/poll/serializers.py +++ b/openslides/poll/serializers.py @@ -22,7 +22,7 @@ class BaseVoteSerializer(ModelSerializer): return vote.option.poll.state -BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain") +BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain", "poll_id", "pollstate") class BaseOptionSerializer(ModelSerializer): @@ -32,6 +32,11 @@ class BaseOptionSerializer(ModelSerializer): 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", @@ -55,6 +60,7 @@ class BasePollSerializer(ModelSerializer): many=True, required=False, queryset=get_group_model().objects.all() ) voted = IdPrimaryKeyRelatedField(many=True, read_only=True) + options = IdPrimaryKeyRelatedField(many=True, read_only=True) votesvalid = DecimalField( max_digits=15, decimal_places=6, min_value=-2, read_only=True diff --git a/openslides/poll/views.py b/openslides/poll/views.py index be8d3c8d5..0f215cacb 100644 --- a/openslides/poll/views.py +++ b/openslides/poll/views.py @@ -1,4 +1,8 @@ +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 @@ -17,7 +21,12 @@ from .models import BasePoll class BasePollViewSet(ModelViewSet): - valid_update_keys = ["majority_method", "onehundred_percent_base"] + valid_update_keys = [ + "majority_method", + "onehundred_percent_base", + "title", + "description", + ] def check_view_permissions(self): """ @@ -29,13 +38,33 @@ class BasePollViewSet(ModelViewSet): 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 motion poll. + Customized view endpoint to update a poll. """ poll = self.get_object() @@ -50,12 +79,42 @@ class BasePollViewSet(ModelViewSet): if len(invalid_keys): raise ValidationError( { - "detail": f"The poll is not in the created state. You can only edit: {', '.join(self.valid_update_keys)}" + "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): + print(poll, poll.type, BasePoll.TYPE_ANALOG) + 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) + 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() @@ -94,6 +153,7 @@ class BasePollViewSet(ModelViewSet): 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"]) @@ -135,60 +195,101 @@ class BasePollViewSet(ModelViewSet): 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) + if poll.type == BasePoll.TYPE_ANALOG: - if not self.has_manage_permissions(): - self.permission_denied(request) - - if ( - poll.state != BasePoll.STATE_STARTED - and poll.state != BasePoll.STATE_FINISHED - ): - raise ValidationError( - {"detail": "You cannot vote for a poll in this state"} - ) - - self.handle_analog_vote(request.data, poll, request.user) - # special: change the poll state to finished. - poll.state = BasePoll.STATE_FINISHED + 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() - else: - if poll.state != BasePoll.STATE_STARTED: - raise ValidationError( - {"detail": "You cannot vote for an unstarted poll"} - ) + elif poll.type == BasePoll.TYPE_NAMED: + self.handle_named_vote(data, poll, request.user) + poll.voted.add(request.user) - if poll.type == BasePoll.TYPE_NAMED: - self.assert_can_vote(poll, request) - self.handle_named_vote(request.data, poll, request.user) - poll.voted.add(request.user) - - elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: - self.assert_can_vote(poll, request) - - if request.user in poll.voted.all(): - self.permission_denied(request) - self.handle_pseudoanonymous_vote(request.data, poll) - poll.voted.add(request.user) + elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: + self.handle_pseudoanonymous_vote(data, poll) + poll.voted.add(request.user) inform_changed_data(poll) # needed for the changed voted relation return Response() def assert_can_vote(self, poll, request): """ - Raises a permission denied, if the user is not in a poll group - and present + 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 + Only pseudoanonymous: has to not have voted yet """ - if not request.user.is_present or not in_some_groups( - request.user.id, poll.groups.all(), exact=True - ): - self.permission_denied(request) + 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, poll.groups.all(), exact=True + ): + self.permission_denied(request) - def parse_decimal_value(self, value, min_value=None): - """ Raises a ValidationError on incorrect values """ - field = DecimalField(min_value=min_value, max_digits=15, decimal_places=6) - return field.to_internal_value(value) + if poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: + if request.user in poll.voted.all(): + 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): + """ + 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 + """ + raise NotImplementedError() + + def handle_pseudoanonymous_vote(self, data, poll): + """ + To be implemented by subclass. Handles the pseudoanonymous vote. Assumes data is validated + """ + raise NotImplementedError() class BaseVoteViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): pass + + +class BaseOptionViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + pass diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index cc8fc0fe9..193fd8461 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -96,7 +96,7 @@ class CreateAssignmentPoll(TestCase): self.assignment.add_candidate(self.admin) def test_simple(self): - with self.assertNumQueries(35): + with self.assertNumQueries(41): response = self.client.post( reverse("assignmentpoll-list"), { @@ -108,7 +108,7 @@ class CreateAssignmentPoll(TestCase): "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) self.assertTrue(AssignmentPoll.objects.exists()) poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo") @@ -144,7 +144,7 @@ class CreateAssignmentPoll(TestCase): "description": "test_description_ieM8ThuasoSh8aecai8p", }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) self.assertTrue(AssignmentPoll.objects.exists()) poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_ahThai4pae1pi4xoogoo") @@ -169,7 +169,7 @@ class CreateAssignmentPoll(TestCase): "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) def test_missing_keys(self): @@ -188,7 +188,7 @@ class CreateAssignmentPoll(TestCase): if _key != key } response = self.client.post(reverse("assignmentpoll-list"), request_data) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) def test_with_groups(self): @@ -206,7 +206,7 @@ class CreateAssignmentPoll(TestCase): "groups_id": [1, 2], }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + 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()) @@ -224,7 +224,7 @@ class CreateAssignmentPoll(TestCase): "groups_id": [], }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) poll = AssignmentPoll.objects.get() self.assertFalse(poll.groups.exists()) @@ -240,7 +240,7 @@ class CreateAssignmentPoll(TestCase): "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) def test_not_allowed_type(self): @@ -256,7 +256,7 @@ class CreateAssignmentPoll(TestCase): "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) setattr(settings, "ENABLE_ELECTRONIC_VOTING", True) @@ -272,7 +272,7 @@ class CreateAssignmentPoll(TestCase): "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) def test_not_supported_onehundred_percent_base(self): @@ -287,7 +287,7 @@ class CreateAssignmentPoll(TestCase): "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) def test_not_supported_majority_method(self): @@ -302,7 +302,7 @@ class CreateAssignmentPoll(TestCase): "majority_method": "invalid majority method", }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) def test_wrong_pollmethod_onehundred_percent_base_combination_1(self): @@ -317,7 +317,7 @@ class CreateAssignmentPoll(TestCase): "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) poll = AssignmentPoll.objects.get() self.assertEqual(poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_YNA) @@ -333,7 +333,7 @@ class CreateAssignmentPoll(TestCase): "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) poll = AssignmentPoll.objects.get() self.assertEqual(poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_YN) @@ -349,12 +349,128 @@ class CreateAssignmentPoll(TestCase): "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + 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): """ @@ -383,7 +499,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"title": "test_title_Aishohh1ohd0aiSut7gi"}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_Aishohh1ohd0aiSut7gi") @@ -394,7 +510,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"assignment_id": assignment.id}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.assignment.id, self.assignment.id) # unchanged @@ -403,7 +519,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"pollmethod": AssignmentPoll.POLLMETHOD_YNA}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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) @@ -413,7 +529,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"pollmethod": "invalid"}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = AssignmentPoll.objects.get() self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_VOTES) @@ -421,7 +537,7 @@ class UpdateAssignmentPoll(TestCase): response = self.client.patch( reverse("assignmentpoll-detail", args=[self.poll.pk]), {"type": "analog"} ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.type, "analog") @@ -429,7 +545,7 @@ class UpdateAssignmentPoll(TestCase): response = self.client.patch( reverse("assignmentpoll-detail", args=[self.poll.pk]), {"type": "invalid"} ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = AssignmentPoll.objects.get() self.assertEqual(poll.type, "named") @@ -439,16 +555,16 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"type": BasePoll.TYPE_NAMED}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = AssignmentPoll.objects.get() self.assertEqual(poll.type, BasePoll.TYPE_NAMED) - setattr(settings, "ENABLE_ELECTRONIC_VOTING", False) + 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.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertFalse(poll.groups.exists()) @@ -458,28 +574,39 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"groups_id": [group2.id]}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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_wrong_state(self): + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() - self.assertEqual(poll.title, "test_title_beeFaihuNae1vej2ai8m") + 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.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.onehundred_percent_base, "cast") @@ -488,7 +615,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"onehundred_percent_base": "invalid"}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = AssignmentPoll.objects.get() self.assertEqual( poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_VOTES @@ -499,7 +626,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.majority_method, AssignmentPoll.MAJORITY_TWO_THIRDS) @@ -508,7 +635,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"majority_method": "invalid majority method"}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = AssignmentPoll.objects.get() self.assertEqual(poll.majority_method, AssignmentPoll.MAJORITY_SIMPLE) @@ -524,7 +651,7 @@ class UpdateAssignmentPoll(TestCase): "votes_amount": 42, }, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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) @@ -542,7 +669,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"majority_method": "two_thirds"}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.majority_method, "two_thirds") @@ -553,7 +680,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"onehundred_percent_base": "cast"}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.onehundred_percent_base, "cast") @@ -565,7 +692,7 @@ class UpdateAssignmentPoll(TestCase): reverse("assignmentpoll-detail", args=[self.poll.pk]), {"onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.onehundred_percent_base, "YN") @@ -608,7 +735,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-start", args=[self.poll.pk]) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.votesvalid, None) @@ -619,7 +746,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): def test_stop_poll(self): self.start_poll() response = self.client.post(reverse("assignmentpoll-stop", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(self.poll.state, AssignmentPoll.STATE_STARTED) def test_vote(self): @@ -634,14 +761,15 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): }, "votesvalid": "4.64", "votesinvalid": "-2", + "votescast": "-2", }, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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, None) + 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) @@ -652,6 +780,20 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): 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( @@ -663,7 +805,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): } }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_too_few_options(self): @@ -673,7 +815,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": {"1": {"Y": "1", "N": "2.35", "A": "-1"}}}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_options(self): @@ -688,25 +830,25 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): } }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_data_format(self): @@ -714,7 +856,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), [1, 2, 5], ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_option_format(self): @@ -723,7 +865,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": [1, "string"]}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_option_id_type(self): @@ -732,7 +874,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": {"string": "some_other_string"}}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_vote_data(self): @@ -741,7 +883,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": {"1": [None]}}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_missing_vote_value(self): @@ -752,7 +894,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), data ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_vote_state_finished(self): @@ -777,7 +919,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): "votescast": "3", }, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.votesvalid, Decimal("4.64")) self.assertEqual(poll.votesinvalid, Decimal("-2")) @@ -802,7 +944,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-start", args=[self.poll.pk]) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.votesvalid, Decimal("0")) @@ -819,7 +961,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": "Y", "2": "N", "3": "A"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertEqual(AssignmentVote.objects.count(), 3) poll = AssignmentPoll.objects.get() self.assertEqual(poll.votesvalid, Decimal("1")) @@ -851,7 +993,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": "N"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertEqual(AssignmentVote.objects.count(), 1) vote = AssignmentVote.objects.get() self.assertEqual(vote.value, "N") @@ -863,7 +1005,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": "Y", "2": "N"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_too_few_options(self): @@ -874,7 +1016,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_options(self): @@ -885,7 +1027,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": "Y", "3": "N"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_no_permissions(self): @@ -896,7 +1038,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) def test_anonymous(self): @@ -907,7 +1049,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) def test_vote_not_present(self): @@ -919,18 +1061,18 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_data_format(self): @@ -940,7 +1082,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): [1, 2, 5], format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_option_format(self): @@ -950,7 +1092,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": "string"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_option_id_type(self): @@ -960,7 +1102,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"id": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_vote_data(self): @@ -970,7 +1112,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): {"1": [None]}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -993,7 +1135,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-start", args=[self.poll.pk]) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.votesvalid, Decimal("0")) @@ -1009,7 +1151,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): {"1": 1, "2": 0}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertEqual(AssignmentVote.objects.count(), 1) poll = AssignmentPoll.objects.get() self.assertEqual(poll.votesvalid, Decimal("1")) @@ -1029,12 +1171,16 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.add_candidate() self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 1, "2": 0}, + format="json", ) response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 0, "2": 1}, + format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() option1 = poll.options.get(pk=1) option2 = poll.options.get(pk=2) @@ -1052,7 +1198,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), "N" ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() option = poll.options.get(pk=1) self.assertEqual(option.yes, Decimal("0")) @@ -1068,7 +1214,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), "N" ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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) @@ -1079,7 +1225,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), "A" ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() option = poll.options.get(pk=1) self.assertEqual(option.yes, Decimal("0")) @@ -1095,7 +1241,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), "A" ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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) @@ -1106,7 +1252,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): {"1": -1}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_multiple_votes(self): @@ -1117,7 +1263,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): {"1": 2, "2": 1}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() option1 = poll.options.get(pk=1) option2 = poll.options.get(pk=2) @@ -1136,7 +1282,18 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): {"1": 2, "2": 2}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_missing_option(self): + self.add_candidate() + 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_too_many_options(self): @@ -1147,7 +1304,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): {"1": 1, "2": 1, "3": 1}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_options(self): @@ -1155,7 +1312,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_no_permissions(self): @@ -1164,7 +1321,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) def test_anonymous(self): @@ -1173,7 +1330,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): response = gclient.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) def test_vote_not_present(self): @@ -1183,20 +1340,20 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_data_format(self): @@ -1206,7 +1363,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): [1, 2, 5], format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_option_format(self): @@ -1216,7 +1373,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): {"1": "string"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_option_id_type(self): @@ -1226,7 +1383,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): {"id": 1}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_vote_data(self): @@ -1236,7 +1393,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): {"1": [None]}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -1253,7 +1410,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-start", args=[self.poll.pk]) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.votesvalid, Decimal("0")) @@ -1270,7 +1427,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": "Y", "2": "N", "3": "A"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertEqual(AssignmentVote.objects.count(), 3) poll = AssignmentPoll.objects.get() self.assertEqual(poll.votesvalid, Decimal("1")) @@ -1302,7 +1459,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": "N"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) poll = AssignmentPoll.objects.get() option1 = poll.options.get(pk=1) self.assertEqual(option1.yes, Decimal("1")) @@ -1316,7 +1473,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": "Y", "2": "N"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_too_few_options(self): @@ -1327,7 +1484,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_options(self): @@ -1338,7 +1495,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": "Y", "3": "N"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_no_permissions(self): @@ -1349,7 +1506,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) def test_anonymous(self): @@ -1360,7 +1517,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) def test_vote_not_present(self): @@ -1372,18 +1529,18 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_data_format(self): @@ -1393,7 +1550,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): [1, 2, 5], format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_option_format(self): @@ -1403,7 +1560,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": "string"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_option_id_type(self): @@ -1413,7 +1570,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"id": "Y"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_vote_data(self): @@ -1423,7 +1580,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): {"1": [None]}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -1446,7 +1603,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-start", args=[self.poll.pk]) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.votesvalid, Decimal("0")) @@ -1462,7 +1619,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): {"1": 1, "2": 0}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertEqual(AssignmentVote.objects.count(), 1) poll = AssignmentPoll.objects.get() self.assertEqual(poll.votesvalid, Decimal("1")) @@ -1482,12 +1639,16 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.add_candidate() self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 1, "2": 0}, + format="json", ) response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"1": 0, "2": 1}, + format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) poll = AssignmentPoll.objects.get() option1 = poll.options.get(pk=1) option2 = poll.options.get(pk=2) @@ -1505,7 +1666,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): {"1": -1}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_multiple_votes(self): @@ -1516,7 +1677,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): {"1": 2, "2": 1}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() option1 = poll.options.get(pk=1) option2 = poll.options.get(pk=2) @@ -1535,7 +1696,18 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): {"1": 2, "2": 2}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + + def test_missing_option(self): + self.add_candidate() + 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_too_many_options(self): @@ -1546,7 +1718,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): {"1": 1, "2": 1, "3": 1}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_options(self): @@ -1554,7 +1726,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_no_permissions(self): @@ -1563,7 +1735,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) def test_anonymous(self): @@ -1572,7 +1744,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): response = gclient.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) def test_vote_not_present(self): @@ -1582,20 +1754,20 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_data_format(self): @@ -1605,7 +1777,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): [1, 2, 5], format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_option_format(self): @@ -1615,7 +1787,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): {"1": "string"}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_option_id_type(self): @@ -1625,7 +1797,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): {"id": 1}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_vote_data(self): @@ -1635,7 +1807,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): {"1": [None]}, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -1688,7 +1860,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) response = self.user_client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": "A"} ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() vote = AssignmentVote.objects.get() @@ -1706,16 +1878,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "amount_global_no": None, "groups_id": [GROUP_DELEGATE_PK], "id": 1, - "options": [ - { - "abstain": "1.000000", - "id": 1, - "no": "0.000000", - "user_id": self.admin.id, - "weight": 1, - "yes": "0.000000", - } - ], + "options_id": [1], "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "state": AssignmentPoll.STATE_STARTED, "title": self.poll.title, @@ -1723,6 +1886,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "type": AssignmentPoll.TYPE_NAMED, "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, + "user_has_voted": False, "voted_id": [self.user.id], "votes_amount": 1, "votescast": "1.000000", @@ -1775,8 +1939,9 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "title": self.poll.title, "description": self.description, "groups_id": [GROUP_DELEGATE_PK], - "options": [{"id": 1, "user_id": self.admin.id, "weight": 1}], + "options_id": [1], "id": 1, + "user_has_voted": user == self.user, "votes_amount": 1, }, ) @@ -1785,6 +1950,78 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) 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", + "user_has_voted": user == self.user, + "voted_id": [self.user.id], + "votes_amount": 1, + "votescast": "1.000000", + "votesinvalid": "0.000000", + "votesvalid": "1.000000", + }, + "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 @@ -1795,7 +2032,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( response = self.user_client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": "A"} ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() vote = AssignmentVote.objects.get() @@ -1812,16 +2049,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "amount_global_no": None, "groups_id": [GROUP_DELEGATE_PK], "id": 1, - "options": [ - { - "abstain": "1.000000", - "id": 1, - "no": "0.000000", - "user_id": self.admin.id, - "weight": 1, - "yes": "0.000000", - } - ], + "options_id": [1], "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "state": AssignmentPoll.STATE_STARTED, "title": self.poll.title, @@ -1829,6 +2057,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, + "user_has_voted": False, "voted_id": [self.user.id], "votes_amount": 1, "votescast": "1.000000", @@ -1867,11 +2096,84 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "title": self.poll.title, "description": self.description, "groups_id": [GROUP_DELEGATE_PK], - "options": [{"id": 1, "user_id": self.admin.id, "weight": 1}], + "options_id": [1], "id": 1, + "user_has_voted": user == self.user, "votes_amount": 1, }, ) 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, + "user_has_voted": user == self.user, + "voted_id": [self.user.id], + "votes_amount": 1, + "votescast": "1.000000", + "votesinvalid": "0.000000", + "votesvalid": "1.000000", + }, + "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/motions/test_polls.py b/tests/integration/motions/test_polls.py index 6e9c60340..7971efb74 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -78,9 +78,7 @@ class CreateMotionPoll(TestCase): Tests creating polls of motions. """ - def setUp(self): - self.client = APIClient() - self.client.login(username="admin", password="admin") + def advancedSetUp(self): self.motion = Motion( title="test_title_Aiqueigh2dae9phabiqu", text="test_text_Neekoh3zou6li5rue8iL", @@ -99,7 +97,7 @@ class CreateMotionPoll(TestCase): "majority_method": "simple", }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) self.assertTrue(MotionPoll.objects.exists()) poll = MotionPoll.objects.get() self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo") @@ -108,6 +106,43 @@ class CreateMotionPoll(TestCase): self.assertEqual(poll.motion.id, self.motion.id) self.assertTrue(poll.options.exists()) + 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": [], + "user_has_voted": False, + "votesvalid": "0.000000", + "votesinvalid": "0.000000", + "votescast": "0.000000", + "options_id": [1], + "voted_id": [], + "id": 1, + }, + ) + self.assertEqual(autoupdate[1], []) + def test_missing_keys(self): complete_request_data = { "title": "test_title_OoCh9aitaeyaeth8nom1", @@ -124,7 +159,7 @@ class CreateMotionPoll(TestCase): if _key != key } response = self.client.post(reverse("motionpoll-list"), request_data) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.exists()) def test_with_groups(self): @@ -142,7 +177,7 @@ class CreateMotionPoll(TestCase): "groups_id": [1, 2], }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + 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()) @@ -160,7 +195,7 @@ class CreateMotionPoll(TestCase): "groups_id": [], }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) poll = MotionPoll.objects.get() self.assertFalse(poll.groups.exists()) @@ -176,7 +211,7 @@ class CreateMotionPoll(TestCase): "majority_method": MotionPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.exists()) def test_not_allowed_type(self): @@ -192,7 +227,7 @@ class CreateMotionPoll(TestCase): "majority_method": MotionPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.exists()) setattr(settings, "ENABLE_ELECTRONIC_VOTING", True) @@ -208,9 +243,94 @@ class CreateMotionPoll(TestCase): "majority_method": MotionPoll.MAJORITY_SIMPLE, }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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): """ @@ -242,7 +362,7 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"title": "test_title_Aishohh1ohd0aiSut7gi"}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.title, "test_title_Aishohh1ohd0aiSut7gi") @@ -255,7 +375,7 @@ class UpdateMotionPoll(TestCase): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), {"motion_id": motion.id} ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.motion.id, self.motion.id) # unchanged @@ -263,7 +383,7 @@ class UpdateMotionPoll(TestCase): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), {"pollmethod": "YN"} ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.pollmethod, "YN") self.assertEqual(poll.onehundred_percent_base, "YN") @@ -272,7 +392,7 @@ class UpdateMotionPoll(TestCase): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), {"pollmethod": "invalid"} ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = MotionPoll.objects.get() self.assertEqual(poll.pollmethod, "YNA") @@ -280,7 +400,7 @@ class UpdateMotionPoll(TestCase): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), {"type": "analog"} ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.type, "analog") @@ -288,7 +408,7 @@ class UpdateMotionPoll(TestCase): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), {"type": "invalid"} ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = MotionPoll.objects.get() self.assertEqual(poll.type, "named") @@ -298,7 +418,7 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"type": BasePoll.TYPE_NAMED}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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) @@ -308,7 +428,7 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"onehundred_percent_base": "cast"}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.onehundred_percent_base, "cast") @@ -317,7 +437,7 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"onehundred_percent_base": "invalid"}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = MotionPoll.objects.get() self.assertEqual(poll.onehundred_percent_base, "YN") @@ -326,7 +446,7 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"majority_method": "two_thirds"}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.majority_method, "two_thirds") @@ -335,7 +455,7 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"majority_method": "invalid majority method"}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = MotionPoll.objects.get() self.assertEqual(poll.majority_method, "simple") @@ -343,7 +463,7 @@ class UpdateMotionPoll(TestCase): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), {"groups_id": []}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertFalse(poll.groups.exists()) @@ -353,21 +473,32 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"groups_id": [group2.id]}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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]), - {"title": "test_title_Oophah8EaLaequu3toh8"}, + {"type": BasePoll.TYPE_NAMED}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = MotionPoll.objects.get() - self.assertEqual(poll.title, "test_title_beeFaihuNae1vej2ai8m") + self.assertEqual(poll.type, BasePoll.TYPE_NAMED) def test_patch_majority_method_state_not_created(self): self.poll.state = 2 @@ -376,7 +507,7 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"majority_method": "two_thirds"}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.majority_method, "two_thirds") @@ -387,7 +518,7 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"onehundred_percent_base": "cast"}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.onehundred_percent_base, "cast") @@ -399,7 +530,7 @@ class UpdateMotionPoll(TestCase): reverse("motionpoll-detail", args=[self.poll.pk]), {"onehundred_percent_base": MotionPoll.PERCENT_BASE_YNA}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.onehundred_percent_base, "YN") @@ -433,7 +564,7 @@ class VoteMotionPollAnalog(TestCase): def test_start_poll(self): response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.state, MotionPoll.STATE_STARTED) self.assertEqual(poll.votesvalid, None) @@ -444,7 +575,7 @@ class VoteMotionPollAnalog(TestCase): def test_stop_poll(self): self.start_poll() response = self.client.post(reverse("motionpoll-stop", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(self.poll.state, MotionPoll.STATE_STARTED) def test_vote(self): @@ -457,13 +588,14 @@ class VoteMotionPollAnalog(TestCase): "A": "-1", "votesvalid": "4.64", "votesinvalid": "-2", + "votescast": "-2", }, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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, None) + self.assertEqual(poll.votescast, Decimal("-2")) self.assertEqual(poll.get_votes().count(), 3) self.assertEqual(poll.state, MotionPoll.STATE_FINISHED) option = poll.options.get() @@ -476,7 +608,7 @@ class VoteMotionPollAnalog(TestCase): self.start_poll() self.make_admin_delegate() response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_missing_data(self): @@ -484,7 +616,7 @@ class VoteMotionPollAnalog(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), {"Y": "4", "N": "22.6"}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_wrong_data_format(self): @@ -492,7 +624,7 @@ class VoteMotionPollAnalog(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_wrong_vote_data(self): @@ -501,7 +633,7 @@ class VoteMotionPollAnalog(TestCase): reverse("motionpoll-vote", args=[self.poll.pk]), {"Y": "some string", "N": "-2", "A": "3"}, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_state_finished(self): @@ -530,7 +662,7 @@ class VoteMotionPollAnalog(TestCase): "votescast": "3", }, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.votesvalid, Decimal("4.64")) self.assertEqual(poll.votesinvalid, Decimal("-2")) @@ -577,7 +709,7 @@ class VoteMotionPollNamed(TestCase): def test_start_poll(self): response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.state, MotionPoll.STATE_STARTED) self.assertEqual(poll.votesvalid, Decimal("0")) @@ -592,7 +724,7 @@ class VoteMotionPollNamed(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesinvalid, Decimal("0")) @@ -613,11 +745,11 @@ class VoteMotionPollNamed(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "A" ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesinvalid, Decimal("0")) @@ -639,7 +771,7 @@ class VoteMotionPollNamed(TestCase): response = guest_client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "Y" ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) # TODO: Move to unit tests @@ -655,21 +787,21 @@ class VoteMotionPollNamed(TestCase): self.make_admin_present() self.make_admin_delegate() response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_missing_data(self): @@ -677,7 +809,7 @@ class VoteMotionPollNamed(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_wrong_data_format(self): @@ -687,7 +819,7 @@ class VoteMotionPollNamed(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -708,12 +840,12 @@ class VoteMotionPollNamedAutoupdates(TestCase): self.other_user, _ = self.create_user() inform_changed_data(self.other_user) - self.user1, user1_password = self.create_user() - self.user1.groups.add(self.delegate_group) - self.user1.is_present = True - self.user1.save() - self.user1_client = APIClient() - self.user1_client.login(username=self.user1.username, password=user1_password) + 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, @@ -728,10 +860,10 @@ class VoteMotionPollNamedAutoupdates(TestCase): self.poll.groups.add(self.delegate_group) def test_vote(self): - response = self.user1_client.post( + response = self.user_client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "A" ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() vote = MotionVote.objects.get() @@ -749,18 +881,12 @@ class VoteMotionPollNamedAutoupdates(TestCase): "onehundred_percent_base": "YN", "majority_method": "simple", "groups_id": [GROUP_DELEGATE_PK], + "user_has_voted": False, "votesvalid": "1.000000", "votesinvalid": "0.000000", "votescast": "1.000000", - "options": [ - { - "id": 1, - "yes": "0.000000", - "no": "0.000000", - "abstain": "1.000000", - } - ], - "voted_id": [self.user1.id], + "options_id": [1], + "voted_id": [self.user.id], "id": 1, }, "motions/motion-vote:1": { @@ -768,15 +894,23 @@ class VoteMotionPollNamedAutoupdates(TestCase): "id": 1, "weight": "1.000000", "value": "A", - "user_id": self.user1.id, + "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.user1) + autoupdate = self.get_last_autoupdate(user=self.user) self.assertEqual( autoupdate[0]["motions/motion-vote:1"], { @@ -785,13 +919,17 @@ class VoteMotionPollNamedAutoupdates(TestCase): "id": 1, "weight": "1.000000", "value": "A", - "user_id": self.user1.id, + "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.user1, self.other_user): + for user in (self.user, self.other_user): self.assertAutoupdate(poll, user=user) autoupdate = self.get_last_autoupdate(user=user) self.assertEqual( @@ -805,10 +943,15 @@ class VoteMotionPollNamedAutoupdates(TestCase): "onehundred_percent_base": "YN", "majority_method": "simple", "groups_id": [GROUP_DELEGATE_PK], - "options": [{"id": 1}], + "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}, + ) # Other users should not get a vote autoupdate self.assertNoAutoupdate(vote, user=self.other_user) @@ -855,7 +998,7 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): response = self.user_client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "A" ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() vote = MotionVote.objects.get() @@ -873,17 +1016,11 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "onehundred_percent_base": "YN", "majority_method": "simple", "groups_id": [GROUP_DELEGATE_PK], + "user_has_voted": False, "votesvalid": "1.000000", "votesinvalid": "0.000000", "votescast": "1.000000", - "options": [ - { - "id": 1, - "yes": "0.000000", - "no": "0.000000", - "abstain": "1.000000", - } - ], + "options_id": [1], "voted_id": [self.user.id], "id": 1, }, @@ -895,6 +1032,14 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "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], []) @@ -915,8 +1060,9 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "onehundred_percent_base": "YN", "majority_method": "simple", "groups_id": [GROUP_DELEGATE_PK], - "options": [{"id": 1}], + "options_id": [1], "id": 1, + "user_has_voted": user == self.user, }, ) @@ -959,7 +1105,7 @@ class VoteMotionPollPseudoanonymous(TestCase): def test_start_poll(self): response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.state, MotionPoll.STATE_STARTED) self.assertEqual(poll.votesvalid, Decimal("0")) @@ -974,7 +1120,7 @@ class VoteMotionPollPseudoanonymous(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesinvalid, Decimal("0")) @@ -996,11 +1142,11 @@ class VoteMotionPollPseudoanonymous(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "A" ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) option = MotionPoll.objects.get().options.get() self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.no, Decimal("1")) @@ -1016,28 +1162,28 @@ class VoteMotionPollPseudoanonymous(TestCase): response = guest_client.post( reverse("motionpoll-vote", args=[self.poll.pk]), "Y" ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + 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.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_missing_data(self): @@ -1045,7 +1191,7 @@ class VoteMotionPollPseudoanonymous(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_wrong_data_format(self): @@ -1055,7 +1201,7 @@ class VoteMotionPollPseudoanonymous(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -1080,12 +1226,12 @@ class StopMotionPoll(TestCase): self.poll.state = MotionPoll.STATE_STARTED self.poll.save() response = self.client.post(reverse("motionpoll-stop", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(MotionPoll.objects.get().state, MotionPoll.STATE_CREATED) @@ -1115,7 +1261,7 @@ class PublishMotionPoll(TestCase): self.poll.state = MotionPoll.STATE_FINISHED self.poll.save() response = self.client.post(reverse("motionpoll-publish", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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 @@ -1133,17 +1279,11 @@ class PublishMotionPoll(TestCase): "onehundred_percent_base": "YN", "majority_method": "simple", "groups_id": [], + "user_has_voted": False, "votesvalid": "0.000000", "votesinvalid": "0.000000", "votescast": "0.000000", - "options": [ - { - "id": 1, - "yes": "0.000000", - "no": "2.000000", - "abstain": "0.000000", - } - ], + "options_id": [1], "voted_id": [], "id": 1, }, @@ -1155,13 +1295,21 @@ class PublishMotionPoll(TestCase): "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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(MotionPoll.objects.get().state, MotionPoll.STATE_CREATED) @@ -1198,7 +1346,7 @@ class PseudoanonymizeMotionPoll(TestCase): response = self.client.post( reverse("motionpoll-pseudoanonymize", args=[self.poll.pk]) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.get_votes().count(), 2) self.assertEqual(poll.count_users_voted(), 2) @@ -1220,7 +1368,7 @@ class PseudoanonymizeMotionPoll(TestCase): response = self.client.post( reverse("motionpoll-pseudoanonymize", args=[self.poll.pk]) ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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()) @@ -1231,7 +1379,7 @@ class PseudoanonymizeMotionPoll(TestCase): response = self.client.post( reverse("motionpoll-pseudoanonymize", args=[self.poll.pk]) ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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()) @@ -1266,7 +1414,7 @@ class ResetMotionPoll(TestCase): def test_reset_poll(self): response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() self.assertEqual(poll.get_votes().count(), 0) self.assertEqual(poll.count_users_voted(), 0) @@ -1281,7 +1429,10 @@ class ResetMotionPoll(TestCase): def test_deleted_autoupdate(self): response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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) @@ -1290,7 +1441,7 @@ class ResetMotionPoll(TestCase): self.poll.state = MotionPoll.STATE_STARTED self.poll.save() response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk])) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = MotionPoll.objects.get() self.assertTrue(poll.get_votes().exists()) self.assertEqual(poll.count_users_voted(), 2) diff --git a/tests/test_case.py b/tests/test_case.py index 6ec35208a..6dd7b3266 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -15,6 +15,8 @@ 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() @@ -79,6 +81,11 @@ class TestCase(_TestCase): 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 """ From fff1f15b6c0adfb95d76723c9733c0aa3357f3e3 Mon Sep 17 00:00:00 2001 From: GabrielMeyer Date: Wed, 15 Jan 2020 15:12:33 +0100 Subject: [PATCH 15/55] Polls for motions and assignments - Adds charts to assignments - Creates base-classes for polls --- .../components/banner/banner.component.html | 3 +- .../components/banner/banner.component.scss | 3 +- .../banner/banner.component.scss-theme.scss | 11 + .../components/charts/charts.component.html | 44 ++-- .../components/charts/charts.component.ts | 99 ++++++-- .../assignment-detail.component.ts | 1 + .../assignment-poll-detail.component.html | 51 +++- .../assignment-poll-detail.component.ts | 29 ++- .../assignment-poll-vote.component.html | 19 +- .../assignment-poll.component.html | 223 ++---------------- .../assignment-poll.component.ts | 27 ++- .../models/view-assignment-poll.ts | 48 +++- .../site/motions/models/view-motion-poll.ts | 23 +- .../motion-detail.component.html | 6 +- .../motion-poll-detail.component.html | 65 +++-- .../motion-poll-detail.component.scss | 7 +- .../motion-poll-detail.component.ts | 44 +++- .../motion-poll-dialog.component.html | 6 +- .../motion-poll-dialog.component.ts | 17 +- .../motion-poll/motion-poll.component.html | 17 +- .../motion-poll/motion-poll.component.ts | 58 ++++- .../motions/services/motion-poll.service.ts | 2 +- .../components/base-poll-detail.component.ts | 59 +++-- .../polls/components/base-poll.component.ts | 34 ++- .../poll-form/poll-form.component.html | 9 +- .../poll-form/poll-form.component.ts | 33 ++- .../app/site/polls/models/view-base-poll.ts | 16 ++ client/src/styles.scss | 2 + 28 files changed, 570 insertions(+), 386 deletions(-) create mode 100644 client/src/app/shared/components/banner/banner.component.scss-theme.scss diff --git a/client/src/app/shared/components/banner/banner.component.html b/client/src/app/shared/components/banner/banner.component.html index 6b86d697b..9511f1074 100644 --- a/client/src/app/shared/components/banner/banner.component.html +++ b/client/src/app/shared/components/banner/banner.component.html @@ -1,4 +1,5 @@ - diff --git a/client/src/app/shared/components/charts/charts.component.ts b/client/src/app/shared/components/charts/charts.component.ts index 5aefc5421..a9030fa6e 100644 --- a/client/src/app/shared/components/charts/charts.component.ts +++ b/client/src/app/shared/components/charts/charts.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core'; import { MatSnackBar } from '@angular/material'; import { Title } from '@angular/platform-browser'; @@ -12,7 +12,7 @@ import { BaseViewComponent } from 'app/site/base/base-view'; /** * The different supported chart-types. */ -export type ChartType = 'line' | 'bar' | 'pie' | 'doughnut' | 'horizontalBar'; +export type ChartType = 'line' | 'bar' | 'pie' | 'doughnut' | 'horizontalBar' | 'stackedBar'; /** * Describes the events the chart is fired, when hovering or clicking on it. @@ -30,6 +30,8 @@ export interface ChartDate { label: string; backgroundColor?: string; hoverBackgroundColor?: string; + barThickness?: number; + maxBarThickness?: number; } /** @@ -37,6 +39,8 @@ export interface ChartDate { */ export type ChartData = ChartDate[]; +export type ChartLegendSize = 'small' | 'middle'; + /** * Wrapper for the chart-library. * @@ -45,7 +49,8 @@ export type ChartData = ChartDate[]; @Component({ selector: 'os-charts', templateUrl: './charts.component.html', - styleUrls: ['./charts.component.scss'] + styleUrls: ['./charts.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class ChartsComponent extends BaseViewComponent { /** @@ -57,15 +62,22 @@ export class ChartsComponent extends BaseViewComponent { 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); - this.circleColors = [ + const circleColors = [ { - backgroundColor: data.map(date => date.backgroundColor), - hoverBackgroundColor: data.map(date => date.hoverBackgroundColor) + 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.checkChartType(); + this.cd.detectChanges(); }) ); } @@ -75,16 +87,20 @@ export class ChartsComponent extends BaseViewComponent { */ @Input() public set type(type: ChartType) { - if (type === 'horizontalBar') { - this.setupHorizontalBar(); - } - this._type = type; + this.checkChartType(type); + 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. */ @@ -147,11 +163,12 @@ export class ChartsComponent extends BaseViewComponent { responsive: true, legend: { position: 'top', - labels: { - fontSize: 14 - } + labels: {} + }, + scales: { + xAxes: [{ ticks: { beginAtZero: true } }], + yAxes: [{ ticks: { beginAtZero: true } }] }, - scales: { xAxes: [{}], yAxes: [{ ticks: { beginAtZero: true } }] }, plugins: { datalabels: { anchor: 'end', @@ -165,6 +182,8 @@ export class ChartsComponent extends BaseViewComponent { */ private _type: ChartType = 'bar'; + private _chartLegendSize: ChartLegendSize = 'middle'; + /** * Constructor. * @@ -173,17 +192,63 @@ export class ChartsComponent extends BaseViewComponent { * @param matSnackbar * @param cd */ - public constructor(title: Title, protected translate: TranslateService, matSnackbar: MatSnackBar) { + public constructor( + title: Title, + protected translate: TranslateService, + matSnackbar: MatSnackBar, + private cd: ChangeDetectorRef + ) { super(title, translate, matSnackbar); } /** - * Changes the chart-options, if the `horizontalBar` is used. + * Changes the chart-options, if the `stackedBar` is used. */ - private setupHorizontalBar(): void { + private setupStackedBar(): void { this.chartOptions.scales = Object.assign(this.chartOptions.scales, { xAxes: [{ stacked: true }], yAxes: [{ stacked: true }] }); } + + 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 + }; + break; + } + this.cd.detectChanges(); + } + + private checkChartType(chartType?: ChartType): void { + let type = chartType || this._type; + if (type === 'stackedBar') { + this.setupStackedBar(); + this.setupBar(); + type = 'horizontalBar'; + } + // if (type === 'bar' || type === 'horizontalBar') { + // this.setupBar(); + // } + this._type = type; + } } 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 5d8e60068..2e329c3d4 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 @@ -374,6 +374,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 }); } }) ); diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html index dd78ffb71..7fcc3ce2c 100644 --- a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html +++ b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html @@ -1,7 +1,7 @@ - +

{{ poll.title }}

@@ -42,23 +42,62 @@

Result

-
+
{{ option.user.full_name }}
-
{{ "Unknown user" | translate}}
+
{{ 'Unknown user' | translate }}
{{ obj.value.user.full_name }}
-
{{ "Unknown user" | translate}}
+
{{ 'Unknown user' | translate }}
-
{{ obj.value.votes[option.user_id]}}
+
{{ obj.value.votes[option.user_id] }}
+
+ + + {{ 'Candidates' | translate }} + {{ row.user }} + + + {{ 'Yes' | translate }} + {{ row.yes }} + + + + {{ 'No' | translate }} + {{ row.no }} + + + + {{ 'Abstain' | translate }} + {{ row.abstain }} + + + + {{ 'Quorum' | translate }} + + + + + + +
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 index 29d7ed222..384e47093 100644 --- 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 @@ -5,9 +5,11 @@ import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; +import { OperatorService } from 'app/core/core-services/operator.service'; import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-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 { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component'; import { ViewUser } from 'app/site/users/models/view-user'; @@ -21,8 +23,24 @@ import { ViewAssignmentVote } from '../../models/view-assignment-vote'; styleUrls: ['./assignment-poll-detail.component.scss'] }) export class AssignmentPollDetailComponent extends BasePollDetailComponent { + public isReady = false; + + public candidatesLabels: string[] = []; + public votesByUser: { [key: number]: { user: ViewUser; votes: { [key: number]: ViewAssignmentVote } } }; + public get chartType(): ChartType { + return 'horizontalBar'; + } + + public get columnDefinition(): string[] { + const columns = ['user', 'yes', 'no', 'quorum']; + if ((this.poll).pollmethod === AssignmentPollMethods.YNA) { + columns.splice(3, 0, 'abstain'); + } + return columns; + } + public constructor( title: Title, translate: TranslateService, @@ -31,7 +49,8 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent - {{ "You can distribute" | translate }} {{ poll.votes_amount }} {{ "votes" | translate }}. + {{ 'You can distribute' | translate }} {{ poll.votes_amount }} {{ 'votes' | translate }}.
{{ option.user.getFullName() }} No user {{ option.candidate_id }}
- +
- ({{ "Current" | translate }}: {{ getCurrentVoteVerbose(option.user_id) | translate }}) + ({{ 'Current' | translate }}: {{ getCurrentVoteVerbose(option.user_id) | translate }})
@@ -31,12 +33,17 @@ - +
-
@@ -44,4 +51,4 @@ {{ vmanager.getVotePermissionErrorVerbose(poll) | translate }} -
\ No newline at end of file + 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 b0a7fe7c8..f1543a869 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 @@ -3,7 +3,7 @@ -
@@ -40,200 +29,38 @@
-
- - - {{ poll.stateVerbose }} - - - -
+
+
+
+ + + {{ poll.stateVerbose }} + +
-

- - {{ poll.title }} - -

+

+ + {{ poll.title }} + +

+
+
+ +
+
- -
-
-
-
-
Candidates
-
Votes
-
-
- Quorum -
-
- - - {{ majorityChoice.display_name | 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) }} %) - -
-
-
-
-
-

Candidates

-
- {{ option.user.getFullName() }} - No user {{ option.candidate_id }} -
-
-
- - - - - -
- - - + - 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 f2b98c86e..b85b9326c 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, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -9,7 +9,10 @@ import { TranslateService } from '@ngx-translate/core'; import { OperatorService } from 'app/core/core-services/operator.service'; import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; +import { ChartType } from 'app/shared/components/charts/charts.component'; +import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; +import { PollService } from 'app/site/polls/services/poll.service'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { ViewAssignmentOption } from '../../models/view-assignment-option'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; @@ -24,6 +27,27 @@ import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; encapsulation: ViewEncapsulation.None }) export class AssignmentPollComponent extends BasePollComponent implements OnInit { + @Input() + public set poll(value: ViewAssignmentPoll) { + this.initPoll(value); + this.candidatesLabels = value.initChartLabels(); + const chartData = + value.pollmethod === AssignmentPollMethods.Votes + ? value.generateCircleChartData() + : value.generateChartData(); + this.chartDataSubject.next(chartData); + } + + public get poll(): ViewAssignmentPoll { + return this._poll; + } + + public get chartType(): ChartType { + return this.poll && this.poll.pollmethod === AssignmentPollMethods.Votes ? 'doughnut' : 'horizontalBar'; + } + + public candidatesLabels: string[] = []; + /** * Form for updating the poll's description */ @@ -58,6 +82,7 @@ export class AssignmentPollComponent extends BasePollComponent implements public static COLLECTIONSTRING = AssignmentPoll.COLLECTIONSTRING; protected _collectionString = AssignmentPoll.COLLECTIONSTRING; + public readonly tableChartData: Map> = new Map(); public readonly pollClassType: 'assignment' | 'motion' = 'assignment'; + public get pollmethodVerbose(): string { + return AssignmentPollMethodsVerbose[this.pollmethod]; + } + public getSlide(): ProjectorElementBuildDeskriptor { // TODO: update to new voting system? return { @@ -36,13 +44,43 @@ export class ViewAssignmentPoll extends ViewBasePoll implements }; } - public get pollmethodVerbose(): string { - return AssignmentPollMethodsVerbose[this.pollmethod]; + public initChartLabels(): string[] { + return this.options.map(candidate => candidate.user.full_name); } - // TODO public generateChartData(): ChartData { - return []; + const fields = ['yes', 'no']; + if (this.pollmethod === AssignmentPollMethods.YNA) { + fields.push('abstain'); + } + const data: ChartData = fields.map(key => ({ + label: key.toUpperCase(), + data: this.options.map(vote => vote[key]), + backgroundColor: PollColor[key], + hoverBackgroundColor: PollColor[key] + })); + return data; + } + + public generateCircleChartData(): ChartData { + const data: ChartData = this.options.map(candidate => ({ + label: candidate.user.getFullName(), + data: [candidate.yes] + })); + return data; + } + + public generateTableData(): {}[] { + const data = this.options + .map(candidate => ({ + yes: candidate.yes, + no: candidate.no, + abstain: candidate.abstain, + user: candidate.user.full_name + })) + .sort((a, b) => b.yes - a.yes); + + return data; } } diff --git a/client/src/app/site/motions/models/view-motion-poll.ts b/client/src/app/site/motions/models/view-motion-poll.ts index af28f82bd..01e465380 100644 --- a/client/src/app/site/motions/models/view-motion-poll.ts +++ b/client/src/app/site/motions/models/view-motion-poll.ts @@ -20,6 +20,20 @@ export class ViewMotionPoll extends ViewBasePoll implements MotionPo public readonly pollClassType: 'assignment' | 'motion' = 'motion'; + private tableKeys = ['yes', 'no', 'abstain']; + private voteKeys = ['votesvalid', 'votesinvalid', 'votescast']; + + public initChartLabels(): string[] { + return ['Votes']; + } + + public generateTableData(): {}[] { + let tableData = this.options.flatMap(vote => this.tableKeys.map(key => ({ key: key, value: vote[key] }))); + tableData.push(...this.voteKeys.map(key => ({ key: key, value: this[key] }))); + tableData = tableData.map(entry => (entry.value >= 0 ? entry : { key: entry.key, value: null })); + return tableData; + } + public generateChartData(): ChartData { const fields = ['yes', 'no']; if (this.pollmethod === MotionPollMethods.YNA) { @@ -27,18 +41,11 @@ export class ViewMotionPoll extends ViewBasePoll implements MotionPo } const data: ChartData = fields.map(key => ({ label: key.toUpperCase(), - data: [this.options[0][key]], + data: this.options.map(option => option[key]), backgroundColor: PollColor[key], hoverBackgroundColor: PollColor[key] })); - data.push({ - label: 'Votes invalid', - data: [this.votesinvalid], - backgroundColor: PollColor.votesinvalid, - hoverBackgroundColor: PollColor.votesinvalid - }); - return data; } 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 2a8239e4a..433dcee23 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 @@ -459,17 +459,13 @@
+
- -
diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html index 1282f7e56..6280bd024 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html @@ -1,13 +1,13 @@
-

{{ poll.title }}

+

{{ motionTitle }}

- - +
+ + +
@@ -45,7 +50,7 @@ close : {{ voteNo }}
-
+
@@ -62,11 +67,7 @@ - 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 index 7f65317dd..383c5c2f6 100644 --- 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 @@ -1,10 +1,12 @@ import { Component, Input } from '@angular/core'; import { MatDialog, MatSnackBar } from '@angular/material'; import { Title } from '@angular/platform-browser'; +import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject } from 'rxjs'; +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 { ChartData } from 'app/shared/components/charts/charts.component'; @@ -28,7 +30,7 @@ export class MotionPollComponent extends BasePollComponent { */ @Input() public set poll(value: ViewMotionPoll) { - this._poll = value; + this.initPoll(value); const chartData = this.poll.generateChartData(); for (const data of chartData) { @@ -54,17 +56,33 @@ export class MotionPollComponent extends BasePollComponent { /** * Number of votes for `Yes`. */ - public voteYes = 0; + // public voteYes = 0; + public set voteYes(n: number | string) { + this._voteYes = n; + } + + public get voteYes(): number | string { + return this.verboseForNumber(this._voteYes as number); + } /** * Number of votes for `No`. */ - public voteNo = 0; + public set voteNo(n: number | string) { + this._voteNo = n; + } - /** - * The motion-poll. - */ - private _poll: ViewMotionPoll; + public get voteNo(): number | string { + return this.verboseForNumber(this._voteNo as number); + } + + public get showChart(): boolean { + return this._voteYes >= 0 && this._voteNo >= 0; + } + + private _voteNo: number | string = 0; + + private _voteYes: number | string = 0; /** * Constructor. @@ -81,10 +99,30 @@ export class MotionPollComponent extends BasePollComponent { translate: TranslateService, dialog: MatDialog, promptService: PromptService, - public repo: MotionPollRepositoryService, + public pollRepo: MotionPollRepositoryService, pollDialog: MotionPollDialogService, - public pollService: PollService + public pollService: PollService, + private router: Router, + private operator: OperatorService ) { - super(titleService, matSnackBar, translate, dialog, promptService, repo, pollDialog); + super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog); + } + + public openPoll(): void { + if (this.operator.hasPerms('motions.can_manage_polls')) { + this.router.navigate(['motions', 'polls', this.poll.id]); + } + } + + private verboseForNumber(input: number): number | string { + input = Math.trunc(input); + switch (input) { + case -1: + return 'Majority'; + case -2: + return 'Not documented'; + default: + return input; + } } } 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 fb5f19dd9..895dee8df 100644 --- a/client/src/app/site/motions/services/motion-poll.service.ts +++ b/client/src/app/site/motions/services/motion-poll.service.ts @@ -52,7 +52,7 @@ export class MotionPollService extends PollService { const length = this.pollRepo.getViewModelList().filter(item => item.motion_id === poll.motion_id).length; poll.title = !length ? this.translate.instant('Vote') : `${this.translate.instant('Vote')} (${length + 1})`; - poll.pollmethod = MotionPollMethods.YN; + poll.pollmethod = MotionPollMethods.YNA; poll.motion_id = poll.motion_id; } } 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 index 1972118f5..e71c2d794 100644 --- a/client/src/app/site/polls/components/base-poll-detail.component.ts +++ b/client/src/app/site/polls/components/base-poll-detail.component.ts @@ -11,14 +11,14 @@ import { GroupRepositoryService } from 'app/core/repositories/users/group-reposi import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; import { Breadcrumb } from 'app/shared/components/breadcrumb/breadcrumb.component'; -import { ChartData } from 'app/shared/components/charts/charts.component'; -import { PollState } from 'app/shared/models/poll/base-poll'; +import { ChartData, ChartType } 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 { ViewGroup } from 'app/site/users/models/view-group'; import { BasePollRepositoryService } from '../services/base-poll-repository.service'; import { ViewBasePoll } from '../models/view-base-poll'; -export class BasePollDetailComponent extends BaseViewComponent implements OnInit { +export abstract class BasePollDetailComponent extends BaseViewComponent implements OnInit { /** * All the groups of users. */ @@ -42,7 +42,8 @@ export class BasePollDetailComponent extends BaseViewCom /** * Sets the type of the shown chart, if votes are entered. */ - public chartType = 'horizontalBar'; + // public chartType = 'horizontalBar'; + public abstract get chartType(): ChartType; /** * The different labels for the votes (used for chart). @@ -99,7 +100,7 @@ export class BasePollDetailComponent extends BaseViewCom const text = 'Do you really want to delete the selected poll?'; if (await this.promptDialog.open(title, text)) { - await this.repo.delete(this.poll); + this.repo.delete(this.poll).then(() => this.onDeleted()); } } @@ -121,12 +122,23 @@ export class BasePollDetailComponent extends BaseViewCom this.chartDataSubject.next(this.poll.generateChartData()); } + protected onDeleted(): void {} + + /** + * 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; + /** * This checks, if the poll has votes. */ private checkData(): void { if (this.poll.state === 3 || this.poll.state === 4) { - // this.chartDataSubject.next(this.poll.generateChartData()); + setTimeout(() => this.chartDataSubject.next(this.poll.generateChartData())); } } @@ -142,7 +154,6 @@ export class BasePollDetailComponent extends BaseViewCom this.poll = poll; this.updateBreadcrumbs(); this.checkData(); - this.labels = this.createChartLabels(); this.onPollLoaded(); } }) @@ -157,23 +168,18 @@ export class BasePollDetailComponent extends BaseViewCom this.pollDialog.openDialog(this.poll); } - /** - * Called after the poll has been loaded. Meant to be overwritten by subclasses who need initial access to the poll - */ - public onPollLoaded(): void {} - /** * Action for the different breadcrumbs. */ private async changeState(): Promise { - this.actionWrapper(this.repo.changePollState(this.poll)); + this.actionWrapper(this.repo.changePollState(this.poll), this.onStateChanged); } /** * Resets the state of a motion-poll. */ private async resetState(): Promise { - this.actionWrapper(this.repo.resetPoll(this.poll)); + this.actionWrapper(this.repo.resetPoll(this.poll), this.onStateChanged); } /** @@ -183,17 +189,15 @@ export class BasePollDetailComponent extends BaseViewCom * * @returns Any promise-like. */ - private actionWrapper(action: Promise): any { - action.then(() => this.checkData()).catch(this.raiseError); - } - - /** - * Function to create the labels for the chart. - * - * @returns An array of `Label`. - */ - private createChartLabels(): Label[] { - return ['Number of votes']; + private actionWrapper(action: Promise, callback?: () => any): any { + action + .then(() => { + this.checkData(); + if (callback) { + callback(); + } + }) + .catch(this.raiseError); } /** @@ -220,11 +224,14 @@ export class BasePollDetailComponent extends BaseViewCom if (!this.poll) { return null; } + if (!this.hasPerms()) { + return null; + } switch (this.poll.state) { case PollState.Created: return state === 2 ? () => this.changeState() : null; case PollState.Started: - return null; + return this.poll.type !== PollType.Analog && state === 3 ? () => this.changeState() : null; case PollState.Finished: if (state === 1) { return () => this.resetState(); diff --git a/client/src/app/site/polls/components/base-poll.component.ts b/client/src/app/site/polls/components/base-poll.component.ts index 533fda44a..bb98620a6 100644 --- a/client/src/app/site/polls/components/base-poll.component.ts +++ b/client/src/app/site/polls/components/base-poll.component.ts @@ -1,23 +1,28 @@ -import { Input } from '@angular/core'; 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 } 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 class BasePollComponent extends BaseViewComponent { - /** - * The poll represented in this component - */ - @Input() - public poll: V; +export abstract class BasePollComponent extends BaseViewComponent { + // /** + // * The poll represented in this component + // */ + // @Input() + // public abstract set poll(model: V); + + public chartDataSubject: BehaviorSubject = new BehaviorSubject([]); + + protected _poll: V; public constructor( titleService: Title, @@ -25,14 +30,14 @@ export class BasePollComponent extends BaseViewComponent public translate: TranslateService, public dialog: MatDialog, protected promptService: PromptService, - public repo: BasePollRepositoryService, + protected repo: BasePollRepositoryService, protected pollDialog: BasePollDialogService ) { super(titleService, translate, matSnackBar); } public changeState(key: PollState): void { - key === PollState.Created ? this.repo.resetPoll(this.poll) : this.repo.changePollState(this.poll); + key === PollState.Created ? this.repo.resetPoll(this._poll) : this.repo.changePollState(this._poll); } /** @@ -41,7 +46,7 @@ export class BasePollComponent extends BaseViewComponent public async onDeletePoll(): Promise { const title = this.translate.instant('Are you sure you want to delete this poll?'); if (await this.promptService.open(title)) { - await this.repo.delete(this.poll).catch(this.raiseError); + await this.repo.delete(this._poll).catch(this.raiseError); } } @@ -49,6 +54,13 @@ export class BasePollComponent extends BaseViewComponent * Edits the poll */ public openDialog(): void { - this.pollDialog.openDialog(this.poll); + 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 index b57d11d21..53667e448 100644 --- 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 @@ -47,13 +47,9 @@ - + - + {{ option.value | translate }} @@ -68,4 +64,3 @@
- \ No newline at end of file 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 index 5c7cb5de5..d975844da 100644 --- 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 @@ -63,6 +63,12 @@ export class PollFormComponent extends BaseViewComponent implements OnInit { */ public pollValues: [string, unknown][] = []; + /** + * Model for the checkbox. + * If true, the given poll will immediately be published. + */ + public publishImmediately = true; + /** * Constructor. Retrieves necessary metadata from the pollService, * injects the poll itself @@ -73,18 +79,10 @@ export class PollFormComponent extends BaseViewComponent implements OnInit { snackbar: MatSnackBar, private fb: FormBuilder, private groupRepo: GroupRepositoryService, - private pollService: PollService + public pollService: PollService ) { super(title, translate, snackbar); - - this.contentForm = this.fb.group({ - title: ['', Validators.required], - type: ['', Validators.required], - pollmethod: ['', Validators.required], - onehundred_percent_base: ['', Validators.required], - majority_method: ['', Validators.required], - groups_id: [[]] - }); + this.initContentForm(); } /** @@ -133,6 +131,10 @@ export class PollFormComponent extends BaseViewComponent implements OnInit { return { ...this.data, ...this.contentForm.value }; } + public isValidPercentBaseWithMethod(base: PercentBase): boolean { + return !(base === PercentBase.YNA && this.contentForm.get('pollmethod').value === 'YN'); + } + /** * This updates the poll-values to get correct data in the view. * @@ -152,4 +154,15 @@ export class PollFormComponent extends BaseViewComponent implements OnInit { ]); } } + + 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], + groups_id: [[]] + }); + } } diff --git a/client/src/app/site/polls/models/view-base-poll.ts b/client/src/app/site/polls/models/view-base-poll.ts index 6e3623e03..ec10da991 100644 --- a/client/src/app/site/polls/models/view-base-poll.ts +++ b/client/src/app/site/polls/models/view-base-poll.ts @@ -53,6 +53,15 @@ export const PercentBaseVerbose = { }; export abstract class ViewBasePoll = any> extends BaseProjectableViewModel { + private _tableData: {}[] = []; + + public get tableData(): {}[] { + if (!this._tableData.length) { + this._tableData = this.generateTableData(); + } + return this._tableData; + } + public get poll(): M { return this._model; } @@ -101,7 +110,14 @@ export abstract class ViewBasePoll = any> extends Bas public abstract getSlide(): ProjectorElementBuildDeskriptor; + /** + * Initializes labels for a chart. + */ + public abstract initChartLabels(): string[]; + public abstract generateChartData(): ChartData; + + public abstract generateTableData(): {}[]; } export interface ViewBasePoll = any> extends BasePoll { diff --git a/client/src/styles.scss b/client/src/styles.scss index 8cff8d348..d221bde56 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -27,6 +27,7 @@ @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'; /** fonts */ @import './assets/styles/fonts.scss'; @@ -54,6 +55,7 @@ $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); } /** Load projector specific SCSS values */ From e67ca77ad168fd2a2a1247c5851126d5f40bff15 Mon Sep 17 00:00:00 2001 From: Joshua Sangmeister Date: Tue, 21 Jan 2020 09:33:33 +0100 Subject: [PATCH 16/55] default motion poll method set, changed permission from 'can_manage_metadata' to 'can_manage' --- openslides/motions/views.py | 11 ++++++++++- tests/integration/motions/test_polls.py | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 0d1ef4987..8b5e5fc78 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -1123,9 +1123,18 @@ class MotionPollViewSet(BasePollViewSet): 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: diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index 7971efb74..c0da78417 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -106,6 +106,22 @@ class CreateMotionPoll(TestCase): 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"), @@ -147,7 +163,6 @@ class CreateMotionPoll(TestCase): complete_request_data = { "title": "test_title_OoCh9aitaeyaeth8nom1", "type": "named", - "pollmethod": "YNA", "motion_id": self.motion.id, "onehundred_percent_base": "YN", "majority_method": "simple", From 7ab5346198765dec8047d131159bf19d5dc62990 Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Tue, 21 Jan 2020 09:48:26 +0100 Subject: [PATCH 17/55] disable caching for reverse relations --- .../core-services/relation-manager.service.ts | 11 ++++++++++ .../search-value-selector.component.ts | 20 +++++++++++-------- .../assignment-poll-dialog.component.ts | 5 ----- ...tor_size_1.py => 0027_projector_size_1.py} | 2 +- ...tor_size_2.py => 0028_projector_size_2.py} | 2 +- ...tor_size_3.py => 0029_projector_size_3.py} | 2 +- openslides/core/models.py | 1 + openslides/utils/consumers.py | 1 - tests/integration/users/test_viewset.py | 2 +- 9 files changed, 28 insertions(+), 18 deletions(-) rename openslides/core/migrations/{0026_projector_size_1.py => 0027_projector_size_1.py} (91%) rename openslides/core/migrations/{0027_projector_size_2.py => 0028_projector_size_2.py} (96%) rename openslides/core/migrations/{0028_projector_size_3.py => 0029_projector_size_3.py} (85%) 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 53e86b96b..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; 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 c746607bd..6275ebb61 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 @@ -78,14 +78,18 @@ export class SearchValueSelectorComponent extends BaseFormControlComponent { - this.selectableItems = items; - if (this.contentForm) { - this.disabled = !items || (!!items && !items.length); - } - }) - ); + if (Array.isArray(value)) { + this.selectableItems = value; + } else { + this.subscriptions.push( + value.pipe(auditTime(10)).subscribe(items => { + this.selectableItems = items; + if (this.contentForm) { + this.disabled = !items || (!!items && !items.length); + } + }) + ); + } } public searchValue: FormControl; 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 ead48430a..25d645230 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 @@ -27,11 +27,6 @@ type OptionsObject = { user_id: number; user: ViewUser }[]; styleUrls: ['./assignment-poll-dialog.component.scss'] }) export class AssignmentPollDialogComponent extends BasePollDialogComponent implements OnInit { - /** - * The actual poll data to work on - */ - public poll: AssignmentPoll; - /** * The summary values that will have fields in the dialog */ diff --git a/openslides/core/migrations/0026_projector_size_1.py b/openslides/core/migrations/0027_projector_size_1.py similarity index 91% rename from openslides/core/migrations/0026_projector_size_1.py rename to openslides/core/migrations/0027_projector_size_1.py index 07d1cec47..24eebbb4a 100644 --- a/openslides/core/migrations/0026_projector_size_1.py +++ b/openslides/core/migrations/0027_projector_size_1.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("core", "0025_projector_color"), + ("core", "0026_remove_history_restricted"), ] operations = [ diff --git a/openslides/core/migrations/0027_projector_size_2.py b/openslides/core/migrations/0028_projector_size_2.py similarity index 96% rename from openslides/core/migrations/0027_projector_size_2.py rename to openslides/core/migrations/0028_projector_size_2.py index 7186e39c9..b201296cd 100644 --- a/openslides/core/migrations/0027_projector_size_2.py +++ b/openslides/core/migrations/0028_projector_size_2.py @@ -37,7 +37,7 @@ def calculate_aspect_ratios(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("core", "0026_projector_size_1"), + ("core", "0027_projector_size_1"), ] operations = [ diff --git a/openslides/core/migrations/0028_projector_size_3.py b/openslides/core/migrations/0029_projector_size_3.py similarity index 85% rename from openslides/core/migrations/0028_projector_size_3.py rename to openslides/core/migrations/0029_projector_size_3.py index 81b623232..570d34696 100644 --- a/openslides/core/migrations/0028_projector_size_3.py +++ b/openslides/core/migrations/0029_projector_size_3.py @@ -6,7 +6,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ("core", "0027_projector_size_2"), + ("core", "0028_projector_size_2"), ] operations = [ diff --git a/openslides/core/models.py b/openslides/core/models.py index ae108d570..e192d3955 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -8,6 +8,7 @@ from jsonfield import JSONField 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 diff --git a/openslides/utils/consumers.py b/openslides/utils/consumers.py index 3ad1029af..1073f831d 100644 --- a/openslides/utils/consumers.py +++ b/openslides/utils/consumers.py @@ -9,7 +9,6 @@ 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 diff --git a/tests/integration/users/test_viewset.py b/tests/integration/users/test_viewset.py index 1a0ddb6b8..8af4588fa 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -134,7 +134,7 @@ class UserCreate(TestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) 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"}) From 604df9d48bdce00432fe636ad4e0b60089653243 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 22 Jan 2020 11:57:51 +0100 Subject: [PATCH 18/55] Enhance voting ux --- .../app/core/ui-services/voting.service.ts | 3 + .../breadcrumb/breadcrumb.component.html | 25 ++--- .../breadcrumb/breadcrumb.component.scss | 27 +---- .../breadcrumb/breadcrumb.component.ts | 16 +-- .../components/charts/charts.component.html | 1 + .../components/charts/charts.component.ts | 7 ++ .../check-input/check-input.component.html | 1 + .../src/app/shared/models/poll/base-poll.ts | 7 ++ .../assignment-poll-detail.component.html | 2 +- .../site/motions/models/view-motion-poll.ts | 4 + .../motion-poll-detail.component.html | 100 +++++++++--------- .../motion-poll-detail.component.scss | 42 ++++++-- .../motion-poll-detail.component.ts | 7 +- .../motion-poll-dialog.component.html | 9 +- .../motion-poll-vote.component.html | 35 +++--- .../motion-poll-vote.component.scss | 18 ++++ .../motion-poll-vote.component.ts | 48 +++++++-- .../motion-poll/motion-poll.component.html | 75 ++++++------- .../motion-poll/motion-poll.component.scss | 29 ++--- .../motion-poll/motion-poll.component.ts | 1 - .../polls/components/base-poll.component.ts | 6 -- .../poll-form/poll-form.component.html | 2 +- 22 files changed, 249 insertions(+), 216 deletions(-) diff --git a/client/src/app/core/ui-services/voting.service.ts b/client/src/app/core/ui-services/voting.service.ts index a413a10c8..772af1597 100644 --- a/client/src/app/core/ui-services/voting.service.ts +++ b/client/src/app/core/ui-services/voting.service.ts @@ -13,6 +13,9 @@ export enum VotingError { USER_HAS_VOTED } +/** + * 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.", diff --git a/client/src/app/shared/components/breadcrumb/breadcrumb.component.html b/client/src/app/shared/components/breadcrumb/breadcrumb.component.html index d9c21bde4..67c604bb5 100644 --- a/client/src/app/shared/components/breadcrumb/breadcrumb.component.html +++ b/client/src/app/shared/components/breadcrumb/breadcrumb.component.html @@ -1,17 +1,14 @@ - + + +
diff --git a/client/src/app/shared/components/breadcrumb/breadcrumb.component.scss b/client/src/app/shared/components/breadcrumb/breadcrumb.component.scss index a7e5f50a3..a7ccf127e 100644 --- a/client/src/app/shared/components/breadcrumb/breadcrumb.component.scss +++ b/client/src/app/shared/components/breadcrumb/breadcrumb.component.scss @@ -1,25 +1,4 @@ -$breadcrumb-content: var(--breadcrumb-content); - -.breadcrumb-list { - display: flex; - flex-wrap: wrap; - list-style: none; -} - -.breadcrumb { - & + & { - padding-left: 8px; - &::before { - padding-right: 8px; - content: $breadcrumb-content; - } - } - - span.has-action { - cursor: pointer; - } - - &.active { - color: inherit; - } +.active-breadcrumb { + // Theme + color: rgba($color: #317796, $alpha: 1); } diff --git a/client/src/app/shared/components/breadcrumb/breadcrumb.component.ts b/client/src/app/shared/components/breadcrumb/breadcrumb.component.ts index 47c2ad5b0..6b33d4524 100644 --- a/client/src/app/shared/components/breadcrumb/breadcrumb.component.ts +++ b/client/src/app/shared/components/breadcrumb/breadcrumb.component.ts @@ -23,6 +23,8 @@ export class BreadcrumbComponent implements OnInit { @Input() public set breadcrumbs(labels: string[] | Breadcrumb[]) { this.breadcrumbList = []; + + // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#caveats for (const breadcrumb of labels) { if (typeof breadcrumb === 'string') { this.breadcrumbList.push({ label: breadcrumb, action: null }); @@ -45,16 +47,6 @@ export class BreadcrumbComponent implements OnInit { this.breadcrumbList[index].active = true; } - /** - * Sets the separator for the breadcrumbs. - * - * @param style The new separator as string (character). - */ - @Input() - public set breadcrumbStyle(style: string) { - document.documentElement.style.setProperty('--breadcrumb-content', `'${style}'`); - } - /** * The list of the breadcrumbs built by the input. */ @@ -63,9 +55,7 @@ export class BreadcrumbComponent implements OnInit { /** * Default constructor. */ - public constructor() { - this.breadcrumbStyle = '/'; - } + public constructor() {} /** * OnInit. diff --git a/client/src/app/shared/components/charts/charts.component.html b/client/src/app/shared/components/charts/charts.component.html index 2420f1bae..7d1e49c2d 100644 --- a/client/src/app/shared/components/charts/charts.component.html +++ b/client/src/app/shared/components/charts/charts.component.html @@ -15,6 +15,7 @@ {{ checkboxLabel }} diff --git a/client/src/app/shared/models/poll/base-poll.ts b/client/src/app/shared/models/poll/base-poll.ts index 75b35cf03..be98afe08 100644 --- a/client/src/app/shared/models/poll/base-poll.ts +++ b/client/src/app/shared/models/poll/base-poll.ts @@ -52,6 +52,13 @@ export abstract class BasePoll = any> extends public onehundred_percent_base: PercentBase; public user_has_voted: boolean; + /** + * Determine if the state is finished or published + */ + public get stateHasVotes(): boolean { + return this.state === PollState.Finished || this.state === PollState.Published; + } + protected getDecimalFields(): (keyof BasePoll)[] { return ['votesvalid', 'votesinvalid', 'votescast']; } diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html index 7fcc3ce2c..64ea46abf 100644 --- a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html +++ b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html @@ -26,7 +26,7 @@

{{ poll.title }}

- +
{{ 'Current state' | translate }}: {{ poll.stateVerbose | translate }}
diff --git a/client/src/app/site/motions/models/view-motion-poll.ts b/client/src/app/site/motions/models/view-motion-poll.ts index 01e465380..6085dce58 100644 --- a/client/src/app/site/motions/models/view-motion-poll.ts +++ b/client/src/app/site/motions/models/view-motion-poll.ts @@ -23,6 +23,10 @@ export class ViewMotionPoll extends ViewBasePoll implements MotionPo private tableKeys = ['yes', 'no', 'abstain']; private voteKeys = ['votesvalid', 'votesinvalid', 'votescast']; + public get hasVotes(): boolean { + return !!this.options[0].votes.length; + } + public initChartLabels(): string[] { return ['Votes']; } diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html index 6280bd024..7a14db653 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html @@ -1,13 +1,6 @@ - +
-

{{ motionTitle }}

+

{{ 'Motion' | translate }} {{ motion.id }}