From ced40cab74e670a6c5cf03778a573737256fc07a Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Fri, 18 Oct 2019 14:18:49 +0200 Subject: [PATCH] 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): """