Initial work for supporting voting

This commit is contained in:
FinnStutzenstein 2019-10-18 14:18:49 +02:00
parent 4d4697eee0
commit ced40cab74
107 changed files with 4841 additions and 2702 deletions

View File

@ -85,7 +85,11 @@ matrix:
- "3.6" - "3.6"
script: script:
- mypy openslides/ tests/ - mypy openslides/ tests/
<<<<<<< HEAD
- pytest --cov --cov-fail-under=72 - pytest --cov --cov-fail-under=72
=======
- pytest --cov --cov-fail-under=74
>>>>>>> Initial work for supporting voting
- name: "Server: Tests Python 3.7" - name: "Server: Tests Python 3.7"
language: python language: python
@ -96,7 +100,11 @@ matrix:
- isort --check-only --diff --recursive openslides tests - isort --check-only --diff --recursive openslides tests
- black --check --diff --target-version py36 openslides tests - black --check --diff --target-version py36 openslides tests
- mypy openslides/ tests/ - mypy openslides/ tests/
<<<<<<< HEAD
- pytest --cov --cov-fail-under=72 - pytest --cov --cov-fail-under=72
=======
- pytest --cov --cov-fail-under=74
>>>>>>> Initial work for supporting voting
- name: "Server: Tests Python 3.8" - name: "Server: Tests Python 3.8"
language: python language: python

View File

@ -68,15 +68,10 @@ export class AppLoadService {
let repository: BaseRepository<any, any, any> = null; let repository: BaseRepository<any, any, any> = null;
repository = this.injector.get(entry.repository); repository = this.injector.get(entry.repository);
repositories.push(repository); repositories.push(repository);
this.modelMapper.registerCollectionElement( this.modelMapper.registerCollectionElement(entry.model, entry.viewModel, repository);
entry.collectionString,
entry.model,
entry.viewModel,
repository
);
if (this.isSearchableModelEntry(entry)) { if (this.isSearchableModelEntry(entry)) {
this.searchService.registerModel( this.searchService.registerModel(
entry.collectionString, entry.model.COLLECTIONSTRING,
repository, repository,
entry.searchOrder, entry.searchOrder,
entry.openInNewTab entry.openInNewTab
@ -108,7 +103,7 @@ export class AppLoadService {
// to check if the result of the contructor (the model instance) is really a searchable. // to check if the result of the contructor (the model instance) is really a searchable.
if (!isSearchable(new entry.viewModel())) { if (!isSearchable(new entry.viewModel())) {
throw Error( 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; return true;

View File

@ -47,12 +47,11 @@ export class CollectionStringMapperService {
* @param model * @param model
*/ */
public registerCollectionElement<V extends BaseViewModel<M>, M extends BaseModel>( public registerCollectionElement<V extends BaseViewModel<M>, M extends BaseModel>(
collectionString: string,
model: ModelConstructor<M>, model: ModelConstructor<M>,
viewModel: ViewModelConstructor<V>, viewModel: ViewModelConstructor<V>,
repository: BaseRepository<V, M, TitleInformation> repository: BaseRepository<V, M, TitleInformation>
): void { ): void {
this.collectionStringMapping[collectionString] = [model, viewModel, repository]; this.collectionStringMapping[model.COLLECTIONSTRING] = [model, viewModel, repository];
} }
/** /**

View File

@ -7,7 +7,6 @@ import { MainMenuEntry } from '../core-services/main-menu.service';
import { Searchable } from '../../site/base/searchable'; import { Searchable } from '../../site/base/searchable';
interface BaseModelEntry { interface BaseModelEntry {
collectionString: string;
repository: Type<BaseRepository<any, any, any>>; repository: Type<BaseRepository<any, any, any>>;
model: ModelConstructor<BaseModel>; model: ModelConstructor<BaseModel>;
} }

View File

@ -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();
});
});

View File

@ -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');
};
}

View File

@ -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 { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { RelationDefinition } from 'app/core/definitions/relations'; import { RelationDefinition } from 'app/core/definitions/relations';
import { Assignment } from 'app/shared/models/assignments/assignment'; 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 { 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 { AssignmentRelatedUser } from 'app/shared/models/assignments/assignment-related-user';
import { AssignmentTitleInformation, ViewAssignment } from 'app/site/assignments/models/view-assignment'; 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 { 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 { ViewAssignmentRelatedUser } from 'app/site/assignments/models/view-assignment-related-user';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewTag } from 'app/site/tags/models/view-tag';
@ -68,8 +68,8 @@ const AssignmentNestedModelDescriptors: NestedModelDescriptors = {
'assignments/assignment-poll': [ 'assignments/assignment-poll': [
{ {
ownKey: 'options', ownKey: 'options',
foreignViewModel: ViewAssignmentPollOption, foreignViewModel: ViewAssignmentOption,
foreignModel: AssignmentPollOption, foreignModel: AssignmentOption,
order: 'weight', order: 'weight',
relationDefinitionsByKey: { relationDefinitionsByKey: {
user: { user: {
@ -97,10 +97,8 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
AssignmentTitleInformation AssignmentTitleInformation
> { > {
private readonly restPath = '/rest/assignments/assignment/'; private readonly restPath = '/rest/assignments/assignment/';
private readonly restPollPath = '/rest/assignments/poll/';
private readonly candidatureOtherPath = '/candidature_other/'; private readonly candidatureOtherPath = '/candidature_other/';
private readonly candidatureSelfPath = '/candidature_self/'; private readonly candidatureSelfPath = '/candidature_self/';
private readonly createPollPath = '/create_poll/';
private readonly markElectedPath = '/mark_elected/'; private readonly markElectedPath = '/mark_elected/';
/** /**
@ -179,67 +177,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath); 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<void> {
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<void> {
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<AssignmentPoll>, originalPoll: ViewAssignmentPoll): Promise<void> {
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<AssignmentPoll>, originalPoll: ViewAssignmentPoll): Promise<void> {
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 * change the 'elected' state of an election candidate
* *

View File

@ -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();
});
});

View File

@ -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<ViewAssignmentVote, AssignmentVote, object> {
/**
* @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');
};
}

View File

@ -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();
});
});

View File

@ -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');
};
}

View File

@ -14,7 +14,6 @@ import { ConfigService } from 'app/core/ui-services/config.service';
import { DiffLinesInParagraph, DiffService } from 'app/core/ui-services/diff.service'; import { DiffLinesInParagraph, DiffService } from 'app/core/ui-services/diff.service';
import { TreeIdNode } from 'app/core/ui-services/tree.service'; import { TreeIdNode } from 'app/core/ui-services/tree.service';
import { Motion } from 'app/shared/models/motions/motion'; 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 { Submitter } from 'app/shared/models/motions/submitter';
import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change'; import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change';
import { PersonalNoteContent } from 'app/shared/models/users/personal-note'; import { PersonalNoteContent } from 'app/shared/models/users/personal-note';
@ -844,46 +843,6 @@ export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersCo
.filter((para: ViewMotionAmendedParagraph) => para !== null); .filter((para: ViewMotionAmendedParagraph) => para !== null);
} }
/**
* Sends a request to the server, creating a new poll for the motion
*/
public async createPoll(motion: ViewMotion): Promise<void> {
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<void> {
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<void> {
const url = '/rest/motions/motion-poll/' + poll.id + '/';
await this.httpService.delete(url);
}
/** /**
* Signals the acceptance of the current recommendation to the server * Signals the acceptance of the current recommendation to the server
* *

View File

@ -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();
});
});

View File

@ -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<ViewMotionVote, MotionVote, object> {
/**
* @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');
};
}

View File

@ -0,0 +1,11 @@
import { BaseOption } from '../poll/base-option';
export class AssignmentOption extends BaseOption<AssignmentOption> {
public static COLLECTIONSTRING = 'assignments/assignment-option';
public user_id: number;
public constructor(input?: any) {
super(AssignmentOption.COLLECTIONSTRING, input);
}
}

View File

@ -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<AssignmentPollOption> {
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);
}
}

View File

@ -1,18 +1,18 @@
import { AssignmentPollMethod } from 'app/site/assignments/services/assignment-poll.service'; import { AssignmentOption } from './assignment-option';
import { AssignmentPollOption } from './assignment-poll-option'; import { BasePoll, BasePollWithoutNestedModels } from '../poll/base-poll';
import { BaseModel } from '../base/base-model';
export interface AssignmentPollWithoutNestedModels extends BaseModel<AssignmentPoll> { export enum AssignmentPollmethods {
id: number; 'yn' = 'yn',
pollmethod: AssignmentPollMethod; 'yna' = 'yna',
description: string; 'votes' = 'votes'
published: boolean; }
votesvalid: number;
votesno: number; export interface AssignmentPollWithoutNestedModels extends BasePollWithoutNestedModels {
votesabstain: number; pollmethod: AssignmentPollmethods;
votesinvalid: number; votes_amount: number;
votescast: number; allow_multiple_votes_per_candidate: boolean;
has_votes: boolean; global_no: boolean;
global_abstain: boolean;
assignment_id: number; assignment_id: number;
} }
@ -20,22 +20,12 @@ export interface AssignmentPollWithoutNestedModels extends BaseModel<AssignmentP
* Content of the 'polls' property of assignments * Content of the 'polls' property of assignments
* @ignore * @ignore
*/ */
export class AssignmentPoll extends BaseModel<AssignmentPoll> { export class AssignmentPoll extends BasePoll<AssignmentPoll, AssignmentOption> {
public static COLLECTIONSTRING = 'assignments/assignment-poll'; public static COLLECTIONSTRING = 'assignments/assignment-poll';
private static DECIMAL_FIELDS = ['votesvalid', 'votesinvalid', 'votescast', 'votesno', 'votesabstain'];
public id: number; public id: number;
public options: AssignmentPollOption[];
public constructor(input?: any) { 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); super(AssignmentPoll.COLLECTIONSTRING, input);
} }
} }

View File

@ -0,0 +1,11 @@
import { BaseVote } from '../poll/base-vote';
export class AssignmentVote extends BaseVote<AssignmentVote> {
public static COLLECTIONSTRING = 'assignments/assignment-vote';
public id: number;
public constructor(input?: any) {
super(AssignmentVote.COLLECTIONSTRING, input);
}
}

View File

@ -0,0 +1,12 @@
import { BaseModel } from './base-model';
export abstract class BaseDecimalModel<T = any> extends BaseModel<T> {
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);
}
}

View File

@ -0,0 +1,9 @@
import { BaseOption } from '../poll/base-option';
export class MotionOption extends BaseOption<MotionOption> {
public static COLLECTIONSTRING = 'motions/motion-option';
public constructor(input?: any) {
super(MotionOption.COLLECTIONSTRING, input);
}
}

View File

@ -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. * Class representing a poll for a motion.
*/ */
export class MotionPoll extends Deserializer { export class MotionPoll extends BasePoll<MotionPoll, MotionOption> {
public static COLLECTIONSTRING = 'motions/motion-poll';
public id: number; 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) { public constructor(input?: any) {
if (typeof input === 'object') { super(MotionPoll.COLLECTIONSTRING, input);
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);
} }
} }
export interface MotionPoll extends MotionPollWithoutNestedModels {}

View File

@ -0,0 +1,11 @@
import { BaseVote } from '../poll/base-vote';
export class MotionVote extends BaseVote<MotionVote> {
public static COLLECTIONSTRING = 'motions/motion-vote';
public id: number;
public constructor(input?: any) {
super(MotionVote.COLLECTIONSTRING, input);
}
}

View File

@ -0,0 +1,11 @@
import { BaseDecimalModel } from '../base/base-decimal-model';
export abstract class BaseOption<T> extends BaseDecimalModel<T> {
public id: number;
public yes: number;
public no: number;
public abstain: number;
public votes_id: number[];
protected decimalFields: (keyof BaseOption<T>)[] = ['yes', 'no', 'abstain'];
}

View File

@ -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<T, O extends BaseOption<any>> extends BaseDecimalModel<T> {
public options: O[];
protected decimalFields: (keyof BasePoll<T, O>)[] = ['votesvalid', 'votesinvalid', 'votescast'];
}
export interface BasePoll<T, O extends BaseOption<any>> extends BasePollWithoutNestedModels {}

View File

@ -0,0 +1,9 @@
import { BaseDecimalModel } from '../base/base-decimal-model';
export abstract class BaseVote<T> extends BaseDecimalModel<T> {
public weight: number;
public value: 'Y' | 'N' | 'A';
public user_id?: number;
protected decimalFields: (keyof BaseVote<T>)[] = ['weight'];
}

View File

@ -9,9 +9,8 @@ import { ViewListOfSpeakers } from './models/view-list-of-speakers';
export const AgendaAppConfig: AppConfig = { export const AgendaAppConfig: AppConfig = {
name: 'agenda', name: 'agenda',
models: [ models: [
{ collectionString: 'agenda/item', model: Item, viewModel: ViewItem, repository: ItemRepositoryService }, { model: Item, viewModel: ViewItem, repository: ItemRepositoryService },
{ {
collectionString: 'agenda/list-of-speakers',
model: ListOfSpeakers, model: ListOfSpeakers,
viewModel: ViewListOfSpeakers, viewModel: ViewListOfSpeakers,
repository: ListOfSpeakersRepositoryService repository: ListOfSpeakersRepositoryService

View File

@ -1,17 +1,32 @@
import { AppConfig } from '../../core/definitions/app-config'; 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 { 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 { Assignment } from '../../shared/models/assignments/assignment';
import { ViewAssignment } from './models/view-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 = { export const AssignmentsAppConfig: AppConfig = {
name: 'assignments', name: 'assignments',
models: [ models: [
{ {
collectionString: 'assignments/assignment',
model: Assignment, model: Assignment,
viewModel: ViewAssignment, viewModel: ViewAssignment,
// searchOrder: 3, // TODO: enable, if there is a detail page and so on. // searchOrder: 3, // TODO: enable, if there is a detail page and so on.
repository: AssignmentRepositoryService repository: AssignmentRepositoryService
},
{
model: AssignmentPoll,
viewModel: ViewAssignmentPoll,
repository: AssignmentPollRepositoryService
},
{
model: AssignmentVote,
viewModel: ViewAssignmentVote,
repository: AssignmentVoteRepositoryService
} }
], ],
mainMenuEntries: [ mainMenuEntries: [

View File

@ -147,7 +147,7 @@
<!-- TODO avoid animation/switching on update --> <!-- TODO avoid animation/switching on update -->
<mat-tab <mat-tab
*ngFor="let poll of assignment.polls; let i = index; trackBy: trackByIndex" *ngFor="let poll of assignment.polls; let i = index; trackBy: trackByIndex"
[label]="getPollLabel(poll, i)" [label]="poll.title"
> >
<os-assignment-poll [assignment]="assignment" [poll]="poll"> </os-assignment-poll> <os-assignment-poll [assignment]="assignment" [poll]="poll"> </os-assignment-poll>
</mat-tab> </mat-tab>

View File

@ -15,7 +15,6 @@ import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { Assignment } from 'app/shared/models/assignments/assignment'; 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 { ViewItem } from 'app/site/agenda/models/view-item';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; 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 { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service'; import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment'; import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment';
import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user'; import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user';
@ -171,7 +169,6 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
formBuilder: FormBuilder, formBuilder: FormBuilder,
public repo: AssignmentRepositoryService, public repo: AssignmentRepositoryService,
private userRepo: UserRepositoryService, private userRepo: UserRepositoryService,
public pollService: AssignmentPollService,
private itemRepo: ItemRepositoryService, private itemRepo: ItemRepositoryService,
private tagRepo: TagRepositoryService, private tagRepo: TagRepositoryService,
private promptService: PromptService, private promptService: PromptService,
@ -303,10 +300,9 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
/** /**
* Creates a new Poll * Creates a new Poll
* TODO: directly open poll dialog?
*/ */
public async createPoll(): Promise<void> { public async createPoll(): Promise<void> {
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 * Triggers an update of the filter for the list of available candidates
* (triggered on an autoupdate of either users or the assignment) * (triggered on an autoupdate of either users or the assignment)

View File

@ -1,16 +1,9 @@
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; 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 { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; import { ViewAssignmentOption } from '../../models/view-assignment-option';
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
import { AssignmentPollService, SummaryPollKey } from '../../services/assignment-poll.service';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; 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) * Vote entries included once for summary (e.g. total votes cast)
@ -61,16 +54,9 @@ export class AssignmentPollDialogComponent {
*/ */
public constructor( public constructor(
public dialogRef: MatDialogRef<AssignmentPollDialogComponent>, public dialogRef: MatDialogRef<AssignmentPollDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: ViewAssignmentPoll, @Inject(MAT_DIALOG_DATA) public data: ViewAssignmentPoll
private matSnackBar: MatSnackBar,
private translate: TranslateService,
public pollService: AssignmentPollService,
private userRepo: UserRepositoryService
) { ) {
this.specialValues = this.pollService.specialPollVotes; switch (this.data.pollmethod) {
this.poll = this.data.poll;
switch (this.poll.pollmethod) {
case 'votes': case 'votes':
this.optionPollKeys = ['Votes']; this.optionPollKeys = ['Votes'];
break; break;
@ -97,7 +83,7 @@ export class AssignmentPollDialogComponent {
* TODO better validation * TODO better validation
*/ */
public submit(): void { public submit(): void {
const error = this.data.options.find(dataoption => { /*const error = this.data.options.find(dataoption => {
this.optionPollKeys.some(key => { this.optionPollKeys.some(key => {
const keyValue = dataoption.votes.find(o => o.value === key); const keyValue = dataoption.votes.find(o => o.value === key);
return !keyValue || keyValue.weight === undefined; return !keyValue || keyValue.weight === undefined;
@ -112,8 +98,8 @@ export class AssignmentPollDialogComponent {
} }
); );
} else { } 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 * @returns a label for a poll option
*/ */
public getLabel(key: CalculablePollKey): string { 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 candidate the candidate for whom to update the value
* @param newData the new value * @param newData the new value
*/ */
public setValue(value: PollVoteValue, candidate: ViewAssignmentPollOption, newData: string): void { public setValue(value: PollVoteValue, candidate: ViewAssignmentOption, newData: string): void {
const vote = candidate.votes.find(v => v.value === value); /*const vote = candidate.votes.find(v => v.value === value);
if (vote) { if (vote) {
vote.weight = parseFloat(newData); vote.weight = parseFloat(newData);
} else { } else {
@ -142,7 +129,7 @@ export class AssignmentPollDialogComponent {
value: value, value: value,
weight: parseFloat(newData) weight: parseFloat(newData)
}); });
} }*/
} }
/** /**
@ -152,9 +139,10 @@ export class AssignmentPollDialogComponent {
* @param candidate the pollOption * @param candidate the pollOption
* @returns the currently entered number or undefined if no number has been set * @returns the currently entered number or undefined if no number has been set
*/ */
public getValue(value: PollVoteValue, candidate: AssignmentPollOption): number | undefined { public getValue(value: PollVoteValue, candidate: ViewAssignmentOption): number | undefined {
const val = candidate.votes.find(v => v.value === value); /*const val = candidate.votes.find(v => v.value === value);
return val ? val.weight : undefined; return val ? val.weight : undefined;*/
throw new Error('TODO');
} }
/** /**
@ -163,8 +151,9 @@ export class AssignmentPollDialogComponent {
* @param value * @param value
* @returns integer or undefined * @returns integer or undefined
*/ */
public getSumValue(value: SummaryPollKey): number | undefined { public getSumValue(value: any /*SummaryPollKey*/): number | undefined {
return this.data[value] || undefined; // return this.data[value] || undefined;
throw new Error('TODO');
} }
/** /**
@ -173,23 +162,11 @@ export class AssignmentPollDialogComponent {
* @param value * @param value
* @param weight * @param weight
*/ */
public setSumValue(value: SummaryPollKey, weight: string): void { public setSumValue(value: any /*SummaryPollKey*/, weight: string): void {
this.poll[value] = parseFloat(weight); this.data[value] = parseFloat(weight);
} }
public getGridClass(): string { public getGridClass(): string {
return `votes-grid-${this.optionPollKeys.length}`; 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 : '';
}
} }

View File

@ -1,5 +1,5 @@
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; 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 { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -7,17 +7,12 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from 'app/core/core-services/operator.service'; 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 { 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 { 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 { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { ViewAssignment } from '../../models/view-assignment'; import { ViewAssignment } from '../../models/view-assignment';
import { ViewAssignmentOption } from '../../models/view-assignment-option';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; 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 * 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.) * used in this poll (e.g.)
*/ */
public get pollValues(): CalculablePollKey[] { 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 * @returns true if the description on the form differs from the poll's description
*/ */
public get dirtyDescription(): boolean { 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 * @returns true if vote results can be seen by the user
*/ */
public get pollData(): boolean { public get pollData(): boolean {
if (!this.poll.has_votes) { /*if (!this.poll.has_votes) {
return false; 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( public constructor(
titleService: Title, titleService: Title,
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
public pollService: AssignmentPollService,
private operator: OperatorService, private operator: OperatorService,
private assignmentRepo: AssignmentRepositoryService,
public translate: TranslateService, public translate: TranslateService,
public dialog: MatDialog, public dialog: MatDialog,
private promptService: PromptService,
private formBuilder: FormBuilder,
private pdfService: AssignmentPollPdfService private pdfService: AssignmentPollPdfService
) { ) {
super(titleService, translate, matSnackBar); 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 * Gets the currently selected majority choice option from the repo
*/ */
public ngOnInit(): void { public ngOnInit(): void {
this.majorityChoice = /*this.majorityChoice =
this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) || this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) ||
null; null;
this.descriptionForm = this.formBuilder.group({ this.descriptionForm = this.formBuilder.group({
description: this.poll ? this.poll.description : '' 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?)) * TODO: Some confirmation (advanced logic (e.g. not deleting published?))
*/ */
public async onDeletePoll(): Promise<void> { public async onDeletePoll(): Promise<void> {
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)) { if (await this.promptService.open(title)) {
await this.assignmentRepo.deletePoll(this.poll).catch(this.raiseError); await this.assignmentRepo.deletePoll(this.poll).catch(this.raiseError);
} }*/
} }
/** /**
@ -180,15 +161,16 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
* @param option * @param option
* @returns true if the quorum is successfully met * @returns true if the quorum is successfully met
*/ */
public quorumReached(option: ViewAssignmentPollOption): boolean { public quorumReached(option: ViewAssignmentOption): boolean {
const yesValue = this.poll.pollmethod === 'votes' ? 'Votes' : 'Yes'; /*const yesValue = this.poll.pollmethod === 'votes' ? 'Votes' : 'Yes';
const amount = option.votes.find(v => v.value === yesValue).weight; const amount = option.votes.find(v => v.value === yesValue).weight;
const yesQuorum = this.pollService.yesQuorum( const yesQuorum = this.pollService.yesQuorum(
this.majorityChoice, this.majorityChoice,
this.pollService.calculationDataFromPoll(this.poll), this.pollService.calculationDataFromPoll(this.poll),
option 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) * closes successfully (validation is done there)
*/ */
public enterVotes(): void { public enterVotes(): void {
const dialogRef = this.dialog.open(AssignmentPollDialogComponent, { /*const dialogRef = this.dialog.open(AssignmentPollDialogComponent, {
data: this.poll, data: this.poll.copy(),
...mediumDialogSettings ...mediumDialogSettings
}); });
dialogRef.afterClosed().subscribe(result => { dialogRef.afterClosed().subscribe(result => {
if (result) { if (result) {
this.assignmentRepo.updateVotes(result, this.poll).catch(this.raiseError); this.assignmentRepo.updateVotes(result, this.poll).catch(this.raiseError);
} }
}); });*/
} }
/** /**
@ -220,7 +202,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
* Toggles the 'published' state * Toggles the 'published' state
*/ */
public togglePublished(): void { 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 * @param option
*/ */
public toggleElected(option: ViewAssignmentPollOption): void { public toggleElected(option: ViewAssignmentOption): void {
if (!this.operator.hasPerms('assignments.can_manage')) { /*if (!this.operator.hasPerms('assignments.can_manage')) {
return; return;
} }
@ -239,7 +221,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
); );
if (viewAssignmentRelatedUser) { if (viewAssignmentRelatedUser) {
this.assignmentRepo.markElected(viewAssignmentRelatedUser, this.assignment, !option.is_elected); this.assignmentRepo.markElected(viewAssignmentRelatedUser, this.assignment, !option.is_elected);
} }*/
} }
/** /**
@ -247,8 +229,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
* TODO: Better feedback * TODO: Better feedback
*/ */
public async onEditDescriptionButton(): Promise<void> { public async onEditDescriptionButton(): Promise<void> {
const desc: string = this.descriptionForm.get('description').value; /*const desc: string = this.descriptionForm.get('description').value;
await this.assignmentRepo.updatePoll({ description: desc }, this.poll).catch(this.raiseError); await this.assignmentRepo.updatePoll({ description: desc }, this.poll).catch(this.raiseError);*/
} }
/** /**
@ -256,8 +238,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
* @param option * @param option
* @returns a translated * @returns a translated
*/ */
public getQuorumReachedString(option: ViewAssignmentPollOption): string { public getQuorumReachedString(option: ViewAssignmentOption): string {
const name = this.translate.instant(this.majorityChoice.display_name); /*const name = this.translate.instant(this.majorityChoice.display_name);
const quorum = this.pollService.yesQuorum( const quorum = this.pollService.yesQuorum(
this.majorityChoice, this.majorityChoice,
this.pollService.calculationDataFromPoll(this.poll), this.pollService.calculationDataFromPoll(this.poll),
@ -266,6 +248,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
const isReached = this.quorumReached(option) const isReached = this.quorumReached(option)
? this.translate.instant('reached') ? this.translate.instant('reached')
: this.translate.instant('not reached'); : this.translate.instant('not reached');
return `${name} (${quorum}) ${isReached}`; return `${name} (${quorum}) ${isReached}`;*/
throw new Error('TODO');
} }
} }

View File

@ -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<AssignmentOption> {
public get option(): AssignmentOption {
return this._model;
}
public static COLLECTIONSTRING = AssignmentOption.COLLECTIONSTRING;
protected _collectionString = AssignmentOption.COLLECTIONSTRING;
}
export interface ViewAssignmentOption extends AssignmentOption {}

View File

@ -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<AssignmentPollOption> {
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;
}

View File

@ -1,9 +1,16 @@
import { AssignmentPoll, AssignmentPollWithoutNestedModels } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPoll, AssignmentPollWithoutNestedModels } from 'app/shared/models/assignments/assignment-poll';
import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; 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<AssignmentPoll> { export interface AssignmentPollTitleInformation {
title: string;
}
export class ViewAssignmentPoll extends BaseProjectableViewModel<AssignmentPoll>
implements AssignmentPollTitleInformation {
public static COLLECTIONSTRING = AssignmentPoll.COLLECTIONSTRING; public static COLLECTIONSTRING = AssignmentPoll.COLLECTIONSTRING;
protected _collectionString = AssignmentPoll.COLLECTIONSTRING; protected _collectionString = AssignmentPoll.COLLECTIONSTRING;
@ -11,18 +18,10 @@ export class ViewAssignmentPoll extends BaseProjectableViewModel<AssignmentPoll>
return this._model; return this._model;
} }
public getListTitle = () => {
return this.getTitle();
};
public getProjectorTitle = () => {
return this.getTitle();
};
public getSlide(): ProjectorElementBuildDeskriptor { public getSlide(): ProjectorElementBuildDeskriptor {
return { /*return {
getBasicProjectorElement: options => ({ getBasicProjectorElement: options => ({
name: 'assignments/poll', name: 'assignments/assignment-poll',
assignment_id: this.assignment_id, assignment_id: this.assignment_id,
poll_id: this.id, poll_id: this.id,
getIdentifiers: () => ['name', 'assignment_id', 'poll_id'] getIdentifiers: () => ['name', 'assignment_id', 'poll_id']
@ -30,10 +29,15 @@ export class ViewAssignmentPoll extends BaseProjectableViewModel<AssignmentPoll>
slideOptions: [], slideOptions: [],
projectionDefaultName: 'assignments', projectionDefaultName: 'assignments',
getDialogTitle: () => 'TODO' getDialogTitle: () => 'TODO'
}; };*/
throw new Error('TODO');
} }
} }
export interface ViewAssignmentPoll extends AssignmentPollWithoutNestedModels { interface TIAssignmentPollRelations {
options: ViewAssignmentPollOption[]; options: ViewAssignmentOption[];
voted: ViewUser[];
groups: ViewGroup[];
} }
export interface ViewAssignmentPoll extends AssignmentPollWithoutNestedModels, TIAssignmentPollRelations {}

View File

@ -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<AssignmentVote> {
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 {}

View File

@ -45,9 +45,6 @@ export class ViewAssignment extends BaseViewModelWithAgendaItemAndListOfSpeakers
return this._model; return this._model;
} }
/**
* TODO: Fix assignment creation: DO NOT create a ViewUser there...
*/
public get candidates(): ViewUser[] { public get candidates(): ViewUser[] {
if (!this.assignment_related_users) { if (!this.assignment_related_users) {
return []; return [];

View File

@ -3,11 +3,7 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { HtmlToPdfService } from 'app/core/pdf-services/html-to-pdf.service'; 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 { 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 * Creates a PDF document from a single assignment
@ -16,12 +12,6 @@ import { ViewAssignmentPollOption } from '../models/view-assignment-poll-option'
providedIn: 'root' providedIn: 'root'
}) })
export class AssignmentPdfService { 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 * Constructor
* *
@ -30,11 +20,7 @@ export class AssignmentPdfService {
* @param pdfDocumentService PDF functions * @param pdfDocumentService PDF functions
* @param htmlToPdfService Convert the assignment detail html text to pdf * @param htmlToPdfService Convert the assignment detail html text to pdf
*/ */
public constructor( public constructor(private translate: TranslateService, private htmlToPdfService: HtmlToPdfService) {}
private translate: TranslateService,
private pollService: AssignmentPollService,
private htmlToPdfService: HtmlToPdfService
) {}
/** /**
* Main function to control the pdf generation. * Main function to control the pdf generation.
@ -161,7 +147,8 @@ export class AssignmentPdfService {
* @param pollOption the poll options (yes, no, maybe [...]) * @param pollOption the poll options (yes, no, maybe [...])
* @returns a line in the table * @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) { if (pollOption.is_elected) {
this.showIsElected = true; this.showIsElected = true;
return { return {
@ -172,8 +159,8 @@ export class AssignmentPdfService {
return { return {
text: candidateName text: candidateName
}; };
} }*
} }*/
/** /**
* Creates the poll result table for all published polls * 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 * @param assignment the ViewAssignment to create the document for
* @returns the table as pdfmake object * @returns the table as pdfmake object
*/ */
// TODO: type the result
private createPollResultTable(assignment: ViewAssignment): object { private createPollResultTable(assignment: ViewAssignment): object {
const resultBody = []; /*const resultBody = [];
for (let pollIndex = 0; pollIndex < assignment.polls.length; pollIndex++) { for (let pollIndex = 0; pollIndex < assignment.polls.length; pollIndex++) {
const poll = assignment.polls[pollIndex]; const poll = assignment.polls[pollIndex];
if (poll.published) { 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 optionLabel Usually Yes or No
* @param value the amount of votes * @param value the amount of votes
* @param poll the specific poll * @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" * @returns a string a nicer number representation: "Yes 25 (22,2%)" or just "10"
*/ */
private parseVoteValue( /*private parseVoteValue(
optionLabel: PollVoteValue, optionLabel: PollVoteValue,
value: number, value: number,
poll: ViewAssignmentPoll, poll: ViewAssignmentPoll,
pollOption: ViewAssignmentPollOption option: ViewAssignmentOption
): string { ): string {
let resultString = ''; let resultString = '';
const label = this.translate.instant(this.pollService.getLabel(optionLabel)); const label = this.translate.instant(this.pollService.getLabel(optionLabel));
const valueString = this.pollService.getSpecialLabel(value); const valueString = this.pollService.getSpecialLabel(value);
const percentNr = this.pollService.getPercent( const percentNr = this.pollService.getPercent(
this.pollService.calculationDataFromPoll(poll), this.pollService.calculationDataFromPoll(poll),
pollOption, option,
optionLabel optionLabel
); );
resultString += `${label} ${valueString}`; resultString += `${label} ${valueString}`;
if ( if (
percentNr && percentNr &&
!this.pollService.isAbstractOption(this.pollService.calculationDataFromPoll(poll), pollOption, optionLabel) !this.pollService.isAbstractOption(this.pollService.calculationDataFromPoll(poll), option, optionLabel)
) { ) {
resultString += ` (${percentNr}%)`; resultString += ` (${percentNr}%)`;
} }
return `${resultString}\n`; return `${resultString}\n`;
} }*/
} }

View File

@ -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 { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { AssignmentPollMethod } from './assignment-poll.service';
import { ViewAssignmentPoll } from '../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../models/view-assignment-poll';
/** /**
@ -113,6 +112,7 @@ export class AssignmentPollPdfService extends PollPdfService {
* @param title The identifier of the motion * @param title The identifier of the motion
* @param subtitle The actual motion title * @param subtitle The actual motion title
*/ */
// TODO: typing of result
protected createBallot(data: AbstractPollData): object { protected createBallot(data: AbstractPollData): object {
return { return {
columns: [ columns: [
@ -136,8 +136,9 @@ export class AssignmentPollPdfService extends PollPdfService {
}; };
} }
// TODO: typing of result
private createCandidateFields(poll: ViewAssignmentPoll): object { private createCandidateFields(poll: ViewAssignmentPoll): object {
const candidates = poll.options.sort((a, b) => { /*const candidates = poll.options.sort((a, b) => {
return a.weight - b.weight; return a.weight - b.weight;
}); });
const resultObject = candidates.map(cand => { const resultObject = candidates.map(cand => {
@ -150,10 +151,12 @@ export class AssignmentPollPdfService extends PollPdfService {
noEntry.margin[1] = 25; noEntry.margin[1] = 25;
resultObject.push(noEntry); 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 choices = method === 'yna' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No'];
const columnstack = choices.map(choice => { const columnstack = choices.map(choice => {
return { return {
@ -171,7 +174,7 @@ export class AssignmentPollPdfService extends PollPdfService {
columns: columnstack columns: columnstack
} }
]; ];
} }*/
/** /**
* Generates the poll description * Generates the poll description
@ -179,10 +182,12 @@ export class AssignmentPollPdfService extends PollPdfService {
* @param poll * @param poll
* @returns pdfMake definitions * @returns pdfMake definitions
*/ */
// TODO: typing of result
private createPollHint(poll: ViewAssignmentPoll): object { private createPollHint(poll: ViewAssignmentPoll): object {
return { /*return {
text: poll.description || '', text: poll.description || '',
style: 'description' style: 'description'
}; };*/
throw new Error('TODO');
} }
} }

View File

@ -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();
});
});

View File

@ -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<string>('assignments_poll_default_majority_method')
.subscribe(method => (this.defaultMajorityMethod = method));
config
.get<AssignmentPollValues>('assignments_poll_vote_values')
.subscribe(method => (this.pollMethod = method));
config
.get<AssignmentPercentBase>('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
};
}
}

View File

@ -5,9 +5,7 @@ import { ViewConfig } from './models/view-config';
export const ConfigAppConfig: AppConfig = { export const ConfigAppConfig: AppConfig = {
name: 'settings', name: 'settings',
models: [ models: [{ model: Config, viewModel: ViewConfig, repository: ConfigRepositoryService }],
{ collectionString: 'core/config', model: Config, viewModel: ViewConfig, repository: ConfigRepositoryService }
],
mainMenuEntries: [ mainMenuEntries: [
{ {
route: '/settings', route: '/settings',

View File

@ -7,7 +7,6 @@ export const MediafileAppConfig: AppConfig = {
name: 'mediafiles', name: 'mediafiles',
models: [ models: [
{ {
collectionString: 'mediafiles/mediafile',
model: Mediafile, model: Mediafile,
viewModel: ViewMediafile, viewModel: ViewMediafile,
searchOrder: 5, searchOrder: 5,

View File

@ -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<MotionOption> {
public get option(): MotionOption {
return this._model;
}
public static COLLECTIONSTRING = MotionOption.COLLECTIONSTRING;
protected _collectionString = MotionOption.COLLECTIONSTRING;
}
export interface ViewMotionPoll extends MotionOption {}

View File

@ -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<MotionPoll> 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 {}

View File

@ -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<MotionVote> {
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 {}

View File

@ -1382,7 +1382,9 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
* Handler for creating a poll * Handler for creating a poll
*/ */
public createPoll(): void { public createPoll(): void {
this.repo.createPoll(this.motion).catch(this.raiseError); // TODO
// this.repo.createPoll(<any>{}).catch(this.raiseError);
throw new Error('TODO');
} }
/** /**

View File

@ -1,8 +1,5 @@
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; 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 { CalculablePollKey } from 'app/core/ui-services/poll.service';
import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { MotionPoll } from 'app/shared/models/motions/motion-poll';
@ -35,8 +32,6 @@ export class MotionPollDialogComponent {
public constructor( public constructor(
public dialogRef: MatDialogRef<MotionPollDialogComponent>, public dialogRef: MatDialogRef<MotionPollDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: MotionPoll, @Inject(MAT_DIALOG_DATA) public data: MotionPoll,
private matSnackBar: MatSnackBar,
private translate: TranslateService,
private pollService: MotionPollService private pollService: MotionPollService
) { ) {
this.pollKeys = this.pollService.pollValues; this.pollKeys = this.pollService.pollValues;
@ -57,7 +52,7 @@ export class MotionPollDialogComponent {
* TODO better validation * TODO better validation
*/ */
public submit(): void { 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.matSnackBar.open(
this.translate.instant('Please fill in all required values'), this.translate.instant('Please fill in all required values'),
this.translate.instant('OK'), this.translate.instant('OK'),
@ -67,7 +62,7 @@ export class MotionPollDialogComponent {
); );
} else { } else {
this.dialogRef.close(this.data); this.dialogRef.close(this.data);
} }*/
} }
/** /**

View File

@ -5,7 +5,7 @@
</ng-container> </ng-container>
<ng-container class="meta-text-block-content"> <ng-container class="meta-text-block-content">
<div class="motion-poll-wrapper"> <div class="motion-poll-wrapper">
<div *ngIf="poll.has_votes" class="poll-result"> <!--<div *ngIf="poll.has_votes" class="poll-result">--><div>
<div *ngFor="let key of pollValues"> <div *ngFor="let key of pollValues">
<div class="poll-progress" *ngIf="poll[key] !== undefined"> <div class="poll-progress" *ngIf="poll[key] !== undefined">
<mat-icon class="main-nav-color" matTooltip="{{ getLabel(key) | translate }}"> <mat-icon class="main-nav-color" matTooltip="{{ getLabel(key) | translate }}">

View File

@ -1,21 +1,16 @@
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { MatSnackBar } from '@angular/material'; import { MatDialog, MatSnackBar } from '@angular/material';
import { MatDialog } from '@angular/material/dialog';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { ConstantsService } from 'app/core/core-services/constants.service'; 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 { 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 { 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 { BaseViewComponent } from 'app/site/base/base-view';
import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service'; import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service';
import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service'; import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service';
import { MotionPollService } from 'app/site/motions/services/motion-poll.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. * A component used to display and edit polls of a motion.
@ -97,9 +92,7 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit {
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
public dialog: MatDialog, public dialog: MatDialog,
public pollService: MotionPollService, public pollService: MotionPollService,
private motionRepo: MotionRepositoryService,
private constants: ConstantsService, private constants: ConstantsService,
private promptService: PromptService,
public perms: LocalPermissionsService, public perms: LocalPermissionsService,
private pdfService: MotionPollPdfService private pdfService: MotionPollPdfService
) { ) {
@ -113,7 +106,7 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit {
* Subscribes to updates of itself * Subscribes to updates of itself
*/ */
public ngOnInit(): void { public ngOnInit(): void {
this.poll = new MotionPoll(this.rawPoll); /*this.poll = new MotionPoll(this.rawPoll);
this.motionRepo.getViewModelObservable(this.poll.motion_id).subscribe(viewmotion => { this.motionRepo.getViewModelObservable(this.poll.motion_id).subscribe(viewmotion => {
if (viewmotion) { if (viewmotion) {
const updatePoll = viewmotion.motion.polls.find(poll => poll.id === this.poll.id); 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); this.poll = new MotionPoll(updatePoll);
} }
} }
}); });*/
} }
/** /**
* Sends a delete request for this poll after a confirmation dialog has been accepted. * Sends a delete request for this poll after a confirmation dialog has been accepted.
*/ */
public async deletePoll(): Promise<void> { public async deletePoll(): Promise<void> {
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)) { if (await this.promptService.open(title)) {
this.motionRepo.deletePoll(this.poll).catch(this.raiseError); 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' * Triggers the 'edit poll' dialog'
*/ */
public editPoll(): void { public editPoll(): void {
const dialogRef = this.dialog.open(MotionPollDialogComponent, { /*const dialogRef = this.dialog.open(MotionPollDialogComponent, {
data: { ...this.poll }, data: { ...this.poll },
...infoDialogSettings ...infoDialogSettings
}); });
@ -200,7 +194,8 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit {
if (result) { if (result) {
this.motionRepo.updatePoll(result).catch(this.raiseError); 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 * @returns true if the quorum is reached
*/ */
public get quorumYesReached(): boolean { public get quorumYesReached(): boolean {
return this.poll.yes >= this.yesQuorum; // return this.poll.yes >= this.yesQuorum;
return false;
} }
/** /**

View File

@ -28,52 +28,44 @@ export const MotionsAppConfig: AppConfig = {
name: 'motions', name: 'motions',
models: [ models: [
{ {
collectionString: 'motions/motion',
model: Motion, model: Motion,
viewModel: ViewMotion, viewModel: ViewMotion,
searchOrder: 2, searchOrder: 2,
repository: MotionRepositoryService repository: MotionRepositoryService
}, },
{ {
collectionString: 'motions/category',
model: Category, model: Category,
viewModel: ViewCategory, viewModel: ViewCategory,
searchOrder: 6, searchOrder: 6,
repository: CategoryRepositoryService repository: CategoryRepositoryService
}, },
{ {
collectionString: 'motions/workflow',
model: Workflow, model: Workflow,
viewModel: ViewWorkflow, viewModel: ViewWorkflow,
repository: WorkflowRepositoryService repository: WorkflowRepositoryService
}, },
{ {
collectionString: 'motions/state',
model: State, model: State,
viewModel: ViewState, viewModel: ViewState,
repository: StateRepositoryService repository: StateRepositoryService
}, },
{ {
collectionString: 'motions/motion-comment-section',
model: MotionCommentSection, model: MotionCommentSection,
viewModel: ViewMotionCommentSection, viewModel: ViewMotionCommentSection,
repository: MotionCommentSectionRepositoryService repository: MotionCommentSectionRepositoryService
}, },
{ {
collectionString: 'motions/motion-change-recommendation',
model: MotionChangeRecommendation, model: MotionChangeRecommendation,
viewModel: ViewMotionChangeRecommendation, viewModel: ViewMotionChangeRecommendation,
repository: ChangeRecommendationRepositoryService repository: ChangeRecommendationRepositoryService
}, },
{ {
collectionString: 'motions/motion-block',
model: MotionBlock, model: MotionBlock,
viewModel: ViewMotionBlock, viewModel: ViewMotionBlock,
searchOrder: 7, searchOrder: 7,
repository: MotionBlockRepositoryService repository: MotionBlockRepositoryService
}, },
{ {
collectionString: 'motions/statute-paragraph',
model: StatuteParagraph, model: StatuteParagraph,
viewModel: ViewStatuteParagraph, viewModel: ViewStatuteParagraph,
searchOrder: 9, searchOrder: 9,

View File

@ -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 { StatuteParagraphRepositoryService } from 'app/core/repositories/motions/statute-paragraph-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { LinenumberingService } from 'app/core/ui-services/linenumbering.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 { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change';
import { getRecommendationTypeName } from 'app/shared/utils/recommendation-type-names'; import { getRecommendationTypeName } from 'app/shared/utils/recommendation-type-names';
import { MotionExportInfo } from './motion-export.service'; import { MotionExportInfo } from './motion-export.service';
import { MotionPollService } from './motion-poll.service';
import { ChangeRecoMode, InfoToExport, LineNumberingMode, PERSONAL_NOTE_ID } from '../motions.constants'; import { ChangeRecoMode, InfoToExport, LineNumberingMode, PERSONAL_NOTE_ID } from '../motions.constants';
import { ViewMotion } from '../models/view-motion'; import { ViewMotion } from '../models/view-motion';
import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragraph'; import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragraph';
@ -62,7 +60,6 @@ export class MotionPdfService {
private configService: ConfigService, private configService: ConfigService,
private pdfDocumentService: PdfDocumentService, private pdfDocumentService: PdfDocumentService,
private htmlToPdfService: HtmlToPdfService, private htmlToPdfService: HtmlToPdfService,
private pollService: MotionPollService,
private linenumberingService: LinenumberingService, private linenumberingService: LinenumberingService,
private commentRepo: MotionCommentSectionRepositoryService private commentRepo: MotionCommentSectionRepositoryService
) {} ) {}
@ -366,7 +363,7 @@ export class MotionPdfService {
const column2 = []; const column2 = [];
const column3 = []; const column3 = [];
motion.motion.polls.map((poll, index) => { motion.motion.polls.map((poll, index) => {
if (poll.has_votes) { /*if (poll.has_votes) {
if (motion.motion.polls.length > 1) { if (motion.motion.polls.length > 1) {
column1.push(index + 1 + '. ' + this.translate.instant('Vote')); column1.push(index + 1 + '. ' + this.translate.instant('Vote'));
column2.push(''); column2.push('');
@ -389,7 +386,7 @@ export class MotionPdfService {
? column3.push('') ? column3.push('')
: column3.push(`(${this.pollService.calculatePercentage(poll, value)} %)`); : column3.push(`(${this.pollService.calculatePercentage(poll, value)} %)`);
}); });
} }*/
}); });
metaTableBody.push([ metaTableBody.push([
{ {

View File

@ -73,7 +73,7 @@ export class MotionPollService extends PollService {
* the base cannot be calculated * the base cannot be calculated
*/ */
public getBaseAmount(poll: MotionPoll): number { public getBaseAmount(poll: MotionPoll): number {
if (!poll) { /*if (!poll) {
return 0; return 0;
} }
switch (this.percentBase) { switch (this.percentBase) {
@ -102,7 +102,8 @@ export class MotionPollService extends PollService {
return 0; return 0;
} }
return poll.yes + poll.no; return poll.yes + poll.no;
} }*/
return 0;
} }
/** /**

View File

@ -16,25 +16,21 @@ export const ProjectorAppConfig: AppConfig = {
name: 'projector', name: 'projector',
models: [ models: [
{ {
collectionString: 'core/projector',
model: Projector, model: Projector,
viewModel: ViewProjector, viewModel: ViewProjector,
repository: ProjectorRepositoryService repository: ProjectorRepositoryService
}, },
{ {
collectionString: 'core/projection-default',
model: ProjectionDefault, model: ProjectionDefault,
viewModel: ViewProjectionDefault, viewModel: ViewProjectionDefault,
repository: ProjectionDefaultRepositoryService repository: ProjectionDefaultRepositoryService
}, },
{ {
collectionString: 'core/countdown',
model: Countdown, model: Countdown,
viewModel: ViewCountdown, viewModel: ViewCountdown,
repository: CountdownRepositoryService repository: CountdownRepositoryService
}, },
{ {
collectionString: 'core/projector-message',
model: ProjectorMessage, model: ProjectorMessage,
viewModel: ViewProjectorMessage, viewModel: ViewProjectorMessage,
repository: ProjectorMessageRepositoryService repository: ProjectorMessageRepositoryService

View File

@ -7,7 +7,6 @@ export const TagAppConfig: AppConfig = {
name: 'tag', name: 'tag',
models: [ models: [
{ {
collectionString: 'core/tag',
model: Tag, model: Tag,
viewModel: ViewTag, viewModel: ViewTag,
searchOrder: 8, searchOrder: 8,

View File

@ -7,7 +7,6 @@ export const TopicsAppConfig: AppConfig = {
name: 'topics', name: 'topics',
models: [ models: [
{ {
collectionString: 'topics/topic',
model: Topic, model: Topic,
viewModel: ViewTopic, viewModel: ViewTopic,
searchOrder: 1, searchOrder: 1,

View File

@ -13,15 +13,13 @@ export const UsersAppConfig: AppConfig = {
name: 'users', name: 'users',
models: [ models: [
{ {
collectionString: 'users/user',
model: User, model: User,
viewModel: ViewUser, viewModel: ViewUser,
searchOrder: 4, searchOrder: 4,
repository: UserRepositoryService repository: UserRepositoryService
}, },
{ collectionString: 'users/group', model: Group, viewModel: ViewGroup, repository: GroupRepositoryService }, { model: Group, viewModel: ViewGroup, repository: GroupRepositoryService },
{ {
collectionString: 'users/personal-note',
model: PersonalNote, model: PersonalNote,
viewModel: ViewPersonalNote, viewModel: ViewPersonalNote,
repository: PersonalNoteRepositoryService repository: PersonalNoteRepositoryService

View File

@ -1,5 +1,4 @@
import { PollVoteValue } from 'app/core/ui-services/poll.service'; import { PollVoteValue } from 'app/core/ui-services/poll.service';
import { AssignmentPercentBase, AssignmentPollMethod } from 'app/site/assignments/services/assignment-poll.service';
export interface PollSlideOption { export interface PollSlideOption {
user: string; user: string;
@ -12,12 +11,12 @@ export interface PollSlideOption {
export interface PollSlideData { export interface PollSlideData {
title: string; title: string;
assignments_poll_100_percent_base: AssignmentPercentBase; assignments_poll_100_percent_base: any /*AssignmentPercentBase*/;
poll: { poll: {
published: boolean; published: boolean;
description?: string; description?: string;
has_votes?: boolean; has_votes?: boolean;
pollmethod?: AssignmentPollMethod; pollmethod?: any /*AssignmentPollmethods*/;
votesno?: string; votesno?: string;
votesabstain?: string; votesabstain?: string;
votesvalid?: string; votesvalid?: string;

View File

@ -1,14 +1,7 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { SlideData } from 'app/core/core-services/projector-data.service'; import { SlideData } from 'app/core/core-services/projector-data.service';
import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.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 { BaseSlideComponent } from 'app/slides/base-slide-component';
import { PollSlideData, PollSlideOption } from './poll-slide-data'; import { PollSlideData, PollSlideOption } from './poll-slide-data';
@ -20,20 +13,19 @@ import { PollSlideData, PollSlideOption } from './poll-slide-data';
export class PollSlideComponent extends BaseSlideComponent<PollSlideData> { export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
private _data: SlideData<PollSlideData>; private _data: SlideData<PollSlideData>;
private calculationData: CalculationData; public get pollValues(): any {
// SummaryPollKey[] {
public get pollValues(): SummaryPollKey[] {
if (!this.data) { if (!this.data) {
return []; 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); return values.filter(val => this.data.data.poll[val] !== null);
} }
@Input() @Input()
public set data(data: SlideData<PollSlideData>) { public set data(data: SlideData<PollSlideData>) {
this._data = data; this._data = data;
this.calculationData = { /*this.calculationData = {
pollMethod: data.data.poll.pollmethod, pollMethod: data.data.poll.pollmethod,
votesno: parseFloat(data.data.poll.votesno), votesno: parseFloat(data.data.poll.votesno),
votesabstain: parseFloat(data.data.poll.votesabstain), votesabstain: parseFloat(data.data.poll.votesabstain),
@ -51,17 +43,13 @@ export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
}; };
}), }),
percentBase: data.data.assignments_poll_100_percent_base percentBase: data.data.assignments_poll_100_percent_base
}; };*/
} }
public get data(): SlideData<PollSlideData> { public get data(): SlideData<PollSlideData> {
return this._data; 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 * get a vote's numerical or special label, including percent values if these are to
* be displayed * be displayed
@ -70,7 +58,7 @@ export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
* @param option * @param option
*/ */
public getVotePercent(key: PollVoteValue, option: PollSlideOption): string { public getVotePercent(key: PollVoteValue, option: PollSlideOption): string {
const calcOption = { /*const calcOption = {
votes: option.votes.map(vote => { votes: option.votes.map(vote => {
return { weight: parseFloat(vote.weight), value: vote.value }; return { weight: parseFloat(vote.weight), value: vote.value };
}) })
@ -79,19 +67,22 @@ export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
const number = this.translate.instant( const number = this.translate.instant(
this.pollService.getSpecialLabel(parseFloat(option.votes.find(v => v.value === key).weight)) 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 { 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])); 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 * @returns a translated label for a key
*/ */
public getLabel(key: CalculablePollKey): string { 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');
} }
} }

View File

@ -21,16 +21,22 @@ class AgendaItemMixin(models.Model):
class Meta(Unsafe): class Meta(Unsafe):
abstract = True 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 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 @property
def agenda_item(self): def agenda_item(self):
""" """

View File

@ -35,7 +35,6 @@ def listen_to_related_object_post_save(sender, instance, created, **kwargs):
if is_agenda_item_content_object: if is_agenda_item_content_object:
if created: if created:
if instance.get_collection_string() == "topics/topic": if instance.get_collection_string() == "topics/topic":
should_create_item = True should_create_item = True
elif config["agenda_item_creation"] == "always": elif config["agenda_item_creation"] == "always":

View File

@ -42,3 +42,12 @@ class AssignmentAccessPermissions(BaseAccessPermissions):
data = [] data = []
return 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

View File

@ -15,7 +15,11 @@ class AssignmentsAppConfig(AppConfig):
from . import serializers # noqa from . import serializers # noqa
from .projector import register_projector_slides from .projector import register_projector_slides
from .signals import get_permission_change_data from .signals import get_permission_change_data
from .views import AssignmentViewSet, AssignmentPollViewSet from .views import (
AssignmentViewSet,
AssignmentPollViewSet,
AssignmentVoteViewSet,
)
# Define projector elements. # Define projector elements.
register_projector_slides() register_projector_slides()
@ -30,7 +34,14 @@ class AssignmentsAppConfig(AppConfig):
router.register( router.register(
self.get_model("Assignment").get_collection_string(), AssignmentViewSet 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 # Register required_users
required_user.add_collection_string( required_user.add_collection_string(
@ -47,13 +58,16 @@ class AssignmentsAppConfig(AppConfig):
Yields all Cachables required on startup i. e. opening the websocket Yields all Cachables required on startup i. e. opening the websocket
connection. 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]: def required_users(element: Dict[str, Any]) -> Set[int]:
""" """
Returns all user ids that are displayed as candidates (including poll Returns all user ids that are displayed as candidates (including poll
options) in the assignment element. options) in the assignment element.
TODO: Adapt this method for new poll structure!!
""" """
candidates = set( candidates = set(
related_user["user_id"] for related_user in element["assignment_related_users"] related_user["user_id"] for related_user in element["assignment_related_users"]

View File

@ -12,25 +12,6 @@ def get_config_variables():
to be evaluated during app loading (see apps.py). to be evaluated during app loading (see apps.py).
""" """
# Ballot and ballot papers # 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( yield ConfigVariable(
name="assignments_poll_100_percent_base", name="assignments_poll_100_percent_base",
default_value="YES_NO_ABSTAIN", default_value="YES_NO_ABSTAIN",

View File

@ -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"),
]

View File

@ -1,5 +1,4 @@
from collections import OrderedDict from collections import OrderedDict
from decimal import Decimal
from typing import Any, Dict, List from typing import Any, Dict, List
from django.conf import settings from django.conf import settings
@ -11,19 +10,16 @@ from openslides.agenda.models import Speaker
from openslides.core.config import config from openslides.core.config import config
from openslides.core.models import Tag from openslides.core.models import Tag
from openslides.mediafiles.models import Mediafile from openslides.mediafiles.models import Mediafile
from openslides.poll.models import ( from openslides.poll.models import BaseOption, BasePoll, BaseVote
BaseOption,
BasePoll,
BaseVote,
CollectDefaultVotesMixin,
PublishPollMixin,
)
from openslides.utils.autoupdate import inform_changed_data from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.models import RESTModelMixin from openslides.utils.models import RESTModelMixin
from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE 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): class AssignmentRelatedUser(RESTModelMixin, models.Model):
@ -196,7 +192,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
""" """
return self.elected.filter(pk=user.pk).exists() return self.elected.filter(pk=user.pk).exists()
def set_candidate(self, user): def add_candidate(self, user):
""" """
Adds the user as candidate. Adds the user as candidate.
""" """
@ -215,7 +211,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
user=user, defaults={"elected": True} 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. Delete the connection from the assignment to the user.
""" """
@ -233,59 +229,6 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
self.phase = phase 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): def vote_results(self, only_published):
""" """
Returns a table represented as a list with all candidates from all Returns a table represented as a list with all candidates from all
@ -332,88 +275,76 @@ class AssignmentVote(RESTModelMixin, BaseVote):
class Meta: class Meta:
default_permissions = () 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): class AssignmentOption(RESTModelMixin, BaseOption):
vote_class = AssignmentVote
poll = models.ForeignKey( poll = models.ForeignKey(
"AssignmentPoll", on_delete=models.CASCADE, related_name="options" "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 settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True
) )
weight = models.IntegerField(default=0) weight = models.IntegerField(default=0)
vote_class = AssignmentVote
class Meta: class Meta:
default_permissions = () default_permissions = ()
def __str__(self):
return str(self.candidate)
def get_root_rest_element(self): def get_root_rest_element(self):
""" return self.poll
Returns the assignment to this instance which is the root REST element.
"""
return self.poll.assignment
# Meta-TODO: Is this todo resolved?
# TODO: remove the type-ignoring in the next line, after this is solved: # TODO: remove the type-ignoring in the next line, after this is solved:
# https://github.com/python/mypy/issues/3855 # https://github.com/python/mypy/issues/3855
class AssignmentPoll( # type: ignore class AssignmentPoll(RESTModelMixin, BasePoll):
RESTModelMixin, CollectDefaultVotesMixin, PublishPollMixin, BasePoll access_permissions = AssignmentPollAccessPermissions()
):
option_class = AssignmentOption option_class = AssignmentOption
assignment = models.ForeignKey( assignment = models.ForeignKey(
Assignment, on_delete=models.CASCADE, related_name="polls" 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( POLLMETHOD_YN = "YN"
null=True, POLLMETHOD_YNA = "YNA"
blank=True, POLLMETHOD_VOTES = "votes"
validators=[MinValueValidator(Decimal("-2"))], POLLMETHODS = (("YN", "YN"), ("YNA", "YNA"), ("votes", "votes"))
max_digits=15, pollmethod = models.CharField(max_length=5, choices=POLLMETHODS)
decimal_places=6,
) global_abstain = models.BooleanField(default=True)
""" General abstain votes, used for pollmethod 'votes' """ global_no = models.BooleanField(default=True)
votesno = models.DecimalField(
null=True, votes_amount = models.IntegerField(default=1, validators=[MinValueValidator(1)])
blank=True, """ For "votes" mode: The amount of votes a voter can give. """
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15, allow_multiple_votes_per_candidate = models.BooleanField(default=False)
decimal_places=6,
)
""" General no votes, used for pollmethod 'votes' """
class Meta: class Meta:
default_permissions = () default_permissions = ()
def get_assignment(self): def create_options(self):
return self.assignment 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): # Add all candidates to list of speakers of related agenda item
if self.pollmethod == "yna": if config["assignments_add_candidates_to_list_of_speakers"]:
return ["Yes", "No", "Abstain"] for related_user in related_users:
if self.pollmethod == "yn": try:
return ["Yes", "No"] Speaker.objects.add(
return ["Votes"] related_user.user,
self.assignment.list_of_speakers,
def get_ballot(self): skip_autoupdate=True,
return self.assignment.polls.filter(id__lte=self.pk).count() )
except OpenSlidesError:
def get_percent_base_choice(self): # The Speaker is already on the list. Do nothing.
return config["assignments_poll_100_percent_base"] pass
inform_changed_data(self.assignment.list_of_speakers)
def get_root_rest_element(self):
"""
Returns the assignment to this instance which is the root REST element.
"""
return self.assignment

View File

@ -1,18 +1,20 @@
from django.db import transaction from openslides.poll.serializers import (
BASE_OPTION_FIELDS,
from openslides.poll.serializers import default_votes_validator BASE_POLL_FIELDS,
BASE_VOTE_FIELDS,
)
from openslides.utils.rest_api import ( from openslides.utils.rest_api import (
BooleanField, BooleanField,
CharField,
DecimalField, DecimalField,
DictField, IdPrimaryKeyRelatedField,
IntegerField, IntegerField,
ListField,
ModelSerializer, ModelSerializer,
SerializerMethodField, SerializerMethodField,
ValidationError, 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.autoupdate import inform_changed_data
from ..utils.validate import validate_html from ..utils.validate import validate_html
from .models import ( from .models import (
@ -42,13 +44,7 @@ class AssignmentRelatedUserSerializer(ModelSerializer):
class Meta: class Meta:
model = AssignmentRelatedUser model = AssignmentRelatedUser
fields = ( fields = ("id", "user", "elected", "weight")
"id",
"user",
"elected",
"assignment",
"weight",
) # js-data needs the assignment-id in the nested object to define relations.
class AssignmentVoteSerializer(ModelSerializer): class AssignmentVoteSerializer(ModelSerializer):
@ -56,9 +52,15 @@ class AssignmentVoteSerializer(ModelSerializer):
Serializer for assignment.models.AssignmentVote objects. Serializer for assignment.models.AssignmentVote objects.
""" """
pollstate = SerializerMethodField()
class Meta: class Meta:
model = AssignmentVote 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): class AssignmentOptionSerializer(ModelSerializer):
@ -66,24 +68,21 @@ class AssignmentOptionSerializer(ModelSerializer):
Serializer for assignment.models.AssignmentOption objects. Serializer for assignment.models.AssignmentOption objects.
""" """
votes = AssignmentVoteSerializer(many=True, read_only=True) yes = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True)
is_elected = SerializerMethodField() 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: class Meta:
model = AssignmentOption model = AssignmentOption
fields = ("id", "candidate", "is_elected", "votes", "poll", "weight") fields = ("user",) + BASE_OPTION_FIELDS
read_only_fields = ("user",) + BASE_OPTION_FIELDS
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)
class AssignmentAllPollSerializer(ModelSerializer): class AssignmentPollSerializer(ModelSerializer):
""" """
Serializer for assignment.models.AssignmentPoll objects. Serializer for assignment.models.AssignmentPoll objects.
@ -91,103 +90,42 @@ class AssignmentAllPollSerializer(ModelSerializer):
""" """
options = AssignmentOptionSerializer(many=True, read_only=True) options = AssignmentOptionSerializer(many=True, read_only=True)
votes = ListField(
child=DictField( title = CharField(allow_blank=False, required=True)
child=DecimalField(max_digits=15, decimal_places=6, min_value=-2) groups = IdPrimaryKeyRelatedField(
), many=True, required=False, queryset=get_group_model().objects.all()
write_only=True, )
required=False, 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: class Meta:
model = AssignmentPoll model = AssignmentPoll
fields = ( fields = (
"id",
"pollmethod",
"description",
"published",
"options",
"votesabstain",
"votesno",
"votesvalid",
"votesinvalid",
"votescast",
"votes",
"has_votes",
"assignment", "assignment",
) # js-data needs the assignment-id in the nested object to define relations. "pollmethod",
read_only_fields = ("pollmethod",) "votes_amount",
validators = (default_votes_validator,) "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): def update(self, instance, validated_data):
""" """ Prevent from updating the assignment """
Customized update method for polls. To update votes use the write validated_data.pop("assignment", None)
only field 'votes'. return super().update(instance, validated_data)
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
class AssignmentFullSerializer(ModelSerializer): class AssignmentSerializer(ModelSerializer):
""" """
Serializer for assignment.models.Assignment objects. With all polls. Serializer for assignment.models.Assignment objects. With all polls.
""" """
@ -195,7 +133,7 @@ class AssignmentFullSerializer(ModelSerializer):
assignment_related_users = AssignmentRelatedUserSerializer( assignment_related_users = AssignmentRelatedUserSerializer(
many=True, read_only=True 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_create = BooleanField(write_only=True, required=False, allow_null=True)
agenda_type = IntegerField( agenda_type = IntegerField(
write_only=True, required=False, min_value=1, max_value=3, allow_null=True write_only=True, required=False, min_value=1, max_value=3, allow_null=True

View File

@ -1,21 +1,21 @@
from decimal import Decimal
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction 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.autoupdate import inform_changed_data
from openslides.utils.rest_api import ( from openslides.utils.rest_api import (
DestroyModelMixin,
GenericViewSet,
ModelViewSet, ModelViewSet,
Response, Response,
UpdateModelMixin,
ValidationError, ValidationError,
detail_route, detail_route,
) )
from openslides.utils.utils import is_int
from ..utils.auth import has_perm
from .access_permissions import AssignmentAccessPermissions from .access_permissions import AssignmentAccessPermissions
from .models import Assignment, AssignmentPoll, AssignmentRelatedUser from .models import Assignment, AssignmentPoll, AssignmentRelatedUser, AssignmentVote
from .serializers import AssignmentAllPollSerializer
# Viewsets for the REST API # Viewsets for the REST API
@ -48,7 +48,6 @@ class AssignmentViewSet(ModelViewSet):
"update", "update",
"destroy", "destroy",
"mark_elected", "mark_elected",
"create_poll",
"sort_related_users", "sort_related_users",
): ):
result = has_perm(self.request.user, "assignments.can_see") and has_perm( 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. # To nominate self during voting you have to be a manager.
self.permission_denied(request) self.permission_denied(request)
# If the request.user is already a candidate he can nominate himself nevertheless. # 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 # Send new candidate via autoupdate because users without permission
# to see users may not have it but can get it now. # to see users may not have it but can get it now.
inform_changed_data([request.user]) inform_changed_data([request.user])
@ -121,7 +120,7 @@ class AssignmentViewSet(ModelViewSet):
raise ValidationError( raise ValidationError(
{"detail": "You are not a candidate of this election."} {"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." return "You have withdrawn your candidature successfully."
def get_user_from_request_data(self, request): def get_user_from_request_data(self, request):
@ -186,7 +185,7 @@ class AssignmentViewSet(ModelViewSet):
raise ValidationError( raise ValidationError(
{"detail": "User {0} is already nominated.", "args": [str(user)]} {"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 # Send new candidate via autoupdate because users without permission
# to see users may not have it but can get it now. # to see users may not have it but can get it now.
inform_changed_data(user) inform_changed_data(user)
@ -211,7 +210,7 @@ class AssignmentViewSet(ModelViewSet):
"args": [str(user)], "args": [str(user)],
} }
) )
assignment.delete_related_user(user) assignment.remove_candidate(user)
return Response( return Response(
{"detail": "Candidate {0} was withdrawn successfully.", "args": [str(user)]} {"detail": "Candidate {0} was withdrawn successfully.", "args": [str(user)]}
) )
@ -243,7 +242,7 @@ class AssignmentViewSet(ModelViewSet):
"args": [str(user)], "args": [str(user)],
} }
) )
assignment.set_candidate(user) assignment.add_candidate(user)
message = "User {0} was successfully unelected." message = "User {0} was successfully unelected."
return Response({"detail": message, "args": [str(user)]}) return Response({"detail": message, "args": [str(user)]})
@ -309,7 +308,7 @@ class AssignmentViewSet(ModelViewSet):
return Response({"detail": "Assignment related users successfully sorted."}) return Response({"detail": "Assignment related users successfully sorted."})
class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): class AssignmentPollViewSet(BasePollViewSet):
""" """
API endpoint for assignment polls. API endpoint for assignment polls.
@ -317,12 +316,246 @@ class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet)
""" """
queryset = AssignmentPoll.objects.all() 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. Returns True if the user has required permissions.
""" """
return has_perm(self.request.user, "assignments.can_see") and has_perm( return has_perm(self.request.user, "assignments.can_see") and has_perm(
self.request.user, "assignments.can_manage" 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": {<option_id>: {"Y": <amount>, ["N": <amount>], ["A": <amount>] }},
["votesvalid": <amount>], ["votesinvalid": <amount>], ["votescast": <amount>],
["global_no": <amount>], ["global_abstain": <amount>]
}
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:
{<option_id>: <amount>} | '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:
{<option_id>: '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")

View File

@ -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")]

View File

@ -273,7 +273,6 @@ class HistoryManager(models.Manager):
), ),
now=history_time, now=history_time,
information=element.get("information", []), information=element.get("information", []),
restricted=element.get("restricted", False),
user_id=element.get("user_id"), user_id=element.get("user_id"),
full_data=data, full_data=data,
) )
@ -324,8 +323,6 @@ class History(models.Model):
information = JSONField() information = JSONField()
restricted = models.BooleanField(default=False)
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
) )

View File

@ -179,3 +179,64 @@ class StateAccessPermissions(BaseAccessPermissions):
""" """
base_permission = "motions.can_see" 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

View File

@ -20,6 +20,7 @@ class MotionsAppConfig(AppConfig):
StatuteParagraphViewSet, StatuteParagraphViewSet,
MotionViewSet, MotionViewSet,
MotionCommentSectionViewSet, MotionCommentSectionViewSet,
MotionVoteViewSet,
MotionBlockViewSet, MotionBlockViewSet,
MotionPollViewSet, MotionPollViewSet,
MotionChangeRecommendationViewSet, MotionChangeRecommendationViewSet,
@ -66,6 +67,9 @@ class MotionsAppConfig(AppConfig):
router.register( router.register(
self.get_model("MotionPoll").get_collection_string(), MotionPollViewSet 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) router.register(self.get_model("State").get_collection_string(), StateViewSet)
# Register required_users # Register required_users
@ -92,6 +96,8 @@ class MotionsAppConfig(AppConfig):
"State", "State",
"MotionChangeRecommendation", "MotionChangeRecommendation",
"MotionCommentSection", "MotionCommentSection",
"MotionPoll",
"MotionVote",
): ):
yield self.get_model(model_name) yield self.get_model(model_name)

View File

@ -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",
},
),
]

View File

@ -8,12 +8,7 @@ from openslides.agenda.mixins import AgendaItemWithListOfSpeakersMixin
from openslides.core.config import config from openslides.core.config import config
from openslides.core.models import Tag from openslides.core.models import Tag
from openslides.mediafiles.models import Mediafile from openslides.mediafiles.models import Mediafile
from openslides.poll.models import ( from openslides.poll.models import BaseOption, BasePoll, BaseVote
BaseOption,
BasePoll,
BaseVote,
CollectDefaultVotesMixin,
)
from openslides.utils.autoupdate import inform_changed_data from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.models import RESTModelMixin from openslides.utils.models import RESTModelMixin
@ -26,6 +21,8 @@ from .access_permissions import (
MotionBlockAccessPermissions, MotionBlockAccessPermissions,
MotionChangeRecommendationAccessPermissions, MotionChangeRecommendationAccessPermissions,
MotionCommentSectionAccessPermissions, MotionCommentSectionAccessPermissions,
MotionPollAccessPermissions,
MotionVoteAccessPermissions,
StateAccessPermissions, StateAccessPermissions,
StatuteParagraphAccessPermissions, StatuteParagraphAccessPermissions,
WorkflowAccessPermissions, WorkflowAccessPermissions,
@ -80,6 +77,11 @@ class MotionManager(models.Manager):
"agenda_items", "agenda_items",
"lists_of_speakers", "lists_of_speakers",
"polls", "polls",
"polls__groups",
"polls__voted",
"polls__options",
"polls__options__votes",
"polls__options__votes__user",
"attachments", "attachments",
"tags", "tags",
"submitters", "submitters",
@ -269,6 +271,7 @@ class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model):
("can_support", "Can support motions"), ("can_support", "Can support motions"),
("can_manage_metadata", "Can manage motion metadata"), ("can_manage_metadata", "Can manage motion metadata"),
("can_manage", "Can manage motions"), ("can_manage", "Can manage motions"),
("can_manage_polls", "Can manage motion polls"),
) )
ordering = ("identifier",) ordering = ("identifier",)
verbose_name = "Motion" verbose_name = "Motion"
@ -424,22 +427,6 @@ class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model):
""" """
return user in self.supporters.all() 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 @property
def workflow_id(self): def workflow_id(self):
""" """
@ -881,82 +868,48 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode
class MotionVote(RESTModelMixin, BaseVote): class MotionVote(RESTModelMixin, BaseVote):
"""Saves the votes for a MotionPoll. access_permissions = MotionVoteAccessPermissions()
option = models.ForeignKey(
There should allways be three MotionVote objects for each poll, "MotionOption", on_delete=models.CASCADE, related_name="votes"
one for 'yes', 'no', and 'abstain'.""" )
option = models.ForeignKey("MotionOption", on_delete=models.CASCADE)
"""The option object, to witch the vote belongs."""
class Meta: class Meta:
default_permissions = () 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): 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 vote_class = MotionVote
"""The VoteClass, to witch this Class links."""
poll = models.ForeignKey(
"MotionPoll", related_name="options", on_delete=models.CASCADE
)
class Meta: class Meta:
default_permissions = () default_permissions = ()
def get_root_rest_element(self): def get_root_rest_element(self):
""" return self.poll
Returns the motion to this instance which is the root REST element.
"""
return self.poll.motion
# Meta-TODO: Is this todo resolved?
# TODO: remove the type-ignoring in the next line, after this is solved: # TODO: remove the type-ignoring in the next line, after this is solved:
# https://github.com/python/mypy/issues/3855 # https://github.com/python/mypy/issues/3855
class MotionPoll(RESTModelMixin, CollectDefaultVotesMixin, BasePoll): # type: ignore class MotionPoll(RESTModelMixin, BasePoll):
"""The Class to saves the vote result for a motion poll.""" access_permissions = MotionPollAccessPermissions()
option_class = MotionOption
motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls") motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls")
"""The motion to witch the object belongs."""
option_class = MotionOption POLLMETHOD_YN = "YN"
"""The option class, witch links between this object the the votes.""" POLLMETHOD_YNA = "YNA"
POLLMETHODS = (("YN", "YN"), ("YNA", "YNA"))
vote_values = ["Yes", "No", "Abstain"] pollmethod = models.CharField(max_length=3, choices=POLLMETHODS)
"""The possible anwers for the poll. 'Yes, 'No' and 'Abstain'."""
class Meta: class Meta:
default_permissions = () default_permissions = ()
def __str__(self): def create_options(self):
""" MotionOption.objects.create(poll=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
class State(RESTModelMixin, models.Model): class State(RESTModelMixin, models.Model):

View File

@ -1,17 +1,19 @@
from typing import Dict, Optional
import jsonschema import jsonschema
from django.db import transaction 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 ..core.config import config
from ..poll.serializers import default_votes_validator
from ..utils.auth import get_group_model, has_perm from ..utils.auth import get_group_model, has_perm
from ..utils.autoupdate import inform_changed_data from ..utils.autoupdate import inform_changed_data
from ..utils.rest_api import ( from ..utils.rest_api import (
BooleanField, BooleanField,
CharField, CharField,
DecimalField, DecimalField,
DictField,
Field, Field,
IdPrimaryKeyRelatedField, IdPrimaryKeyRelatedField,
IntegerField, IntegerField,
@ -28,7 +30,9 @@ from .models import (
MotionChangeRecommendation, MotionChangeRecommendation,
MotionComment, MotionComment,
MotionCommentSection, MotionCommentSection,
MotionOption,
MotionPoll, MotionPoll,
MotionVote,
State, State,
StatuteParagraph, StatuteParagraph,
Submitter, Submitter,
@ -220,116 +224,65 @@ class AmendmentParagraphsJSONSerializerField(Field):
return data 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): class MotionPollSerializer(ModelSerializer):
""" """
Serializer for motion.models.MotionPoll objects. Serializer for motion.models.MotionPoll objects.
""" """
yes = SerializerMethodField() options = MotionOptionSerializer(many=True, read_only=True)
no = SerializerMethodField()
abstain = SerializerMethodField() title = CharField(allow_blank=False, required=True)
votes = DictField( groups = IdPrimaryKeyRelatedField(
child=DecimalField( many=True, required=False, queryset=get_group_model().objects.all()
max_digits=15, decimal_places=6, min_value=-2, allow_null=True )
), voted = IdPrimaryKeyRelatedField(many=True, read_only=True)
write_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: class Meta:
model = MotionPoll model = MotionPoll
fields = ( fields = ("motion", "pollmethod") + BASE_POLL_FIELDS
"id", read_only_fields = ("state",)
"motion",
"yes",
"no",
"abstain",
"votesvalid",
"votesinvalid",
"votescast",
"votes",
"has_votes",
)
validators = (default_votes_validator,)
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): def update(self, instance, validated_data):
""" """ Prevent from updating the motion """
Customized update method for polls. To update votes use the write validated_data.pop("motion", None)
only field 'votes'. return super().update(instance, validated_data)
Example data:
"votes": {"Yes": 10, "No": 4, "Abstain": -2}
"""
# Update votes.
votes = validated_data.get("votes")
if votes:
if len(votes) != len(instance.get_vote_values()):
raise ValidationError(
{
"detail": "You have to submit data for {0} vote values.",
"args": [len(instance.get_vote_values())],
}
)
for vote_value in votes.keys():
if vote_value not in instance.get_vote_values():
raise ValidationError(
{"detail": "Vote value {0} is invalid.", "args": [vote_value]}
)
instance.set_vote_objects_with_values(
instance.get_options().get(), votes, skip_autoupdate=True
)
# Update remaining writeable fields.
instance.votesvalid = validated_data.get("votesvalid", instance.votesvalid)
instance.votesinvalid = validated_data.get(
"votesinvalid", instance.votesinvalid
)
instance.votescast = validated_data.get("votescast", instance.votescast)
instance.save()
return instance
class MotionChangeRecommendationSerializer(ModelSerializer): class MotionChangeRecommendationSerializer(ModelSerializer):
@ -418,7 +371,7 @@ class MotionSerializer(ModelSerializer):
""" """
comments = MotionCommentSerializer(many=True, read_only=True) 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) modified_final_version = CharField(allow_blank=True, required=False)
reason = CharField(allow_blank=True, required=False) reason = CharField(allow_blank=True, required=False)
state_restriction = SerializerMethodField() state_restriction = SerializerMethodField()

View File

@ -1,3 +1,4 @@
from decimal import Decimal
from typing import List, Set from typing import List, Set
import jsonschema import jsonschema
@ -8,17 +9,16 @@ from django.db.models.deletion import ProtectedError
from django.http.request import QueryDict from django.http.request import QueryDict
from rest_framework import status from rest_framework import status
from openslides.poll.views import BasePollViewSet, BaseVoteViewSet
from ..core.config import config from ..core.config import config
from ..core.models import Tag from ..core.models import Tag
from ..utils.auth import has_perm, in_some_groups from ..utils.auth import has_perm, in_some_groups
from ..utils.autoupdate import inform_changed_data, inform_deleted_data from ..utils.autoupdate import inform_changed_data, inform_deleted_data
from ..utils.rest_api import ( from ..utils.rest_api import (
DestroyModelMixin,
GenericViewSet,
ModelViewSet, ModelViewSet,
Response, Response,
ReturnDict, ReturnDict,
UpdateModelMixin,
ValidationError, ValidationError,
detail_route, detail_route,
list_route, list_route,
@ -34,7 +34,6 @@ from .access_permissions import (
StatuteParagraphAccessPermissions, StatuteParagraphAccessPermissions,
WorkflowAccessPermissions, WorkflowAccessPermissions,
) )
from .exceptions import WorkflowError
from .models import ( from .models import (
Category, Category,
Motion, Motion,
@ -43,13 +42,13 @@ from .models import (
MotionComment, MotionComment,
MotionCommentSection, MotionCommentSection,
MotionPoll, MotionPoll,
MotionVote,
State, State,
StatuteParagraph, StatuteParagraph,
Submitter, Submitter,
Workflow, Workflow,
) )
from .numbering import numbering from .numbering import numbering
from .serializers import MotionPollSerializer
# Viewsets for the REST API # Viewsets for the REST API
@ -87,7 +86,6 @@ class MotionViewSet(TreeSortMixin, ModelViewSet):
"follow_recommendation", "follow_recommendation",
"manage_multiple_submitters", "manage_multiple_submitters",
"manage_multiple_tags", "manage_multiple_tags",
"create_poll",
): ):
result = has_perm(self.request.user, "motions.can_see") and has_perm( result = has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage_metadata" self.request.user, "motions.can_manage_metadata"
@ -400,9 +398,7 @@ class MotionViewSet(TreeSortMixin, ModelViewSet):
message = ["Comment {arg1} deleted", section.name] message = ["Comment {arg1} deleted", section.name]
# Fire autoupdate again to save information to OpenSlides history. # Fire autoupdate again to save information to OpenSlides history.
inform_changed_data( inform_changed_data(motion, information=message, user_id=request.user.pk)
motion, information=message, user_id=request.user.pk, restricted=True
)
return Response({"detail": message}) return Response({"detail": message})
@ -1042,31 +1038,6 @@ class MotionViewSet(TreeSortMixin, ModelViewSet):
return Response({"detail": "Recommendation followed successfully."}) 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"]) @list_route(methods=["post"])
@transaction.atomic @transaction.atomic
def manage_multiple_tags(self, request): 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. API endpoint for motion polls.
@ -1145,9 +1116,8 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
""" """
queryset = MotionPoll.objects.all() 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. Returns True if the user has required permissions.
""" """
@ -1155,16 +1125,30 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
self.request.user, "motions.can_manage_metadata" 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): def update(self, *args, **kwargs):
""" """
Customized view endpoint to update a motion poll. Customized view endpoint to update a motion poll.
""" """
response = super().update(*args, **kwargs) response = super().update(*args, **kwargs)
poll = self.get_object()
# Fire autoupdate again to save information to OpenSlides history. # Fire autoupdate again to save information to OpenSlides history.
poll = self.get_object()
inform_changed_data( 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 return response
@ -1178,11 +1162,75 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
# Fire autoupdate again to save information to OpenSlides history. # Fire autoupdate again to save information to OpenSlides history.
inform_changed_data( 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 return result
def handle_analog_vote(self, data, poll, user):
"""
Request data:
{ "Y": <amount>, "N": <amount>, ["A": <amount>],
["votesvalid": <amount>], ["votesinvalid": <amount>], ["votescast": <amount>]}
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): class MotionChangeRecommendationViewSet(ModelViewSet):
""" """
@ -1620,7 +1668,6 @@ class StateViewSet(ModelViewSet, ProtectedErrorMessageMixin):
""" """
queryset = State.objects.all() queryset = State.objects.all()
# serializer_class = StateSerializer
access_permissions = StateAccessPermissions() access_permissions = StateAccessPermissions()
def check_view_permissions(self): def check_view_permissions(self):

View File

@ -1,20 +1,44 @@
import locale
from decimal import Decimal from decimal import Decimal
from typing import Optional, Type from typing import Optional, Type
from django.core.exceptions import ObjectDoesNotExist from django.conf import settings
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models 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): class BaseOption(models.Model):
""" """
Base option class for a poll. All subclasses must have poll attribute with the related name "options"
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.
""" """
vote_class: Optional[Type["BaseVote"]] = None vote_class: Optional[Type["BaseVote"]] = None
@ -22,146 +46,135 @@ class BaseOption(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def get_votes(self): @property
return self.get_vote_class().objects.filter(option=self) def yes(self) -> Decimal:
return self.sum_weight("Y")
def get_vote_class(self): @property
if self.vote_class is None: 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( 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): def get_root_rest_element(self):
try: return self.poll.get_root_rest_element()
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()
class BasePoll(models.Model): class BasePoll(models.Model):
""" option_class: Optional[Type["BaseOption"]] = None
Base poll class.
"""
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: class Meta:
abstract = True abstract = True
def has_votes(self): def get_votesvalid(self):
""" if self.type == self.TYPE_ANALOG:
Returns True if there are votes in the poll. return self.db_votesvalid
""" else:
if self.get_votes().exists(): return Decimal(self.count_users_voted())
return True
return False
def set_options(self, options_data=None, skip_autoupdate=False): def set_votesvalid(self, value):
""" if self.type != self.TYPE_ANALOG:
Adds new option objects to the poll. raise ValueError("Do not set votesvalid for non analog polls")
self.db_votesvalid = value
option_data: A list of arguments for the option. votesvalid = property(get_votesvalid, set_votesvalid)
"""
if options_data is None:
options_data = []
for option_data in options_data: def get_votesinvalid(self):
option = self.get_option_class()(**option_data) if self.type == self.TYPE_ANALOG:
option.poll = self return self.db_votesinvalid
option.save(skip_autoupdate=skip_autoupdate) 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): def get_options(self):
""" """
@ -169,75 +182,49 @@ class BasePoll(models.Model):
""" """
return self.get_option_class().objects.filter(poll=self) return self.get_option_class().objects.filter(poll=self)
def get_option_class(self): def create_options(self):
""" """ Should be called after creation of this model. """
Returns the option class for the poll. Default is self.option_class. raise NotImplementedError()
"""
return self.option_class
def get_vote_values(self): @classmethod
""" def get_option_class(cls):
Returns the possible values for the poll. Default is as list. if cls.option_class is None:
""" raise NotImplementedError(
return self.vote_values f"The poll class {cls} has to have an attribute option_class."
)
return cls.option_class
def get_vote_class(self): @classmethod
""" def get_vote_class(cls):
Returns the related vote class. return cls.get_option_class().get_vote_class()
"""
return self.get_option_class().vote_class
def get_votes(self): def get_votes(self):
""" """
Return a QuerySet with all vote objects related to this poll. 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) return self.get_vote_class().objects.filter(option__poll__id=self.id)
def set_vote_objects_with_values(self, option, data, skip_autoupdate=False): def pseudoanonymize(self):
""" for vote in self.get_votes():
Creates or updates the vote objects for the poll. vote.user = None
""" vote.save()
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 get_vote_objects_with_values(self, option_id): def reset(self):
""" self.voted.clear()
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
# 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): # Reset state
""" self.state = BasePoll.STATE_CREATED
Returns a human readable string for the vote value. It is 'majority', if self.type == self.TYPE_ANALOG:
'undocumented' or the vote value with percent value if so. self.votesvalid = None
""" self.votesinvalid = None
if value == -1: self.votescast = None
verbose_value = "majority" self.save()
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

View File

@ -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): BASE_VOTE_FIELDS = ("id", "weight", "value", "user")
"""
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

165
openslides/poll/views.py Normal file
View File

@ -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

View File

@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db.models import Model from django.db.models import Model
from django.db.models.query import QuerySet
from .cache import element_cache from .cache import element_cache
@ -111,28 +112,40 @@ async def async_has_perm(user_id: int, perm: str) -> bool:
return has_perm 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 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 of ids or a QuerySet.
is always true, even if no groups are given.
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. user_id 0 means anonymous user.
""" """
# Convert user to right type # Convert user to right type
# TODO: Remove this and make use, that user has always the right type # TODO: Remove this and make use, that user has always the right type
user_id = user_to_user_id(user_id) 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 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. is always true, even if no groups are given.
user_id 0 means anonymous user. 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(): if not user_id and not await async_anonymous_is_enabled():
in_some_groups = False in_some_groups = False
elif not user_id: 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: if user_data is None:
raise UserDoesNotExist() 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. # User in admin group (pk 2) grants all permissions.
in_some_groups = True in_some_groups = True
else: else:

View File

@ -28,13 +28,18 @@ class Element(ElementBase, total=False):
if full_data is None, it means, that the element was deleted. If reload is 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 True, full_data is ignored and reloaded from the database later in the
process. 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] information: List[str]
restricted: bool
user_id: Optional[int] user_id: Optional[int]
disable_history: bool disable_history: bool
reload: bool reload: bool
no_delete_on_restriction: bool
AutoupdateFormat = TypedDict( AutoupdateFormat = TypedDict(
@ -53,7 +58,7 @@ def inform_changed_data(
instances: Union[Iterable[Model], Model], instances: Union[Iterable[Model], Model],
information: List[str] = None, information: List[str] = None,
user_id: Optional[int] = None, user_id: Optional[int] = None,
restricted: bool = False, no_delete_on_restriction: bool = False,
) -> None: ) -> None:
""" """
Informs the autoupdate system and the caching system about the creation or 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(), collection_string=root_instance.get_collection_string(),
full_data=root_instance.get_full_data(), full_data=root_instance.get_full_data(),
information=information, information=information,
restricted=restricted,
user_id=user_id, user_id=user_id,
no_delete_on_restriction=no_delete_on_restriction,
) )
bundle = autoupdate_bundle.get(threading.get_ident()) bundle = autoupdate_bundle.get(threading.get_ident())
@ -101,7 +106,6 @@ def inform_deleted_data(
deleted_elements: Iterable[Tuple[str, int]], deleted_elements: Iterable[Tuple[str, int]],
information: List[str] = None, information: List[str] = None,
user_id: Optional[int] = None, user_id: Optional[int] = None,
restricted: bool = False,
) -> None: ) -> None:
""" """
Informs the autoupdate system and the caching system about the deletion of 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], collection_string=deleted_element[0],
full_data=None, full_data=None,
information=information, information=information,
restricted=restricted,
user_id=user_id, user_id=user_id,
) )
@ -197,7 +200,12 @@ def handle_changed_elements(elements: Iterable[Element]) -> None:
cache_elements: Dict[str, Optional[Dict[str, Any]]] = {} cache_elements: Dict[str, Optional[Dict[str, Any]]] = {}
for element in elements: for element in elements:
element_id = get_element_id(element["collection_string"], element["id"]) 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) return await element_cache.change_elements(cache_elements)
async def async_handle_collection_elements(elements: Iterable[Element]) -> None: async def async_handle_collection_elements(elements: Iterable[Element]) -> None:

View File

@ -204,7 +204,11 @@ class ElementCache:
all_data: Dict[str, List[Dict[str, Any]]] = defaultdict(list) all_data: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for element_id, data in (await self.cache_provider.get_all_data()).items(): for element_id, data in (await self.cache_provider.get_all_data()).items():
collection_string, _ = split_element_id(element_id) 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: if user_id is not None:
for collection_string in all_data.keys(): for collection_string in all_data.keys():
@ -226,7 +230,11 @@ class ElementCache:
all_data: Dict[str, Dict[int, Dict[str, Any]]] = defaultdict(dict) all_data: Dict[str, Dict[int, Dict[str, Any]]] = defaultdict(dict)
for element_id, data in (await self.cache_provider.get_all_data()).items(): for element_id, data in (await self.cache_provider.get_all_data()).items():
collection_string, id = split_element_id(element_id) 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) return dict(all_data)
async def get_collection_data( async def get_collection_data(
@ -241,6 +249,9 @@ class ElementCache:
collection_data = {} collection_data = {}
for id in encoded_collection_data.keys(): for id in encoded_collection_data.keys():
collection_data[id] = json.loads(encoded_collection_data[id].decode()) 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 return collection_data
async def get_element_data( async def get_element_data(
@ -257,6 +268,9 @@ class ElementCache:
if encoded_element is None: if encoded_element is None:
return None return None
element = json.loads(encoded_element.decode()) # type: ignore 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: if user_id is not None:
element = await self.restrict_element_data( element = await self.restrict_element_data(
@ -319,6 +333,16 @@ class ElementCache:
# the list(...) is important, because `changed_elements` will be # the list(...) is important, because `changed_elements` will be
# altered during iteration and restricting data # altered during iteration and restricting data
for collection_string, elements in list(changed_elements.items()): 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] cacheable = self.cachables[collection_string]
restricted_elements = await cacheable.restrict_elements( restricted_elements = await cacheable.restrict_elements(
user_id, elements user_id, elements
@ -327,11 +351,12 @@ class ElementCache:
# If the model is personalized, it must not be deleted for other users # If the model is personalized, it must not be deleted for other users
if not cacheable.personalized_model: if not cacheable.personalized_model:
# Add removed objects (through restricter) to deleted elements. # Add removed objects (through restricter) to deleted elements.
element_ids = set([element["id"] for element in elements])
restricted_element_ids = set( restricted_element_ids = set(
[element["id"] for element in restricted_elements] [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)) deleted_elements.append(get_element_id(collection_string, id))
if not restricted_elements: if not restricted_elements:

View File

@ -8,7 +8,7 @@ from . import logging
from .access_permissions import BaseAccessPermissions from .access_permissions import BaseAccessPermissions
from .autoupdate import Element, inform_changed_data, inform_changed_elements from .autoupdate import Element, inform_changed_data, inform_changed_elements
from .rest_api import model_serializer_classes 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__) logger = logging.getLogger(__name__)
@ -90,7 +90,16 @@ class RESTModelMixin:
""" """
return self.pk # type: ignore 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. 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 return_value = super().save(*args, **kwargs) # type: ignore
if not skip_autoupdate: 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 return return_value
def delete(self, skip_autoupdate: bool = False, *args: Any, **kwargs: Any) -> Any: def delete(self, skip_autoupdate: bool = False, *args: Any, **kwargs: Any) -> Any:

View File

@ -1,7 +0,0 @@
from django.test import TestCase as _TestCase
class TestCase(_TestCase):
"""
Does currently nothing.
"""

View File

@ -1,7 +1,7 @@
import random import random
import re import re
import string import string
from typing import Dict, Generator, Optional, Tuple, Type, Union from typing import Any, Dict, Generator, Optional, Tuple, Type, Union
import roman import roman
from django.apps import apps from django.apps import apps
@ -64,6 +64,14 @@ def str_dict_to_bytes(str_dict: Dict[str, str]) -> Dict[bytes, bytes]:
return out 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]] = {} _models_to_collection_string: Dict[str, Type[Model]] = {}

View File

@ -15,4 +15,4 @@ PyPDF2>=1.26,<1.27
roman>=2.0,<3.2 roman>=2.0,<3.2
setuptools>=29.0,<42.0 setuptools>=29.0,<42.0
typing_extensions>=3.6.6,<3.8 typing_extensions>=3.6.6,<3.8
websockets>=8.0,<9.0 websockets>=8.0,<9.0

View File

@ -1,6 +1,6 @@
from openslides.agenda.models import Item from openslides.agenda.models import Item
from openslides.topics.models import Topic from openslides.topics.models import Topic
from openslides.utils.test import TestCase from tests.test_case import TestCase
class TestItemManager(TestCase): class TestItemManager(TestCase):

View File

@ -16,7 +16,7 @@ from openslides.motions.models import Motion
from openslides.topics.models import Topic from openslides.topics.models import Topic
from openslides.users.models import Group from openslides.users.models import Group
from openslides.utils.autoupdate import inform_changed_data 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 ...common_groups import GROUP_DEFAULT_PK
from ..helpers import count_queries from ..helpers import count_queries
@ -284,25 +284,11 @@ class ManageSpeaker(TestCase):
Tests managing speakers. Tests managing speakers.
""" """
def setUp(self): def advancedSetUp(self):
self.client = APIClient()
self.client.login(username="admin", password="admin")
self.list_of_speakers = Topic.objects.create( self.list_of_speakers = Topic.objects.create(
title="test_title_aZaedij4gohn5eeQu8fe" title="test_title_aZaedij4gohn5eeQu8fe"
).list_of_speakers ).list_of_speakers
self.user = get_user_model().objects.create_user( self.user, _ = self.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)
def test_add_oneself_once(self): def test_add_oneself_once(self):
response = self.client.post( response = self.client.post(
@ -383,7 +369,7 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_add_someone_else_non_admin(self): def test_add_someone_else_non_admin(self):
self.revoke_admin_rights() self.make_admin_delegate()
response = self.client.post( response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
@ -392,6 +378,7 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_remove_someone_else(self): def test_remove_someone_else(self):
print(self.user)
speaker = Speaker.objects.add(self.user, self.list_of_speakers) speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.delete( response = self.client.delete(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
@ -419,7 +406,7 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_remove_someone_else_non_admin(self): 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) speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.delete( response = self.client.delete(
@ -433,14 +420,13 @@ class ManageSpeaker(TestCase):
response = self.client.patch( response = self.client.patch(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"user": self.user.pk, "marked": True}, {"user": self.user.pk, "marked": True},
format="json",
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(Speaker.objects.get().marked) self.assertTrue(Speaker.objects.get().marked)
def test_mark_speaker_non_admin(self): def test_mark_speaker_non_admin(self):
self.revoke_admin_rights() self.make_admin_delegate()
Speaker.objects.add(self.user, self.list_of_speakers) Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch( response = self.client.patch(
@ -515,7 +501,7 @@ class ManageSpeaker(TestCase):
def test_readd_last_speaker_no_admin(self): def test_readd_last_speaker_no_admin(self):
self.util_add_user_as_last_speaker() self.util_add_user_as_last_speaker()
self.revoke_admin_rights() self.make_admin_delegate()
response = self.client.post( response = self.client.post(
reverse( reverse(

View File

@ -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())

View File

@ -9,7 +9,7 @@ from openslides.assignments.models import Assignment
from openslides.core.models import Tag from openslides.core.models import Tag
from openslides.mediafiles.models import Mediafile from openslides.mediafiles.models import Mediafile
from openslides.utils.autoupdate import inform_changed_data 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 from ..helpers import count_queries
@ -75,7 +75,7 @@ class CreateAssignment(TestCase):
self.assertTrue(assignment.attachments.exists()) self.assertTrue(assignment.attachments.exists())
class CanidatureSelf(TestCase): class CandidatureSelf(TestCase):
""" """
Tests self candidation view. Tests self candidation view.
""" """
@ -99,7 +99,7 @@ class CanidatureSelf(TestCase):
) )
def test_nominate_self_twice(self): 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( response = self.client.post(
reverse("assignment-candidature-self", args=[self.assignment.pk]) reverse("assignment-candidature-self", args=[self.assignment.pk])
@ -152,7 +152,7 @@ class CanidatureSelf(TestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_withdraw_self(self): 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( response = self.client.delete(
reverse("assignment-candidature-self", args=[self.assignment.pk]) reverse("assignment-candidature-self", args=[self.assignment.pk])
@ -173,7 +173,7 @@ class CanidatureSelf(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_withdraw_self_when_finished(self): 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.set_phase(Assignment.PHASE_FINISHED)
self.assignment.save() self.assignment.save()
@ -184,7 +184,7 @@ class CanidatureSelf(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_withdraw_self_during_voting(self): 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.set_phase(Assignment.PHASE_VOTING)
self.assignment.save() self.assignment.save()
@ -198,7 +198,7 @@ class CanidatureSelf(TestCase):
) )
def test_withdraw_self_during_voting_non_admin(self): 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.set_phase(Assignment.PHASE_VOTING)
self.assignment.save() self.assignment.save()
admin = get_user_model().objects.get(username="admin") admin = get_user_model().objects.get(username="admin")
@ -267,7 +267,7 @@ class CandidatureOther(TestCase):
) )
def test_nominate_other_twice(self): def test_nominate_other_twice(self):
self.assignment.set_candidate( self.assignment.add_candidate(
get_user_model().objects.get(username="test_user_eeheekai4Phue6cahtho") get_user_model().objects.get(username="test_user_eeheekai4Phue6cahtho")
) )
response = self.client.post( response = self.client.post(
@ -321,7 +321,7 @@ class CandidatureOther(TestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_delete_other(self): def test_delete_other(self):
self.assignment.set_candidate(self.user) self.assignment.add_candidate(self.user)
response = self.client.delete( response = self.client.delete(
reverse("assignment-candidature-other", args=[self.assignment.pk]), reverse("assignment-candidature-other", args=[self.assignment.pk]),
{"user": self.user.pk}, {"user": self.user.pk},
@ -343,7 +343,7 @@ class CandidatureOther(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_delete_other_when_finished(self): 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.set_phase(Assignment.PHASE_FINISHED)
self.assignment.save() self.assignment.save()
@ -355,7 +355,7 @@ class CandidatureOther(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_delete_other_during_voting(self): 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.set_phase(Assignment.PHASE_VOTING)
self.assignment.save() self.assignment.save()
@ -372,7 +372,7 @@ class CandidatureOther(TestCase):
) )
def test_delete_other_during_voting_non_admin(self): 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.set_phase(Assignment.PHASE_VOTING)
self.assignment.save() self.assignment.save()
admin = get_user_model().objects.get(username="admin") admin = get_user_model().objects.get(username="admin")
@ -408,7 +408,7 @@ class MarkElectedOtherUser(TestCase):
) )
def test_mark_elected(self): def test_mark_elected(self):
self.assignment.set_candidate( self.assignment.add_candidate(
get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev") get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev")
) )
response = self.client.post( response = self.client.post(
@ -437,46 +437,3 @@ class MarkElectedOtherUser(TestCase):
.elected.filter(username="test_user_Oonei3rahji5jugh1eev") .elected.filter(username="test_user_Oonei3rahji5jugh1eev")
.exists() .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)

View File

@ -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.config import ConfigVariable, config
from openslides.core.models import Projector from openslides.core.models import Projector
from openslides.utils.rest_api import ValidationError 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) @pytest.mark.django_db(transaction=False)

View File

@ -13,8 +13,8 @@ from openslides.core.models import Projector, Tag
from openslides.users.models import User from openslides.users.models import User
from openslides.utils.auth import get_group_model from openslides.utils.auth import get_group_model
from openslides.utils.autoupdate import inform_changed_data 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.common_groups import GROUP_ADMIN_PK, GROUP_DELEGATE_PK
from tests.test_case import TestCase
from ..helpers import count_queries from ..helpers import count_queries
@ -107,7 +107,6 @@ class Projection(TestCase):
response = self.client.post( response = self.client.post(
reverse("projector-project", args=[self.projector.pk]), reverse("projector-project", args=[self.projector.pk]),
{"elements": elements}, {"elements": elements},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.projector = Projector.objects.get(pk=1) self.projector = Projector.objects.get(pk=1)
@ -117,9 +116,7 @@ class Projection(TestCase):
def test_add_element_without_name(self): def test_add_element_without_name(self):
response = self.client.post( response = self.client.post(
reverse("projector-project", args=[self.projector.pk]), reverse("projector-project", args=[self.projector.pk]), {"elements": [{}]}
{"elements": [{}]},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.projector = Projector.objects.get(pk=1) self.projector = Projector.objects.get(pk=1)
@ -134,7 +131,7 @@ class Projection(TestCase):
inform_changed_data(admin) inform_changed_data(admin)
response = self.client.post( 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) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@ -142,9 +139,7 @@ class Projection(TestCase):
self.projector.elements = [{"name": "core/clock"}] self.projector.elements = [{"name": "core/clock"}]
self.projector.save() self.projector.save()
response = self.client.post( response = self.client.post(
reverse("projector-project", args=[self.projector.pk]), reverse("projector-project", args=[self.projector.pk]), {"elements": []}
{"elements": []},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.projector = Projector.objects.get(pk=1) self.projector = Projector.objects.get(pk=1)
@ -157,7 +152,6 @@ class Projection(TestCase):
response = self.client.post( response = self.client.post(
reverse("projector-project", args=[self.projector.pk]), reverse("projector-project", args=[self.projector.pk]),
{"append_to_history": element}, {"append_to_history": element},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.projector = Projector.objects.get(pk=1) self.projector = Projector.objects.get(pk=1)
@ -173,7 +167,6 @@ class Projection(TestCase):
response = self.client.post( response = self.client.post(
reverse("projector-project", args=[self.projector.pk]), reverse("projector-project", args=[self.projector.pk]),
{"delete_last_history_element": True}, {"delete_last_history_element": True},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.projector = Projector.objects.get(pk=1) self.projector = Projector.objects.get(pk=1)
@ -186,7 +179,6 @@ class Projection(TestCase):
response = self.client.post( response = self.client.post(
reverse("projector-project", args=[self.projector.pk]), reverse("projector-project", args=[self.projector.pk]),
{"preview": elements}, {"preview": elements},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.projector = Projector.objects.get(pk=1) self.projector = Projector.objects.get(pk=1)
@ -265,7 +257,6 @@ class ConfigViewSet(TestCase):
response = self.client.put( response = self.client.put(
reverse("config-detail", args=["agenda_start_event_date_time"]), reverse("config-detail", args=["agenda_start_event_date_time"]),
{"value": None}, {"value": None},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(config["agenda_start_event_date_time"], None) self.assertEqual(config["agenda_start_event_date_time"], None)
@ -277,7 +268,6 @@ class ConfigViewSet(TestCase):
response = self.client.put( response = self.client.put(
reverse("config-detail", args=["motions_identifier_min_digits"]), reverse("config-detail", args=["motions_identifier_min_digits"]),
{"value": None}, {"value": None},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 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) self.degrade_admin(can_manage_logos_and_fonts=True)
value = self.get_static_config_value() value = self.get_static_config_value()
response = self.client.put( response = self.client.put(
reverse("config-detail", args=[self.logo_config_key]), reverse("config-detail", args=[self.logo_config_key]), {"value": value}
{"value": value},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(config[self.logo_config_key], value) 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.string_config_key, "value": string_value},
{"key": self.logo_config_key, "value": logo_value}, {"key": self.logo_config_key, "value": logo_value},
], ],
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["errors"], {}) self.assertEqual(response.data["errors"], {})
@ -345,7 +332,6 @@ class ConfigViewSet(TestCase):
response = self.client.post( response = self.client.post(
reverse("config-bulk-update"), reverse("config-bulk-update"),
[{"key": self.string_config_key, "value": string_value}], [{"key": self.string_config_key, "value": string_value}],
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(config[self.string_config_key], "OpenSlides") self.assertEqual(config[self.string_config_key], "OpenSlides")
@ -355,7 +341,6 @@ class ConfigViewSet(TestCase):
response = self.client.post( response = self.client.post(
reverse("config-bulk-update"), reverse("config-bulk-update"),
{"key": self.string_config_key, "value": string_value}, {"key": self.string_config_key, "value": string_value},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(config[self.string_config_key], "OpenSlides") self.assertEqual(config[self.string_config_key], "OpenSlides")
@ -363,16 +348,14 @@ class ConfigViewSet(TestCase):
def test_bulk_update_no_key(self): def test_bulk_update_no_key(self):
string_value = "test_value_glwe32qc&Lml2lclmqmc" string_value = "test_value_glwe32qc&Lml2lclmqmc"
response = self.client.post( 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(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(config[self.string_config_key], "OpenSlides") self.assertEqual(config[self.string_config_key], "OpenSlides")
def test_bulk_update_no_value(self): def test_bulk_update_no_value(self):
response = self.client.post( response = self.client.post(
reverse("config-bulk-update"), reverse("config-bulk-update"), [{"key": self.string_config_key}]
[{"key": self.string_config_key}],
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(config[self.string_config_key], "OpenSlides") self.assertEqual(config[self.string_config_key], "OpenSlides")
@ -384,7 +367,7 @@ class ConfigViewSet(TestCase):
"motions_preamble" "motions_preamble"
] = "test_preamble_2390jvwohjwo1oigefoq" # Group motions ] = "test_preamble_2390jvwohjwo1oigefoq" # Group motions
response = self.client.post( 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(response.status_code, status.HTTP_200_OK)
self.assertEqual(config["general_event_name"], "OpenSlides") self.assertEqual(config["general_event_name"], "OpenSlides")
@ -394,15 +377,11 @@ class ConfigViewSet(TestCase):
) )
def test_reset_group_wrong_format_1(self): def test_reset_group_wrong_format_1(self):
response = self.client.post( response = self.client.post(reverse("config-reset-groups"), {"wrong": "format"})
reverse("config-reset-groups"), {"wrong": "format"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_reset_group_wrong_format_2(self): def test_reset_group_wrong_format_2(self):
response = self.client.post( response = self.client.post(
reverse("config-reset-groups"), reverse("config-reset-groups"), ["some_string", {"wrong": "format"}]
["some_string", {"wrong": "format"}],
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

View File

@ -7,7 +7,7 @@ from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from openslides.mediafiles.models import Mediafile from openslides.mediafiles.models import Mediafile
from openslides.utils.test import TestCase from tests.test_case import TestCase
from ..helpers import count_queries from ..helpers import count_queries
@ -41,6 +41,7 @@ class TestCreation(TestCase):
response = self.client.post( response = self.client.post(
reverse("mediafile-list"), reverse("mediafile-list"),
{"title": "test_title_ahyo1uifoo9Aiph2av5a", "mediafile": self.file}, {"title": "test_title_ahyo1uifoo9Aiph2av5a", "mediafile": self.file},
format="multipart",
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
mediafile = Mediafile.objects.get() mediafile = Mediafile.objects.get()
@ -170,8 +171,8 @@ class TestCreation(TestCase):
reverse("mediafile-list"), reverse("mediafile-list"),
{ {
"title": "test_title_dggjwevBnUngelkdviom", "title": "test_title_dggjwevBnUngelkdviom",
"mediafile": self.file, "is_directory": True,
"access_groups_id": json.dumps([2, 4]), "access_groups_id": [2, 4],
}, },
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@ -268,7 +269,6 @@ class TestUpdate(TestCase):
response = self.client.put( response = self.client.put(
reverse("mediafile-detail", args=[self.mediafileA.pk]), reverse("mediafile-detail", args=[self.mediafileA.pk]),
{"title": self.mediafileA.title, "parent_id": None}, {"title": self.mediafileA.title, "parent_id": None},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
mediafile = Mediafile.objects.get(pk=self.mediafileA.pk) mediafile = Mediafile.objects.get(pk=self.mediafileA.pk)

View File

@ -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"
)

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ from django.test.client import Client
from openslides.core.config import config from openslides.core.config import config
from openslides.motions.models import Motion from openslides.motions.models import Motion
from openslides.utils.test import TestCase from tests.test_case import TestCase
class AnonymousRequests(TestCase): class AnonymousRequests(TestCase):

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ from rest_framework import status
from openslides.agenda.models import Item from openslides.agenda.models import Item
from openslides.topics.models import Topic from openslides.topics.models import Topic
from openslides.utils.test import TestCase from tests.test_case import TestCase
from ..helpers import count_queries from ..helpers import count_queries

View File

@ -3,12 +3,15 @@ import json
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APIClient from rest_framework.test import APIClient
from openslides.utils.test import TestCase from tests.test_case import TestCase
class TestWhoAmIView(TestCase): class TestWhoAmIView(TestCase):
url = reverse("user_whoami") url = reverse("user_whoami")
def setUp(self):
pass
def test_get_anonymous(self): def test_get_anonymous(self):
response = self.client.get(self.url) response = self.client.get(self.url)
@ -44,6 +47,9 @@ class TestWhoAmIView(TestCase):
class TestUserLogoutView(TestCase): class TestUserLogoutView(TestCase):
url = reverse("user_logout") url = reverse("user_logout")
def setUp(self):
pass
def test_get(self): def test_get(self):
response = self.client.get(self.url) response = self.client.get(self.url)

View File

@ -8,7 +8,7 @@ from rest_framework.test import APIClient
from openslides.core.config import config from openslides.core.config import config
from openslides.users.models import Group, PersonalNote, User from openslides.users.models import Group, PersonalNote, User
from openslides.utils.autoupdate import inform_changed_data from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.test import TestCase from tests.test_case import TestCase
from ...common_groups import ( from ...common_groups import (
GROUP_ADMIN_PK, GROUP_ADMIN_PK,
@ -196,7 +196,6 @@ class UserUpdate(TestCase):
response = admin_client.patch( response = admin_client.patch(
reverse("user-detail", args=[user_pk]), reverse("user-detail", args=[user_pk]),
{"username": "admin", "is_active": False}, {"username": "admin", "is_active": False},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@ -268,7 +267,7 @@ class UserDelete(TestCase):
ids.append(user.id) ids.append(user.id)
response = self.admin_client.post( 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.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(User.objects.filter(pk__in=ids).exists()) self.assertFalse(User.objects.filter(pk__in=ids).exists())
@ -276,7 +275,7 @@ class UserDelete(TestCase):
def test_bulk_delete_self(self): def test_bulk_delete_self(self):
""" The own id should be excluded, so nothing should happen. """ """ The own id should be excluded, so nothing should happen. """
response = self.admin_client.post( 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.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertTrue(User.objects.filter(pk=1).exists()) self.assertTrue(User.objects.filter(pk=1).exists())
@ -416,9 +415,7 @@ class UserPassword(TestCase):
self.assertTrue(user2.check_password(default_password2)) self.assertTrue(user2.check_password(default_password2))
response = self.admin_client.post( response = self.admin_client.post(
reverse("user-bulk-generate-passwords"), reverse("user-bulk-generate-passwords"), {"user_ids": [user1.id, user2.id]}
{"user_ids": [user1.id, user2.id]},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -450,7 +447,6 @@ class UserPassword(TestCase):
response = self.admin_client.post( response = self.admin_client.post(
reverse("user-bulk-reset-passwords-to-default"), reverse("user-bulk-reset-passwords-to-default"),
{"user_ids": [user1.id, user2.id]}, {"user_ids": [user1.id, user2.id]},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -478,7 +474,6 @@ class UserBulkSetState(TestCase):
response = self.client.post( response = self.client.post(
reverse("user-bulk-set-state"), reverse("user-bulk-set-state"),
{"user_ids": [1], "field": "is_present", "value": False}, {"user_ids": [1], "field": "is_present", "value": False},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(User.objects.get().is_active) self.assertTrue(User.objects.get().is_active)
@ -489,7 +484,6 @@ class UserBulkSetState(TestCase):
response = self.client.post( response = self.client.post(
reverse("user-bulk-set-state"), reverse("user-bulk-set-state"),
{"user_ids": [1], "field": "invalid", "value": False}, {"user_ids": [1], "field": "invalid", "value": False},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertTrue(User.objects.get().is_active) self.assertTrue(User.objects.get().is_active)
@ -500,7 +494,6 @@ class UserBulkSetState(TestCase):
response = self.client.post( response = self.client.post(
reverse("user-bulk-set-state"), reverse("user-bulk-set-state"),
{"user_ids": [1], "field": "is_active", "value": "invalid"}, {"user_ids": [1], "field": "is_active", "value": "invalid"},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertTrue(User.objects.get().is_active) self.assertTrue(User.objects.get().is_active)
@ -511,7 +504,6 @@ class UserBulkSetState(TestCase):
response = self.client.post( response = self.client.post(
reverse("user-bulk-set-state"), reverse("user-bulk-set-state"),
{"user_ids": [1], "field": "is_active", "value": False}, {"user_ids": [1], "field": "is_active", "value": False},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(User.objects.get().is_active) self.assertTrue(User.objects.get().is_active)
@ -539,7 +531,6 @@ class UserBulkAlterGroups(TestCase):
"action": "add", "action": "add",
"group_ids": [GROUP_DELEGATE_PK, GROUP_STAFF_PK], "group_ids": [GROUP_DELEGATE_PK, GROUP_STAFF_PK],
}, },
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(self.user.groups.count(), 2) self.assertEqual(self.user.groups.count(), 2)
@ -558,7 +549,6 @@ class UserBulkAlterGroups(TestCase):
"action": "remove", "action": "remove",
"group_ids": [GROUP_DEFAULT_PK, GROUP_STAFF_PK], "group_ids": [GROUP_DEFAULT_PK, GROUP_STAFF_PK],
}, },
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(self.user.groups.count(), 1) self.assertEqual(self.user.groups.count(), 1)
@ -574,7 +564,6 @@ class UserBulkAlterGroups(TestCase):
"action": "add", "action": "add",
"group_ids": [GROUP_DELEGATE_PK], "group_ids": [GROUP_DELEGATE_PK],
}, },
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(self.admin.groups.count(), 1) self.assertEqual(self.admin.groups.count(), 1)
@ -588,7 +577,6 @@ class UserBulkAlterGroups(TestCase):
"action": "invalid", "action": "invalid",
"group_ids": [GROUP_DELEGATE_PK], "group_ids": [GROUP_DELEGATE_PK],
}, },
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@ -614,7 +602,7 @@ class UserMassImport(TestCase):
"groups_id": [], "groups_id": [],
} }
response = self.client.post( 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(response.status_code, 200)
self.assertEqual(User.objects.count(), 3) self.assertEqual(User.objects.count(), 3)
@ -640,9 +628,7 @@ class UserSendIntivationEmail(TestCase):
"subject": config["users_email_subject"], "subject": config["users_email_subject"],
"message": config["users_email_body"], "message": config["users_email_body"],
} }
response = self.client.post( response = self.client.post(reverse("user-mass-invite-email"), data)
reverse("user-mass-invite-email"), data, format="json"
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 1) self.assertEqual(response.data["count"], 1)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
@ -666,6 +652,9 @@ class GroupMetadata(TestCase):
class GroupReceive(TestCase): class GroupReceive(TestCase):
def setUp(self):
pass
def test_get_groups_as_anonymous_deactivated(self): def test_get_groups_as_anonymous_deactivated(self):
""" """
Test to get the groups with an anonymous user, when they are deactivated. Test to get the groups with an anonymous user, when they are deactivated.
@ -849,7 +838,6 @@ class GroupUpdate(TestCase):
response = admin_client.put( response = admin_client.put(
reverse("group-detail", args=[group.pk]), reverse("group-detail", args=[group.pk]),
{"name": "new_group_name_Chie6duwaepoo8aech7r", "permissions": permissions}, {"name": "new_group_name_Chie6duwaepoo8aech7r", "permissions": permissions},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -869,7 +857,6 @@ class GroupUpdate(TestCase):
response = admin_client.post( response = admin_client.post(
reverse("group-set-permission", args=[GROUP_DEFAULT_PK]), reverse("group-set-permission", args=[GROUP_DEFAULT_PK]),
{"perm": "users.can_manage", "set": True}, {"perm": "users.can_manage", "set": True},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -887,7 +874,6 @@ class GroupUpdate(TestCase):
response = admin_client.post( response = admin_client.post(
reverse("group-set-permission", args=[GROUP_DEFAULT_PK]), reverse("group-set-permission", args=[GROUP_DEFAULT_PK]),
{"perm": "not_existing.permission", "set": True}, {"perm": "not_existing.permission", "set": True},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@ -899,7 +885,6 @@ class GroupUpdate(TestCase):
response = admin_client.post( response = admin_client.post(
reverse("group-set-permission", args=[GROUP_DEFAULT_PK]), reverse("group-set-permission", args=[GROUP_DEFAULT_PK]),
{"perm": "users.can_see_name", "set": False}, {"perm": "users.can_see_name", "set": False},
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) 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": 1, "content": content1},
{"collection": "example-model", "id": 2, "content": content2}, {"collection": "example-model", "id": 2, "content": content2},
], ],
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(PersonalNote.objects.exists()) self.assertTrue(PersonalNote.objects.exists())
@ -985,9 +969,7 @@ class PersonalNoteTest(TestCase):
def test_anonymous_create(self): def test_anonymous_create(self):
guest_client = APIClient() guest_client = APIClient()
response = guest_client.post( response = guest_client.post(reverse("personalnote-create-or-update"), [])
reverse("personalnote-create-or-update"), [], format="json"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(PersonalNote.objects.exists()) self.assertFalse(PersonalNote.objects.exists())
@ -1007,7 +989,6 @@ class PersonalNoteTest(TestCase):
"content": "test_note_do2ncoi7ci2fm93LjwlO", "content": "test_note_do2ncoi7ci2fm93LjwlO",
} }
], ],
format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
personal_note = PersonalNote.objects.get() personal_note = PersonalNote.objects.get()

View File

@ -2,7 +2,7 @@ from openslides.agenda.models import ListOfSpeakers, Speaker
from openslides.topics.models import Topic from openslides.topics.models import Topic
from openslides.users.models import User from openslides.users.models import User
from openslides.utils.exceptions import OpenSlidesError from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.test import TestCase from tests.test_case import TestCase
class ListOfSpeakerModelTests(TestCase): class ListOfSpeakerModelTests(TestCase):

Some files were not shown because too many files have changed in this diff Show More