Initial work for supporting voting
This commit is contained in:
parent
4d4697eee0
commit
ced40cab74
@ -85,7 +85,11 @@ matrix:
|
||||
- "3.6"
|
||||
script:
|
||||
- mypy openslides/ tests/
|
||||
<<<<<<< HEAD
|
||||
- pytest --cov --cov-fail-under=72
|
||||
=======
|
||||
- pytest --cov --cov-fail-under=74
|
||||
>>>>>>> Initial work for supporting voting
|
||||
|
||||
- name: "Server: Tests Python 3.7"
|
||||
language: python
|
||||
@ -96,7 +100,11 @@ matrix:
|
||||
- isort --check-only --diff --recursive openslides tests
|
||||
- black --check --diff --target-version py36 openslides tests
|
||||
- mypy openslides/ tests/
|
||||
<<<<<<< HEAD
|
||||
- pytest --cov --cov-fail-under=72
|
||||
=======
|
||||
- pytest --cov --cov-fail-under=74
|
||||
>>>>>>> Initial work for supporting voting
|
||||
|
||||
- name: "Server: Tests Python 3.8"
|
||||
language: python
|
||||
|
@ -68,15 +68,10 @@ export class AppLoadService {
|
||||
let repository: BaseRepository<any, any, any> = null;
|
||||
repository = this.injector.get(entry.repository);
|
||||
repositories.push(repository);
|
||||
this.modelMapper.registerCollectionElement(
|
||||
entry.collectionString,
|
||||
entry.model,
|
||||
entry.viewModel,
|
||||
repository
|
||||
);
|
||||
this.modelMapper.registerCollectionElement(entry.model, entry.viewModel, repository);
|
||||
if (this.isSearchableModelEntry(entry)) {
|
||||
this.searchService.registerModel(
|
||||
entry.collectionString,
|
||||
entry.model.COLLECTIONSTRING,
|
||||
repository,
|
||||
entry.searchOrder,
|
||||
entry.openInNewTab
|
||||
@ -108,7 +103,7 @@ export class AppLoadService {
|
||||
// to check if the result of the contructor (the model instance) is really a searchable.
|
||||
if (!isSearchable(new entry.viewModel())) {
|
||||
throw Error(
|
||||
`Wrong configuration for ${entry.collectionString}: you gave a searchOrder, but the model is not searchable.`
|
||||
`Wrong configuration for ${entry.model.COLLECTIONSTRING}: you gave a searchOrder, but the model is not searchable.`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
|
@ -47,12 +47,11 @@ export class CollectionStringMapperService {
|
||||
* @param model
|
||||
*/
|
||||
public registerCollectionElement<V extends BaseViewModel<M>, M extends BaseModel>(
|
||||
collectionString: string,
|
||||
model: ModelConstructor<M>,
|
||||
viewModel: ViewModelConstructor<V>,
|
||||
repository: BaseRepository<V, M, TitleInformation>
|
||||
): void {
|
||||
this.collectionStringMapping[collectionString] = [model, viewModel, repository];
|
||||
this.collectionStringMapping[model.COLLECTIONSTRING] = [model, viewModel, repository];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -7,7 +7,6 @@ import { MainMenuEntry } from '../core-services/main-menu.service';
|
||||
import { Searchable } from '../../site/base/searchable';
|
||||
|
||||
interface BaseModelEntry {
|
||||
collectionString: string;
|
||||
repository: Type<BaseRepository<any, any, any>>;
|
||||
model: ModelConstructor<BaseModel>;
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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');
|
||||
};
|
||||
}
|
@ -8,12 +8,12 @@ import { RelationManagerService } from 'app/core/core-services/relation-manager.
|
||||
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
|
||||
import { RelationDefinition } from 'app/core/definitions/relations';
|
||||
import { Assignment } from 'app/shared/models/assignments/assignment';
|
||||
import { AssignmentOption } from 'app/shared/models/assignments/assignment-option';
|
||||
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
|
||||
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
|
||||
import { AssignmentRelatedUser } from 'app/shared/models/assignments/assignment-related-user';
|
||||
import { AssignmentTitleInformation, ViewAssignment } from 'app/site/assignments/models/view-assignment';
|
||||
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
|
||||
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
|
||||
import { ViewAssignmentPollOption } from 'app/site/assignments/models/view-assignment-poll-option';
|
||||
import { ViewAssignmentRelatedUser } from 'app/site/assignments/models/view-assignment-related-user';
|
||||
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
|
||||
import { ViewTag } from 'app/site/tags/models/view-tag';
|
||||
@ -68,8 +68,8 @@ const AssignmentNestedModelDescriptors: NestedModelDescriptors = {
|
||||
'assignments/assignment-poll': [
|
||||
{
|
||||
ownKey: 'options',
|
||||
foreignViewModel: ViewAssignmentPollOption,
|
||||
foreignModel: AssignmentPollOption,
|
||||
foreignViewModel: ViewAssignmentOption,
|
||||
foreignModel: AssignmentOption,
|
||||
order: 'weight',
|
||||
relationDefinitionsByKey: {
|
||||
user: {
|
||||
@ -97,10 +97,8 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
|
||||
AssignmentTitleInformation
|
||||
> {
|
||||
private readonly restPath = '/rest/assignments/assignment/';
|
||||
private readonly restPollPath = '/rest/assignments/poll/';
|
||||
private readonly candidatureOtherPath = '/candidature_other/';
|
||||
private readonly candidatureSelfPath = '/candidature_self/';
|
||||
private readonly createPollPath = '/create_poll/';
|
||||
private readonly markElectedPath = '/mark_elected/';
|
||||
|
||||
/**
|
||||
@ -179,67 +177,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
|
||||
await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Poll to a given assignment
|
||||
*
|
||||
* @param assignment The assignment to add the poll to
|
||||
*/
|
||||
public async addPoll(assignment: ViewAssignment): Promise<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
|
||||
*
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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');
|
||||
};
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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');
|
||||
};
|
||||
}
|
@ -14,7 +14,6 @@ import { ConfigService } from 'app/core/ui-services/config.service';
|
||||
import { DiffLinesInParagraph, DiffService } from 'app/core/ui-services/diff.service';
|
||||
import { TreeIdNode } from 'app/core/ui-services/tree.service';
|
||||
import { Motion } from 'app/shared/models/motions/motion';
|
||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||
import { Submitter } from 'app/shared/models/motions/submitter';
|
||||
import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change';
|
||||
import { PersonalNoteContent } from 'app/shared/models/users/personal-note';
|
||||
@ -844,46 +843,6 @@ export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersCo
|
||||
.filter((para: ViewMotionAmendedParagraph) => para !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to the server, creating a new poll for the motion
|
||||
*/
|
||||
public async createPoll(motion: ViewMotion): Promise<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
|
||||
*
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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');
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,18 +1,18 @@
|
||||
import { AssignmentPollMethod } from 'app/site/assignments/services/assignment-poll.service';
|
||||
import { AssignmentPollOption } from './assignment-poll-option';
|
||||
import { BaseModel } from '../base/base-model';
|
||||
import { AssignmentOption } from './assignment-option';
|
||||
import { BasePoll, BasePollWithoutNestedModels } from '../poll/base-poll';
|
||||
|
||||
export interface AssignmentPollWithoutNestedModels extends BaseModel<AssignmentPoll> {
|
||||
id: number;
|
||||
pollmethod: AssignmentPollMethod;
|
||||
description: string;
|
||||
published: boolean;
|
||||
votesvalid: number;
|
||||
votesno: number;
|
||||
votesabstain: number;
|
||||
votesinvalid: number;
|
||||
votescast: number;
|
||||
has_votes: boolean;
|
||||
export enum AssignmentPollmethods {
|
||||
'yn' = 'yn',
|
||||
'yna' = 'yna',
|
||||
'votes' = 'votes'
|
||||
}
|
||||
|
||||
export interface AssignmentPollWithoutNestedModels extends BasePollWithoutNestedModels {
|
||||
pollmethod: AssignmentPollmethods;
|
||||
votes_amount: number;
|
||||
allow_multiple_votes_per_candidate: boolean;
|
||||
global_no: boolean;
|
||||
global_abstain: boolean;
|
||||
assignment_id: number;
|
||||
}
|
||||
|
||||
@ -20,22 +20,12 @@ export interface AssignmentPollWithoutNestedModels extends BaseModel<AssignmentP
|
||||
* Content of the 'polls' property of assignments
|
||||
* @ignore
|
||||
*/
|
||||
export class AssignmentPoll extends BaseModel<AssignmentPoll> {
|
||||
export class AssignmentPoll extends BasePoll<AssignmentPoll, AssignmentOption> {
|
||||
public static COLLECTIONSTRING = 'assignments/assignment-poll';
|
||||
private static DECIMAL_FIELDS = ['votesvalid', 'votesinvalid', 'votescast', 'votesno', 'votesabstain'];
|
||||
|
||||
public id: number;
|
||||
public options: AssignmentPollOption[];
|
||||
|
||||
public constructor(input?: any) {
|
||||
// cast stringify numbers
|
||||
if (input) {
|
||||
AssignmentPoll.DECIMAL_FIELDS.forEach(field => {
|
||||
if (input[field] && typeof input[field] === 'string') {
|
||||
input[field] = parseFloat(input[field]);
|
||||
}
|
||||
});
|
||||
}
|
||||
super(AssignmentPoll.COLLECTIONSTRING, input);
|
||||
}
|
||||
}
|
||||
|
11
client/src/app/shared/models/assignments/assignment-vote.ts
Normal file
11
client/src/app/shared/models/assignments/assignment-vote.ts
Normal 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);
|
||||
}
|
||||
}
|
12
client/src/app/shared/models/base/base-decimal-model.ts
Normal file
12
client/src/app/shared/models/base/base-decimal-model.ts
Normal 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);
|
||||
}
|
||||
}
|
9
client/src/app/shared/models/motions/motion-option.ts
Normal file
9
client/src/app/shared/models/motions/motion-option.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -1,36 +1,26 @@
|
||||
import { Deserializer } from '../base/deserializer';
|
||||
import { BasePoll, BasePollWithoutNestedModels } from '../poll/base-poll';
|
||||
import { MotionOption } from './motion-option';
|
||||
|
||||
export enum MotionPollmethods {
|
||||
'YN' = 'YN',
|
||||
'YNA' = 'YNA'
|
||||
}
|
||||
|
||||
export interface MotionPollWithoutNestedModels extends BasePollWithoutNestedModels {
|
||||
motion_id: number;
|
||||
pollmethod: MotionPollmethods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class representing a poll for a motion.
|
||||
*/
|
||||
export class MotionPoll extends Deserializer {
|
||||
export class MotionPoll extends BasePoll<MotionPoll, MotionOption> {
|
||||
public static COLLECTIONSTRING = 'motions/motion-poll';
|
||||
|
||||
public id: number;
|
||||
public yes: number;
|
||||
public no: number;
|
||||
public abstain: number;
|
||||
public votesvalid: number;
|
||||
public votesinvalid: number;
|
||||
public votescast: number;
|
||||
public has_votes: boolean;
|
||||
public motion_id: number;
|
||||
|
||||
/**
|
||||
* Needs to be completely optional because motion has (yet) the optional parameter 'polls'
|
||||
* Tries to cast incoming strings as numbers
|
||||
* @param input
|
||||
*/
|
||||
public constructor(input?: any) {
|
||||
if (typeof input === 'object') {
|
||||
Object.keys(input).forEach(key => {
|
||||
if (typeof input[key] === 'string') {
|
||||
input[key] = parseInt(input[key], 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
super(input);
|
||||
}
|
||||
|
||||
public deserialize(input: any): void {
|
||||
Object.assign(this, input);
|
||||
super(MotionPoll.COLLECTIONSTRING, input);
|
||||
}
|
||||
}
|
||||
export interface MotionPoll extends MotionPollWithoutNestedModels {}
|
||||
|
11
client/src/app/shared/models/motions/motion-vote.ts
Normal file
11
client/src/app/shared/models/motions/motion-vote.ts
Normal 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);
|
||||
}
|
||||
}
|
11
client/src/app/shared/models/poll/base-option.ts
Normal file
11
client/src/app/shared/models/poll/base-option.ts
Normal 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'];
|
||||
}
|
33
client/src/app/shared/models/poll/base-poll.ts
Normal file
33
client/src/app/shared/models/poll/base-poll.ts
Normal 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 {}
|
9
client/src/app/shared/models/poll/base-vote.ts
Normal file
9
client/src/app/shared/models/poll/base-vote.ts
Normal 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'];
|
||||
}
|
@ -9,9 +9,8 @@ import { ViewListOfSpeakers } from './models/view-list-of-speakers';
|
||||
export const AgendaAppConfig: AppConfig = {
|
||||
name: 'agenda',
|
||||
models: [
|
||||
{ collectionString: 'agenda/item', model: Item, viewModel: ViewItem, repository: ItemRepositoryService },
|
||||
{ model: Item, viewModel: ViewItem, repository: ItemRepositoryService },
|
||||
{
|
||||
collectionString: 'agenda/list-of-speakers',
|
||||
model: ListOfSpeakers,
|
||||
viewModel: ViewListOfSpeakers,
|
||||
repository: ListOfSpeakersRepositoryService
|
||||
|
@ -1,17 +1,32 @@
|
||||
import { AppConfig } from '../../core/definitions/app-config';
|
||||
import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service';
|
||||
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
|
||||
import { AssignmentVoteRepositoryService } from 'app/core/repositories/assignments/assignment-vote-repository.service';
|
||||
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
|
||||
import { AssignmentVote } from 'app/shared/models/assignments/assignment-vote';
|
||||
import { Assignment } from '../../shared/models/assignments/assignment';
|
||||
import { ViewAssignment } from './models/view-assignment';
|
||||
import { ViewAssignmentPoll } from './models/view-assignment-poll';
|
||||
import { ViewAssignmentVote } from './models/view-assignment-vote';
|
||||
|
||||
export const AssignmentsAppConfig: AppConfig = {
|
||||
name: 'assignments',
|
||||
models: [
|
||||
{
|
||||
collectionString: 'assignments/assignment',
|
||||
model: Assignment,
|
||||
viewModel: ViewAssignment,
|
||||
// searchOrder: 3, // TODO: enable, if there is a detail page and so on.
|
||||
repository: AssignmentRepositoryService
|
||||
},
|
||||
{
|
||||
model: AssignmentPoll,
|
||||
viewModel: ViewAssignmentPoll,
|
||||
repository: AssignmentPollRepositoryService
|
||||
},
|
||||
{
|
||||
model: AssignmentVote,
|
||||
viewModel: ViewAssignmentVote,
|
||||
repository: AssignmentVoteRepositoryService
|
||||
}
|
||||
],
|
||||
mainMenuEntries: [
|
||||
|
@ -147,7 +147,7 @@
|
||||
<!-- TODO avoid animation/switching on update -->
|
||||
<mat-tab
|
||||
*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>
|
||||
</mat-tab>
|
||||
|
@ -15,7 +15,6 @@ import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.
|
||||
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
|
||||
import { PromptService } from 'app/core/ui-services/prompt.service';
|
||||
import { Assignment } from 'app/shared/models/assignments/assignment';
|
||||
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
|
||||
import { ViewItem } from 'app/site/agenda/models/view-item';
|
||||
import { BaseViewComponent } from 'app/site/base/base-view';
|
||||
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
|
||||
@ -23,7 +22,6 @@ import { LocalPermissionsService } from 'app/site/motions/services/local-permiss
|
||||
import { ViewTag } from 'app/site/tags/models/view-tag';
|
||||
import { ViewUser } from 'app/site/users/models/view-user';
|
||||
import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service';
|
||||
import { AssignmentPollService } from '../../services/assignment-poll.service';
|
||||
import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment';
|
||||
import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user';
|
||||
|
||||
@ -171,7 +169,6 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
|
||||
formBuilder: FormBuilder,
|
||||
public repo: AssignmentRepositoryService,
|
||||
private userRepo: UserRepositoryService,
|
||||
public pollService: AssignmentPollService,
|
||||
private itemRepo: ItemRepositoryService,
|
||||
private tagRepo: TagRepositoryService,
|
||||
private promptService: PromptService,
|
||||
@ -303,10 +300,9 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
|
||||
|
||||
/**
|
||||
* Creates a new Poll
|
||||
* TODO: directly open poll dialog?
|
||||
*/
|
||||
public async createPoll(): Promise<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
|
||||
* (triggered on an autoupdate of either users or the assignment)
|
||||
|
@ -1,16 +1,9 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
|
||||
import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service';
|
||||
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
|
||||
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
|
||||
import { AssignmentPollService, SummaryPollKey } from '../../services/assignment-poll.service';
|
||||
import { ViewAssignmentOption } from '../../models/view-assignment-option';
|
||||
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
|
||||
import { ViewAssignmentPollOption } from '../../models/view-assignment-poll-option';
|
||||
|
||||
/**
|
||||
* Vote entries included once for summary (e.g. total votes cast)
|
||||
@ -61,16 +54,9 @@ export class AssignmentPollDialogComponent {
|
||||
*/
|
||||
public constructor(
|
||||
public dialogRef: MatDialogRef<AssignmentPollDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: ViewAssignmentPoll,
|
||||
private matSnackBar: MatSnackBar,
|
||||
private translate: TranslateService,
|
||||
public pollService: AssignmentPollService,
|
||||
private userRepo: UserRepositoryService
|
||||
@Inject(MAT_DIALOG_DATA) public data: ViewAssignmentPoll
|
||||
) {
|
||||
this.specialValues = this.pollService.specialPollVotes;
|
||||
this.poll = this.data.poll;
|
||||
|
||||
switch (this.poll.pollmethod) {
|
||||
switch (this.data.pollmethod) {
|
||||
case 'votes':
|
||||
this.optionPollKeys = ['Votes'];
|
||||
break;
|
||||
@ -97,7 +83,7 @@ export class AssignmentPollDialogComponent {
|
||||
* TODO better validation
|
||||
*/
|
||||
public submit(): void {
|
||||
const error = this.data.options.find(dataoption => {
|
||||
/*const error = this.data.options.find(dataoption => {
|
||||
this.optionPollKeys.some(key => {
|
||||
const keyValue = dataoption.votes.find(o => o.value === key);
|
||||
return !keyValue || keyValue.weight === undefined;
|
||||
@ -112,8 +98,8 @@ export class AssignmentPollDialogComponent {
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.dialogRef.close(this.poll);
|
||||
}
|
||||
this.dialogRef.close(this.data);
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
@ -123,7 +109,8 @@ export class AssignmentPollDialogComponent {
|
||||
* @returns a label for a poll option
|
||||
*/
|
||||
public getLabel(key: CalculablePollKey): string {
|
||||
return this.pollService.getLabel(key);
|
||||
// return this.pollService.getLabel(key);
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -133,8 +120,8 @@ export class AssignmentPollDialogComponent {
|
||||
* @param candidate the candidate for whom to update the value
|
||||
* @param newData the new value
|
||||
*/
|
||||
public setValue(value: PollVoteValue, candidate: ViewAssignmentPollOption, newData: string): void {
|
||||
const vote = candidate.votes.find(v => v.value === value);
|
||||
public setValue(value: PollVoteValue, candidate: ViewAssignmentOption, newData: string): void {
|
||||
/*const vote = candidate.votes.find(v => v.value === value);
|
||||
if (vote) {
|
||||
vote.weight = parseFloat(newData);
|
||||
} else {
|
||||
@ -142,7 +129,7 @@ export class AssignmentPollDialogComponent {
|
||||
value: value,
|
||||
weight: parseFloat(newData)
|
||||
});
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
@ -152,9 +139,10 @@ export class AssignmentPollDialogComponent {
|
||||
* @param candidate the pollOption
|
||||
* @returns the currently entered number or undefined if no number has been set
|
||||
*/
|
||||
public getValue(value: PollVoteValue, candidate: AssignmentPollOption): number | undefined {
|
||||
const val = candidate.votes.find(v => v.value === value);
|
||||
return val ? val.weight : undefined;
|
||||
public getValue(value: PollVoteValue, candidate: ViewAssignmentOption): number | undefined {
|
||||
/*const val = candidate.votes.find(v => v.value === value);
|
||||
return val ? val.weight : undefined;*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -163,8 +151,9 @@ export class AssignmentPollDialogComponent {
|
||||
* @param value
|
||||
* @returns integer or undefined
|
||||
*/
|
||||
public getSumValue(value: SummaryPollKey): number | undefined {
|
||||
return this.data[value] || undefined;
|
||||
public getSumValue(value: any /*SummaryPollKey*/): number | undefined {
|
||||
// return this.data[value] || undefined;
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -173,23 +162,11 @@ export class AssignmentPollDialogComponent {
|
||||
* @param value
|
||||
* @param weight
|
||||
*/
|
||||
public setSumValue(value: SummaryPollKey, weight: string): void {
|
||||
this.poll[value] = parseFloat(weight);
|
||||
public setSumValue(value: any /*SummaryPollKey*/, weight: string): void {
|
||||
this.data[value] = parseFloat(weight);
|
||||
}
|
||||
|
||||
public getGridClass(): string {
|
||||
return `votes-grid-${this.optionPollKeys.length}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the name for a poll option
|
||||
* TODO: observable. Note that the assignment.related_user may not contain the user (anymore?)
|
||||
*
|
||||
* @param option Any poll option
|
||||
* @returns the full_name for the candidate
|
||||
*/
|
||||
public getCandidateName(option: AssignmentPollOption): string {
|
||||
const user = this.userRepo.getViewModel(option.candidate_id);
|
||||
return user ? user.full_name : '';
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
@ -7,17 +7,12 @@ import { Title } from '@angular/platform-browser';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { OperatorService } from 'app/core/core-services/operator.service';
|
||||
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
|
||||
import { CalculablePollKey, MajorityMethod } from 'app/core/ui-services/poll.service';
|
||||
import { PromptService } from 'app/core/ui-services/prompt.service';
|
||||
import { mediumDialogSettings } from 'app/shared/utils/dialog-settings';
|
||||
import { BaseViewComponent } from 'app/site/base/base-view';
|
||||
import { AssignmentPollDialogComponent } from '../assignment-poll-dialog/assignment-poll-dialog.component';
|
||||
import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service';
|
||||
import { AssignmentPollService } from '../../services/assignment-poll.service';
|
||||
import { ViewAssignment } from '../../models/view-assignment';
|
||||
import { ViewAssignmentOption } from '../../models/view-assignment-option';
|
||||
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
|
||||
import { ViewAssignmentPollOption } from '../../models/view-assignment-poll-option';
|
||||
|
||||
/**
|
||||
* Component for a single assignment poll. Used in assignment detail view
|
||||
@ -69,24 +64,27 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
* used in this poll (e.g.)
|
||||
*/
|
||||
public get pollValues(): CalculablePollKey[] {
|
||||
return this.pollService.getVoteOptionsByPoll(this.poll);
|
||||
// return this.pollService.getVoteOptionsByPoll(this.poll);
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the description on the form differs from the poll's description
|
||||
*/
|
||||
public get dirtyDescription(): boolean {
|
||||
return this.descriptionForm.get('description').value !== this.poll.description;
|
||||
// return this.descriptionForm.get('description').value !== this.poll.description;
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if vote results can be seen by the user
|
||||
*/
|
||||
public get pollData(): boolean {
|
||||
if (!this.poll.has_votes) {
|
||||
/*if (!this.poll.has_votes) {
|
||||
return false;
|
||||
}
|
||||
return this.poll.published || this.canManage;
|
||||
return this.poll.published || this.canManage;*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -113,29 +111,12 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* constructor. Does nothing
|
||||
*
|
||||
* @param titleService
|
||||
* @param matSnackBar
|
||||
* @param pollService poll related calculations
|
||||
* @param operator permission checks
|
||||
* @param assignmentRepo The repository to the assignments
|
||||
* @param translate Translation service
|
||||
* @param dialog MatDialog for the vote entering dialog
|
||||
* @param promptService Prompts for confirmation dialogs
|
||||
* @param pdfService pdf service
|
||||
*/
|
||||
public constructor(
|
||||
titleService: Title,
|
||||
matSnackBar: MatSnackBar,
|
||||
public pollService: AssignmentPollService,
|
||||
private operator: OperatorService,
|
||||
private assignmentRepo: AssignmentRepositoryService,
|
||||
public translate: TranslateService,
|
||||
public dialog: MatDialog,
|
||||
private promptService: PromptService,
|
||||
private formBuilder: FormBuilder,
|
||||
private pdfService: AssignmentPollPdfService
|
||||
) {
|
||||
super(titleService, translate, matSnackBar);
|
||||
@ -145,12 +126,12 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
* Gets the currently selected majority choice option from the repo
|
||||
*/
|
||||
public ngOnInit(): void {
|
||||
this.majorityChoice =
|
||||
/*this.majorityChoice =
|
||||
this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) ||
|
||||
null;
|
||||
this.descriptionForm = this.formBuilder.group({
|
||||
description: this.poll ? this.poll.description : ''
|
||||
});
|
||||
});*/
|
||||
}
|
||||
|
||||
/**
|
||||
@ -159,10 +140,10 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
* TODO: Some confirmation (advanced logic (e.g. not deleting published?))
|
||||
*/
|
||||
public async onDeletePoll(): Promise<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)) {
|
||||
await this.assignmentRepo.deletePoll(this.poll).catch(this.raiseError);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
@ -180,15 +161,16 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
* @param option
|
||||
* @returns true if the quorum is successfully met
|
||||
*/
|
||||
public quorumReached(option: ViewAssignmentPollOption): boolean {
|
||||
const yesValue = this.poll.pollmethod === 'votes' ? 'Votes' : 'Yes';
|
||||
public quorumReached(option: ViewAssignmentOption): boolean {
|
||||
/*const yesValue = this.poll.pollmethod === 'votes' ? 'Votes' : 'Yes';
|
||||
const amount = option.votes.find(v => v.value === yesValue).weight;
|
||||
const yesQuorum = this.pollService.yesQuorum(
|
||||
this.majorityChoice,
|
||||
this.pollService.calculationDataFromPoll(this.poll),
|
||||
option
|
||||
);
|
||||
return yesQuorum && amount >= yesQuorum;
|
||||
return yesQuorum && amount >= yesQuorum;*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -196,15 +178,15 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
* closes successfully (validation is done there)
|
||||
*/
|
||||
public enterVotes(): void {
|
||||
const dialogRef = this.dialog.open(AssignmentPollDialogComponent, {
|
||||
data: this.poll,
|
||||
/*const dialogRef = this.dialog.open(AssignmentPollDialogComponent, {
|
||||
data: this.poll.copy(),
|
||||
...mediumDialogSettings
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.assignmentRepo.updateVotes(result, this.poll).catch(this.raiseError);
|
||||
}
|
||||
});
|
||||
});*/
|
||||
}
|
||||
|
||||
/**
|
||||
@ -220,7 +202,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
* Toggles the 'published' state
|
||||
*/
|
||||
public togglePublished(): void {
|
||||
this.assignmentRepo.updatePoll({ published: !this.poll.published }, this.poll);
|
||||
// this.assignmentRepo.updatePoll({ published: !this.poll.published }, this.poll);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -228,8 +210,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
*
|
||||
* @param option
|
||||
*/
|
||||
public toggleElected(option: ViewAssignmentPollOption): void {
|
||||
if (!this.operator.hasPerms('assignments.can_manage')) {
|
||||
public toggleElected(option: ViewAssignmentOption): void {
|
||||
/*if (!this.operator.hasPerms('assignments.can_manage')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -239,7 +221,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
);
|
||||
if (viewAssignmentRelatedUser) {
|
||||
this.assignmentRepo.markElected(viewAssignmentRelatedUser, this.assignment, !option.is_elected);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
@ -247,8 +229,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
* TODO: Better feedback
|
||||
*/
|
||||
public async onEditDescriptionButton(): Promise<void> {
|
||||
const desc: string = this.descriptionForm.get('description').value;
|
||||
await this.assignmentRepo.updatePoll({ description: desc }, this.poll).catch(this.raiseError);
|
||||
/*const desc: string = this.descriptionForm.get('description').value;
|
||||
await this.assignmentRepo.updatePoll({ description: desc }, this.poll).catch(this.raiseError);*/
|
||||
}
|
||||
|
||||
/**
|
||||
@ -256,8 +238,8 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
* @param option
|
||||
* @returns a translated
|
||||
*/
|
||||
public getQuorumReachedString(option: ViewAssignmentPollOption): string {
|
||||
const name = this.translate.instant(this.majorityChoice.display_name);
|
||||
public getQuorumReachedString(option: ViewAssignmentOption): string {
|
||||
/*const name = this.translate.instant(this.majorityChoice.display_name);
|
||||
const quorum = this.pollService.yesQuorum(
|
||||
this.majorityChoice,
|
||||
this.pollService.calculationDataFromPoll(this.poll),
|
||||
@ -266,6 +248,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
|
||||
const isReached = this.quorumReached(option)
|
||||
? this.translate.instant('reached')
|
||||
: this.translate.instant('not reached');
|
||||
return `${name} (${quorum}) ${isReached}`;
|
||||
return `${name} (${quorum}) ${isReached}`;*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
}
|
||||
|
@ -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 {}
|
@ -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;
|
||||
}
|
@ -1,9 +1,16 @@
|
||||
import { AssignmentPoll, AssignmentPollWithoutNestedModels } from 'app/shared/models/assignments/assignment-poll';
|
||||
import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model';
|
||||
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
|
||||
import { ViewAssignmentPollOption } from './view-assignment-poll-option';
|
||||
import { ViewGroup } from 'app/site/users/models/view-group';
|
||||
import { ViewUser } from 'app/site/users/models/view-user';
|
||||
import { ViewAssignmentOption } from './view-assignment-option';
|
||||
|
||||
export class ViewAssignmentPoll extends BaseProjectableViewModel<AssignmentPoll> {
|
||||
export interface AssignmentPollTitleInformation {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export class ViewAssignmentPoll extends BaseProjectableViewModel<AssignmentPoll>
|
||||
implements AssignmentPollTitleInformation {
|
||||
public static COLLECTIONSTRING = AssignmentPoll.COLLECTIONSTRING;
|
||||
protected _collectionString = AssignmentPoll.COLLECTIONSTRING;
|
||||
|
||||
@ -11,18 +18,10 @@ export class ViewAssignmentPoll extends BaseProjectableViewModel<AssignmentPoll>
|
||||
return this._model;
|
||||
}
|
||||
|
||||
public getListTitle = () => {
|
||||
return this.getTitle();
|
||||
};
|
||||
|
||||
public getProjectorTitle = () => {
|
||||
return this.getTitle();
|
||||
};
|
||||
|
||||
public getSlide(): ProjectorElementBuildDeskriptor {
|
||||
return {
|
||||
/*return {
|
||||
getBasicProjectorElement: options => ({
|
||||
name: 'assignments/poll',
|
||||
name: 'assignments/assignment-poll',
|
||||
assignment_id: this.assignment_id,
|
||||
poll_id: this.id,
|
||||
getIdentifiers: () => ['name', 'assignment_id', 'poll_id']
|
||||
@ -30,10 +29,15 @@ export class ViewAssignmentPoll extends BaseProjectableViewModel<AssignmentPoll>
|
||||
slideOptions: [],
|
||||
projectionDefaultName: 'assignments',
|
||||
getDialogTitle: () => 'TODO'
|
||||
};
|
||||
};*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
}
|
||||
|
||||
export interface ViewAssignmentPoll extends AssignmentPollWithoutNestedModels {
|
||||
options: ViewAssignmentPollOption[];
|
||||
interface TIAssignmentPollRelations {
|
||||
options: ViewAssignmentOption[];
|
||||
voted: ViewUser[];
|
||||
groups: ViewGroup[];
|
||||
}
|
||||
|
||||
export interface ViewAssignmentPoll extends AssignmentPollWithoutNestedModels, TIAssignmentPollRelations {}
|
||||
|
@ -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 {}
|
@ -45,9 +45,6 @@ export class ViewAssignment extends BaseViewModelWithAgendaItemAndListOfSpeakers
|
||||
return this._model;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Fix assignment creation: DO NOT create a ViewUser there...
|
||||
*/
|
||||
public get candidates(): ViewUser[] {
|
||||
if (!this.assignment_related_users) {
|
||||
return [];
|
||||
|
@ -3,11 +3,7 @@ import { Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { HtmlToPdfService } from 'app/core/pdf-services/html-to-pdf.service';
|
||||
import { PollVoteValue } from 'app/core/ui-services/poll.service';
|
||||
import { AssignmentPollService } from './assignment-poll.service';
|
||||
import { ViewAssignment } from '../models/view-assignment';
|
||||
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
|
||||
import { ViewAssignmentPollOption } from '../models/view-assignment-poll-option';
|
||||
|
||||
/**
|
||||
* Creates a PDF document from a single assignment
|
||||
@ -16,12 +12,6 @@ import { ViewAssignmentPollOption } from '../models/view-assignment-poll-option'
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AssignmentPdfService {
|
||||
/**
|
||||
* Will be set to `true` of a person was elected.
|
||||
* Determines that in indicator is shown under the table
|
||||
*/
|
||||
private showIsElected = false;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
@ -30,11 +20,7 @@ export class AssignmentPdfService {
|
||||
* @param pdfDocumentService PDF functions
|
||||
* @param htmlToPdfService Convert the assignment detail html text to pdf
|
||||
*/
|
||||
public constructor(
|
||||
private translate: TranslateService,
|
||||
private pollService: AssignmentPollService,
|
||||
private htmlToPdfService: HtmlToPdfService
|
||||
) {}
|
||||
public constructor(private translate: TranslateService, private htmlToPdfService: HtmlToPdfService) {}
|
||||
|
||||
/**
|
||||
* Main function to control the pdf generation.
|
||||
@ -161,7 +147,8 @@ export class AssignmentPdfService {
|
||||
* @param pollOption the poll options (yes, no, maybe [...])
|
||||
* @returns a line in the table
|
||||
*/
|
||||
private electedCandidateLine(candidateName: string, pollOption: ViewAssignmentPollOption): object {
|
||||
// TODO: type the result.
|
||||
/*private electedCandidateLine(candidateName: string, pollOption: ViewAssignmentOption): object {
|
||||
if (pollOption.is_elected) {
|
||||
this.showIsElected = true;
|
||||
return {
|
||||
@ -172,8 +159,8 @@ export class AssignmentPdfService {
|
||||
return {
|
||||
text: candidateName
|
||||
};
|
||||
}
|
||||
}
|
||||
}*
|
||||
}*/
|
||||
|
||||
/**
|
||||
* Creates the poll result table for all published polls
|
||||
@ -181,8 +168,9 @@ export class AssignmentPdfService {
|
||||
* @param assignment the ViewAssignment to create the document for
|
||||
* @returns the table as pdfmake object
|
||||
*/
|
||||
// TODO: type the result
|
||||
private createPollResultTable(assignment: ViewAssignment): object {
|
||||
const resultBody = [];
|
||||
/*const resultBody = [];
|
||||
for (let pollIndex = 0; pollIndex < assignment.polls.length; pollIndex++) {
|
||||
const poll = assignment.polls[pollIndex];
|
||||
if (poll.published) {
|
||||
@ -277,7 +265,8 @@ export class AssignmentPdfService {
|
||||
});
|
||||
}
|
||||
|
||||
return resultBody;
|
||||
return resultBody;*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -287,32 +276,32 @@ export class AssignmentPdfService {
|
||||
* @param optionLabel Usually Yes or No
|
||||
* @param value the amount of votes
|
||||
* @param poll the specific poll
|
||||
* @param pollOption the corresponding poll option
|
||||
* @param option the corresponding poll option
|
||||
* @returns a string a nicer number representation: "Yes 25 (22,2%)" or just "10"
|
||||
*/
|
||||
private parseVoteValue(
|
||||
/*private parseVoteValue(
|
||||
optionLabel: PollVoteValue,
|
||||
value: number,
|
||||
poll: ViewAssignmentPoll,
|
||||
pollOption: ViewAssignmentPollOption
|
||||
option: ViewAssignmentOption
|
||||
): string {
|
||||
let resultString = '';
|
||||
const label = this.translate.instant(this.pollService.getLabel(optionLabel));
|
||||
const valueString = this.pollService.getSpecialLabel(value);
|
||||
const percentNr = this.pollService.getPercent(
|
||||
this.pollService.calculationDataFromPoll(poll),
|
||||
pollOption,
|
||||
option,
|
||||
optionLabel
|
||||
);
|
||||
|
||||
resultString += `${label} ${valueString}`;
|
||||
if (
|
||||
percentNr &&
|
||||
!this.pollService.isAbstractOption(this.pollService.calculationDataFromPoll(poll), pollOption, optionLabel)
|
||||
!this.pollService.isAbstractOption(this.pollService.calculationDataFromPoll(poll), option, optionLabel)
|
||||
) {
|
||||
resultString += ` (${percentNr}%)`;
|
||||
}
|
||||
|
||||
return `${resultString}\n`;
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import { PdfDocumentService } from 'app/core/pdf-services/pdf-document.service';
|
||||
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
|
||||
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
|
||||
import { ConfigService } from 'app/core/ui-services/config.service';
|
||||
import { AssignmentPollMethod } from './assignment-poll.service';
|
||||
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
|
||||
|
||||
/**
|
||||
@ -113,6 +112,7 @@ export class AssignmentPollPdfService extends PollPdfService {
|
||||
* @param title The identifier of the motion
|
||||
* @param subtitle The actual motion title
|
||||
*/
|
||||
// TODO: typing of result
|
||||
protected createBallot(data: AbstractPollData): object {
|
||||
return {
|
||||
columns: [
|
||||
@ -136,8 +136,9 @@ export class AssignmentPollPdfService extends PollPdfService {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: typing of result
|
||||
private createCandidateFields(poll: ViewAssignmentPoll): object {
|
||||
const candidates = poll.options.sort((a, b) => {
|
||||
/*const candidates = poll.options.sort((a, b) => {
|
||||
return a.weight - b.weight;
|
||||
});
|
||||
const resultObject = candidates.map(cand => {
|
||||
@ -150,10 +151,12 @@ export class AssignmentPollPdfService extends PollPdfService {
|
||||
noEntry.margin[1] = 25;
|
||||
resultObject.push(noEntry);
|
||||
}
|
||||
return resultObject;
|
||||
return resultObject;*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
private createYNBallotEntry(option: string, method: AssignmentPollMethod): object {
|
||||
// TODO: typing of result
|
||||
/*private createYNBallotEntry(option: string, method: AssignmentPollmethods): object {
|
||||
const choices = method === 'yna' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No'];
|
||||
const columnstack = choices.map(choice => {
|
||||
return {
|
||||
@ -171,7 +174,7 @@ export class AssignmentPollPdfService extends PollPdfService {
|
||||
columns: columnstack
|
||||
}
|
||||
];
|
||||
}
|
||||
}*/
|
||||
|
||||
/**
|
||||
* Generates the poll description
|
||||
@ -179,10 +182,12 @@ export class AssignmentPollPdfService extends PollPdfService {
|
||||
* @param poll
|
||||
* @returns pdfMake definitions
|
||||
*/
|
||||
// TODO: typing of result
|
||||
private createPollHint(poll: ViewAssignmentPoll): object {
|
||||
return {
|
||||
/*return {
|
||||
text: poll.description || '',
|
||||
style: 'description'
|
||||
};
|
||||
};*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
@ -5,9 +5,7 @@ import { ViewConfig } from './models/view-config';
|
||||
|
||||
export const ConfigAppConfig: AppConfig = {
|
||||
name: 'settings',
|
||||
models: [
|
||||
{ collectionString: 'core/config', model: Config, viewModel: ViewConfig, repository: ConfigRepositoryService }
|
||||
],
|
||||
models: [{ model: Config, viewModel: ViewConfig, repository: ConfigRepositoryService }],
|
||||
mainMenuEntries: [
|
||||
{
|
||||
route: '/settings',
|
||||
|
@ -7,7 +7,6 @@ export const MediafileAppConfig: AppConfig = {
|
||||
name: 'mediafiles',
|
||||
models: [
|
||||
{
|
||||
collectionString: 'mediafiles/mediafile',
|
||||
model: Mediafile,
|
||||
viewModel: ViewMediafile,
|
||||
searchOrder: 5,
|
||||
|
12
client/src/app/site/motions/models/view-motion-option.ts
Normal file
12
client/src/app/site/motions/models/view-motion-option.ts
Normal 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 {}
|
41
client/src/app/site/motions/models/view-motion-poll.ts
Normal file
41
client/src/app/site/motions/models/view-motion-poll.ts
Normal 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 {}
|
17
client/src/app/site/motions/models/view-motion-vote.ts
Normal file
17
client/src/app/site/motions/models/view-motion-vote.ts
Normal 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 {}
|
@ -1382,7 +1382,9 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
|
||||
* Handler for creating a poll
|
||||
*/
|
||||
public createPoll(): void {
|
||||
this.repo.createPoll(this.motion).catch(this.raiseError);
|
||||
// TODO
|
||||
// this.repo.createPoll(<any>{}).catch(this.raiseError);
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,8 +1,5 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { CalculablePollKey } from 'app/core/ui-services/poll.service';
|
||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||
@ -35,8 +32,6 @@ export class MotionPollDialogComponent {
|
||||
public constructor(
|
||||
public dialogRef: MatDialogRef<MotionPollDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: MotionPoll,
|
||||
private matSnackBar: MatSnackBar,
|
||||
private translate: TranslateService,
|
||||
private pollService: MotionPollService
|
||||
) {
|
||||
this.pollKeys = this.pollService.pollValues;
|
||||
@ -57,7 +52,7 @@ export class MotionPollDialogComponent {
|
||||
* TODO better validation
|
||||
*/
|
||||
public submit(): void {
|
||||
if (this.data.yes === undefined || this.data.no === undefined || this.data.abstain === undefined) {
|
||||
/*if (this.data.yes === undefined || this.data.no === undefined || this.data.abstain === undefined) {
|
||||
this.matSnackBar.open(
|
||||
this.translate.instant('Please fill in all required values'),
|
||||
this.translate.instant('OK'),
|
||||
@ -67,7 +62,7 @@ export class MotionPollDialogComponent {
|
||||
);
|
||||
} else {
|
||||
this.dialogRef.close(this.data);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -5,7 +5,7 @@
|
||||
</ng-container>
|
||||
<ng-container class="meta-text-block-content">
|
||||
<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 class="poll-progress" *ngIf="poll[key] !== undefined">
|
||||
<mat-icon class="main-nav-color" matTooltip="{{ getLabel(key) | translate }}">
|
||||
|
@ -1,21 +1,16 @@
|
||||
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatDialog, MatSnackBar } from '@angular/material';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { ConstantsService } from 'app/core/core-services/constants.service';
|
||||
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
|
||||
import { CalculablePollKey } from 'app/core/ui-services/poll.service';
|
||||
import { PromptService } from 'app/core/ui-services/prompt.service';
|
||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||
import { infoDialogSettings } from 'app/shared/utils/dialog-settings';
|
||||
import { BaseViewComponent } from 'app/site/base/base-view';
|
||||
import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service';
|
||||
import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service';
|
||||
import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
|
||||
import { MotionPollDialogComponent } from './motion-poll-dialog.component';
|
||||
|
||||
/**
|
||||
* A component used to display and edit polls of a motion.
|
||||
@ -97,9 +92,7 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit {
|
||||
matSnackBar: MatSnackBar,
|
||||
public dialog: MatDialog,
|
||||
public pollService: MotionPollService,
|
||||
private motionRepo: MotionRepositoryService,
|
||||
private constants: ConstantsService,
|
||||
private promptService: PromptService,
|
||||
public perms: LocalPermissionsService,
|
||||
private pdfService: MotionPollPdfService
|
||||
) {
|
||||
@ -113,7 +106,7 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit {
|
||||
* Subscribes to updates of itself
|
||||
*/
|
||||
public ngOnInit(): void {
|
||||
this.poll = new MotionPoll(this.rawPoll);
|
||||
/*this.poll = new MotionPoll(this.rawPoll);
|
||||
this.motionRepo.getViewModelObservable(this.poll.motion_id).subscribe(viewmotion => {
|
||||
if (viewmotion) {
|
||||
const updatePoll = viewmotion.motion.polls.find(poll => poll.id === this.poll.id);
|
||||
@ -121,17 +114,18 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit {
|
||||
this.poll = new MotionPoll(updatePoll);
|
||||
}
|
||||
}
|
||||
});
|
||||
});*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a delete request for this poll after a confirmation dialog has been accepted.
|
||||
*/
|
||||
public async deletePoll(): Promise<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)) {
|
||||
this.motionRepo.deletePoll(this.poll).catch(this.raiseError);
|
||||
}
|
||||
}*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -192,7 +186,7 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit {
|
||||
* Triggers the 'edit poll' dialog'
|
||||
*/
|
||||
public editPoll(): void {
|
||||
const dialogRef = this.dialog.open(MotionPollDialogComponent, {
|
||||
/*const dialogRef = this.dialog.open(MotionPollDialogComponent, {
|
||||
data: { ...this.poll },
|
||||
...infoDialogSettings
|
||||
});
|
||||
@ -200,7 +194,8 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit {
|
||||
if (result) {
|
||||
this.motionRepo.updatePoll(result).catch(this.raiseError);
|
||||
}
|
||||
});
|
||||
});*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -209,7 +204,8 @@ export class MotionPollComponent extends BaseViewComponent implements OnInit {
|
||||
* @returns true if the quorum is reached
|
||||
*/
|
||||
public get quorumYesReached(): boolean {
|
||||
return this.poll.yes >= this.yesQuorum;
|
||||
// return this.poll.yes >= this.yesQuorum;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -28,52 +28,44 @@ export const MotionsAppConfig: AppConfig = {
|
||||
name: 'motions',
|
||||
models: [
|
||||
{
|
||||
collectionString: 'motions/motion',
|
||||
model: Motion,
|
||||
viewModel: ViewMotion,
|
||||
searchOrder: 2,
|
||||
repository: MotionRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'motions/category',
|
||||
model: Category,
|
||||
viewModel: ViewCategory,
|
||||
searchOrder: 6,
|
||||
repository: CategoryRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'motions/workflow',
|
||||
model: Workflow,
|
||||
viewModel: ViewWorkflow,
|
||||
repository: WorkflowRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'motions/state',
|
||||
model: State,
|
||||
viewModel: ViewState,
|
||||
repository: StateRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'motions/motion-comment-section',
|
||||
model: MotionCommentSection,
|
||||
viewModel: ViewMotionCommentSection,
|
||||
repository: MotionCommentSectionRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'motions/motion-change-recommendation',
|
||||
model: MotionChangeRecommendation,
|
||||
viewModel: ViewMotionChangeRecommendation,
|
||||
repository: ChangeRecommendationRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'motions/motion-block',
|
||||
model: MotionBlock,
|
||||
viewModel: ViewMotionBlock,
|
||||
searchOrder: 7,
|
||||
repository: MotionBlockRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'motions/statute-paragraph',
|
||||
model: StatuteParagraph,
|
||||
viewModel: ViewStatuteParagraph,
|
||||
searchOrder: 9,
|
||||
|
@ -10,11 +10,9 @@ import { MotionRepositoryService } from 'app/core/repositories/motions/motion-re
|
||||
import { StatuteParagraphRepositoryService } from 'app/core/repositories/motions/statute-paragraph-repository.service';
|
||||
import { ConfigService } from 'app/core/ui-services/config.service';
|
||||
import { LinenumberingService } from 'app/core/ui-services/linenumbering.service';
|
||||
import { CalculablePollKey } from 'app/core/ui-services/poll.service';
|
||||
import { ViewUnifiedChange, ViewUnifiedChangeType } from 'app/shared/models/motions/view-unified-change';
|
||||
import { getRecommendationTypeName } from 'app/shared/utils/recommendation-type-names';
|
||||
import { MotionExportInfo } from './motion-export.service';
|
||||
import { MotionPollService } from './motion-poll.service';
|
||||
import { ChangeRecoMode, InfoToExport, LineNumberingMode, PERSONAL_NOTE_ID } from '../motions.constants';
|
||||
import { ViewMotion } from '../models/view-motion';
|
||||
import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragraph';
|
||||
@ -62,7 +60,6 @@ export class MotionPdfService {
|
||||
private configService: ConfigService,
|
||||
private pdfDocumentService: PdfDocumentService,
|
||||
private htmlToPdfService: HtmlToPdfService,
|
||||
private pollService: MotionPollService,
|
||||
private linenumberingService: LinenumberingService,
|
||||
private commentRepo: MotionCommentSectionRepositoryService
|
||||
) {}
|
||||
@ -366,7 +363,7 @@ export class MotionPdfService {
|
||||
const column2 = [];
|
||||
const column3 = [];
|
||||
motion.motion.polls.map((poll, index) => {
|
||||
if (poll.has_votes) {
|
||||
/*if (poll.has_votes) {
|
||||
if (motion.motion.polls.length > 1) {
|
||||
column1.push(index + 1 + '. ' + this.translate.instant('Vote'));
|
||||
column2.push('');
|
||||
@ -389,7 +386,7 @@ export class MotionPdfService {
|
||||
? column3.push('')
|
||||
: column3.push(`(${this.pollService.calculatePercentage(poll, value)} %)`);
|
||||
});
|
||||
}
|
||||
}*/
|
||||
});
|
||||
metaTableBody.push([
|
||||
{
|
||||
|
@ -73,7 +73,7 @@ export class MotionPollService extends PollService {
|
||||
* the base cannot be calculated
|
||||
*/
|
||||
public getBaseAmount(poll: MotionPoll): number {
|
||||
if (!poll) {
|
||||
/*if (!poll) {
|
||||
return 0;
|
||||
}
|
||||
switch (this.percentBase) {
|
||||
@ -102,7 +102,8 @@ export class MotionPollService extends PollService {
|
||||
return 0;
|
||||
}
|
||||
return poll.yes + poll.no;
|
||||
}
|
||||
}*/
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -16,25 +16,21 @@ export const ProjectorAppConfig: AppConfig = {
|
||||
name: 'projector',
|
||||
models: [
|
||||
{
|
||||
collectionString: 'core/projector',
|
||||
model: Projector,
|
||||
viewModel: ViewProjector,
|
||||
repository: ProjectorRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'core/projection-default',
|
||||
model: ProjectionDefault,
|
||||
viewModel: ViewProjectionDefault,
|
||||
repository: ProjectionDefaultRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'core/countdown',
|
||||
model: Countdown,
|
||||
viewModel: ViewCountdown,
|
||||
repository: CountdownRepositoryService
|
||||
},
|
||||
{
|
||||
collectionString: 'core/projector-message',
|
||||
model: ProjectorMessage,
|
||||
viewModel: ViewProjectorMessage,
|
||||
repository: ProjectorMessageRepositoryService
|
||||
|
@ -7,7 +7,6 @@ export const TagAppConfig: AppConfig = {
|
||||
name: 'tag',
|
||||
models: [
|
||||
{
|
||||
collectionString: 'core/tag',
|
||||
model: Tag,
|
||||
viewModel: ViewTag,
|
||||
searchOrder: 8,
|
||||
|
@ -7,7 +7,6 @@ export const TopicsAppConfig: AppConfig = {
|
||||
name: 'topics',
|
||||
models: [
|
||||
{
|
||||
collectionString: 'topics/topic',
|
||||
model: Topic,
|
||||
viewModel: ViewTopic,
|
||||
searchOrder: 1,
|
||||
|
@ -13,15 +13,13 @@ export const UsersAppConfig: AppConfig = {
|
||||
name: 'users',
|
||||
models: [
|
||||
{
|
||||
collectionString: 'users/user',
|
||||
model: User,
|
||||
viewModel: ViewUser,
|
||||
searchOrder: 4,
|
||||
repository: UserRepositoryService
|
||||
},
|
||||
{ collectionString: 'users/group', model: Group, viewModel: ViewGroup, repository: GroupRepositoryService },
|
||||
{ model: Group, viewModel: ViewGroup, repository: GroupRepositoryService },
|
||||
{
|
||||
collectionString: 'users/personal-note',
|
||||
model: PersonalNote,
|
||||
viewModel: ViewPersonalNote,
|
||||
repository: PersonalNoteRepositoryService
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { PollVoteValue } from 'app/core/ui-services/poll.service';
|
||||
import { AssignmentPercentBase, AssignmentPollMethod } from 'app/site/assignments/services/assignment-poll.service';
|
||||
|
||||
export interface PollSlideOption {
|
||||
user: string;
|
||||
@ -12,12 +11,12 @@ export interface PollSlideOption {
|
||||
|
||||
export interface PollSlideData {
|
||||
title: string;
|
||||
assignments_poll_100_percent_base: AssignmentPercentBase;
|
||||
assignments_poll_100_percent_base: any /*AssignmentPercentBase*/;
|
||||
poll: {
|
||||
published: boolean;
|
||||
description?: string;
|
||||
has_votes?: boolean;
|
||||
pollmethod?: AssignmentPollMethod;
|
||||
pollmethod?: any /*AssignmentPollmethods*/;
|
||||
votesno?: string;
|
||||
votesabstain?: string;
|
||||
votesvalid?: string;
|
||||
|
@ -1,14 +1,7 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { SlideData } from 'app/core/core-services/projector-data.service';
|
||||
import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service';
|
||||
import {
|
||||
AssignmentPollService,
|
||||
CalculationData,
|
||||
SummaryPollKey
|
||||
} from 'app/site/assignments/services/assignment-poll.service';
|
||||
import { BaseSlideComponent } from 'app/slides/base-slide-component';
|
||||
import { PollSlideData, PollSlideOption } from './poll-slide-data';
|
||||
|
||||
@ -20,20 +13,19 @@ import { PollSlideData, PollSlideOption } from './poll-slide-data';
|
||||
export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
|
||||
private _data: SlideData<PollSlideData>;
|
||||
|
||||
private calculationData: CalculationData;
|
||||
|
||||
public get pollValues(): SummaryPollKey[] {
|
||||
public get pollValues(): any {
|
||||
// SummaryPollKey[] {
|
||||
if (!this.data) {
|
||||
return [];
|
||||
}
|
||||
const values: SummaryPollKey[] = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast'];
|
||||
const values: any /*SummaryPollKey[]*/ = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast'];
|
||||
return values.filter(val => this.data.data.poll[val] !== null);
|
||||
}
|
||||
|
||||
@Input()
|
||||
public set data(data: SlideData<PollSlideData>) {
|
||||
this._data = data;
|
||||
this.calculationData = {
|
||||
/*this.calculationData = {
|
||||
pollMethod: data.data.poll.pollmethod,
|
||||
votesno: parseFloat(data.data.poll.votesno),
|
||||
votesabstain: parseFloat(data.data.poll.votesabstain),
|
||||
@ -51,17 +43,13 @@ export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
|
||||
};
|
||||
}),
|
||||
percentBase: data.data.assignments_poll_100_percent_base
|
||||
};
|
||||
};*/
|
||||
}
|
||||
|
||||
public get data(): SlideData<PollSlideData> {
|
||||
return this._data;
|
||||
}
|
||||
|
||||
public constructor(private pollService: AssignmentPollService, private translate: TranslateService) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* get a vote's numerical or special label, including percent values if these are to
|
||||
* be displayed
|
||||
@ -70,7 +58,7 @@ export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
|
||||
* @param option
|
||||
*/
|
||||
public getVotePercent(key: PollVoteValue, option: PollSlideOption): string {
|
||||
const calcOption = {
|
||||
/*const calcOption = {
|
||||
votes: option.votes.map(vote => {
|
||||
return { weight: parseFloat(vote.weight), value: vote.value };
|
||||
})
|
||||
@ -79,19 +67,22 @@ export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
|
||||
const number = this.translate.instant(
|
||||
this.pollService.getSpecialLabel(parseFloat(option.votes.find(v => v.value === key).weight))
|
||||
);
|
||||
return percent === null ? number : `${number} (${percent}%)`;
|
||||
return percent === null ? number : `${number} (${percent}%)`;*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
public getPollPercent(key: CalculablePollKey): string {
|
||||
const percent = this.pollService.getValuePercent(this.calculationData, key);
|
||||
/*const percent = this.pollService.getValuePercent(this.calculationData, key);
|
||||
const number = this.translate.instant(this.pollService.getSpecialLabel(this.calculationData[key]));
|
||||
return percent === null ? number : `${number} (${percent}%)`;
|
||||
return percent === null ? number : `${number} (${percent}%)`;*/
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a translated label for a key
|
||||
*/
|
||||
public getLabel(key: CalculablePollKey): string {
|
||||
return this.translate.instant(this.pollService.getLabel(key));
|
||||
// return this.translate.instant(this.pollService.getLabel(key));
|
||||
throw new Error('TODO');
|
||||
}
|
||||
}
|
||||
|
@ -21,15 +21,21 @@ class AgendaItemMixin(models.Model):
|
||||
class Meta(Unsafe):
|
||||
abstract = True
|
||||
|
||||
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.
|
||||
"""
|
||||
agenda_item_update_information: Dict[str, Any] = {}
|
||||
|
||||
agenda_item_skip_autoupdate = False
|
||||
Important: Do not just write this into the class definition, becuase the object would become
|
||||
shared within all instances inherited from this class!
|
||||
"""
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def agenda_item(self):
|
||||
|
@ -35,7 +35,6 @@ def listen_to_related_object_post_save(sender, instance, created, **kwargs):
|
||||
|
||||
if is_agenda_item_content_object:
|
||||
if created:
|
||||
|
||||
if instance.get_collection_string() == "topics/topic":
|
||||
should_create_item = True
|
||||
elif config["agenda_item_creation"] == "always":
|
||||
|
@ -42,3 +42,12 @@ class AssignmentAccessPermissions(BaseAccessPermissions):
|
||||
data = []
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class AssignmentPollAccessPermissions(BaseAccessPermissions):
|
||||
base_permission = "assignments.can_see"
|
||||
|
||||
async def get_restricted_data(
|
||||
self, full_data: List[Dict[str, Any]], user_id: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
return full_data
|
||||
|
@ -15,7 +15,11 @@ class AssignmentsAppConfig(AppConfig):
|
||||
from . import serializers # noqa
|
||||
from .projector import register_projector_slides
|
||||
from .signals import get_permission_change_data
|
||||
from .views import AssignmentViewSet, AssignmentPollViewSet
|
||||
from .views import (
|
||||
AssignmentViewSet,
|
||||
AssignmentPollViewSet,
|
||||
AssignmentVoteViewSet,
|
||||
)
|
||||
|
||||
# Define projector elements.
|
||||
register_projector_slides()
|
||||
@ -30,7 +34,14 @@ class AssignmentsAppConfig(AppConfig):
|
||||
router.register(
|
||||
self.get_model("Assignment").get_collection_string(), AssignmentViewSet
|
||||
)
|
||||
router.register("assignments/poll", AssignmentPollViewSet)
|
||||
router.register(
|
||||
self.get_model("AssignmentPoll").get_collection_string(),
|
||||
AssignmentPollViewSet,
|
||||
)
|
||||
router.register(
|
||||
self.get_model("AssignmentVote").get_collection_string(),
|
||||
AssignmentVoteViewSet,
|
||||
)
|
||||
|
||||
# Register required_users
|
||||
required_user.add_collection_string(
|
||||
@ -47,13 +58,16 @@ class AssignmentsAppConfig(AppConfig):
|
||||
Yields all Cachables required on startup i. e. opening the websocket
|
||||
connection.
|
||||
"""
|
||||
yield self.get_model("Assignment")
|
||||
for model_name in ("Assignment", "AssignmentPoll", "AssignmentVote"):
|
||||
yield self.get_model(model_name)
|
||||
|
||||
|
||||
def required_users(element: Dict[str, Any]) -> Set[int]:
|
||||
"""
|
||||
Returns all user ids that are displayed as candidates (including poll
|
||||
options) in the assignment element.
|
||||
|
||||
TODO: Adapt this method for new poll structure!!
|
||||
"""
|
||||
candidates = set(
|
||||
related_user["user_id"] for related_user in element["assignment_related_users"]
|
||||
|
@ -12,25 +12,6 @@ def get_config_variables():
|
||||
to be evaluated during app loading (see apps.py).
|
||||
"""
|
||||
# Ballot and ballot papers
|
||||
yield ConfigVariable(
|
||||
name="assignments_poll_vote_values",
|
||||
default_value="auto",
|
||||
input_type="choice",
|
||||
label="Election method",
|
||||
choices=(
|
||||
{"value": "auto", "display_name": "Automatic assign of method"},
|
||||
{"value": "votes", "display_name": "Always one option per candidate"},
|
||||
{
|
||||
"value": "yesnoabstain",
|
||||
"display_name": "Always Yes-No-Abstain per candidate",
|
||||
},
|
||||
{"value": "yesno", "display_name": "Always Yes/No per candidate"},
|
||||
),
|
||||
weight=410,
|
||||
group="Elections",
|
||||
subgroup="Ballot and ballot papers",
|
||||
)
|
||||
|
||||
yield ConfigVariable(
|
||||
name="assignments_poll_100_percent_base",
|
||||
default_value="YES_NO_ABSTAIN",
|
||||
|
139
openslides/assignments/migrations/0008_auto_20191017_1040.py
Normal file
139
openslides/assignments/migrations/0008_auto_20191017_1040.py
Normal 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"),
|
||||
]
|
@ -1,5 +1,4 @@
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from django.conf import settings
|
||||
@ -11,19 +10,16 @@ from openslides.agenda.models import Speaker
|
||||
from openslides.core.config import config
|
||||
from openslides.core.models import Tag
|
||||
from openslides.mediafiles.models import Mediafile
|
||||
from openslides.poll.models import (
|
||||
BaseOption,
|
||||
BasePoll,
|
||||
BaseVote,
|
||||
CollectDefaultVotesMixin,
|
||||
PublishPollMixin,
|
||||
)
|
||||
from openslides.poll.models import BaseOption, BasePoll, BaseVote
|
||||
from openslides.utils.autoupdate import inform_changed_data
|
||||
from openslides.utils.exceptions import OpenSlidesError
|
||||
from openslides.utils.models import RESTModelMixin
|
||||
|
||||
from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE
|
||||
from .access_permissions import AssignmentAccessPermissions
|
||||
from .access_permissions import (
|
||||
AssignmentAccessPermissions,
|
||||
AssignmentPollAccessPermissions,
|
||||
)
|
||||
|
||||
|
||||
class AssignmentRelatedUser(RESTModelMixin, models.Model):
|
||||
@ -196,7 +192,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
|
||||
"""
|
||||
return self.elected.filter(pk=user.pk).exists()
|
||||
|
||||
def set_candidate(self, user):
|
||||
def add_candidate(self, user):
|
||||
"""
|
||||
Adds the user as candidate.
|
||||
"""
|
||||
@ -215,7 +211,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
|
||||
user=user, defaults={"elected": True}
|
||||
)
|
||||
|
||||
def delete_related_user(self, user):
|
||||
def remove_candidate(self, user):
|
||||
"""
|
||||
Delete the connection from the assignment to the user.
|
||||
"""
|
||||
@ -233,59 +229,6 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
|
||||
|
||||
self.phase = phase
|
||||
|
||||
def create_poll(self):
|
||||
"""
|
||||
Creates a new poll for the assignment and adds all candidates to all
|
||||
lists of speakers of related agenda items.
|
||||
"""
|
||||
candidates = self.candidates.all()
|
||||
|
||||
# Find out the method of the election
|
||||
if config["assignments_poll_vote_values"] == "votes":
|
||||
pollmethod = "votes"
|
||||
elif config["assignments_poll_vote_values"] == "yesnoabstain":
|
||||
pollmethod = "yna"
|
||||
elif config["assignments_poll_vote_values"] == "yesno":
|
||||
pollmethod = "yn"
|
||||
else:
|
||||
# config['assignments_poll_vote_values'] == 'auto'
|
||||
# candidates <= available posts -> yes/no/abstain
|
||||
if len(candidates) <= (self.open_posts - self.elected.count()):
|
||||
pollmethod = "yna"
|
||||
else:
|
||||
pollmethod = "votes"
|
||||
|
||||
# Create the poll with the candidates.
|
||||
poll = self.polls.create(
|
||||
description=self.poll_description_default, pollmethod=pollmethod
|
||||
)
|
||||
options = []
|
||||
related_users = AssignmentRelatedUser.objects.filter(
|
||||
assignment__id=self.id
|
||||
).exclude(elected=True)
|
||||
for related_user in related_users:
|
||||
options.append(
|
||||
{"candidate": related_user.user, "weight": related_user.weight}
|
||||
)
|
||||
poll.set_options(options, skip_autoupdate=True)
|
||||
inform_changed_data(self)
|
||||
|
||||
# Add all candidates to list of speakers of related agenda item
|
||||
# TODO: Try to do this in a bulk create
|
||||
if config["assignments_add_candidates_to_list_of_speakers"]:
|
||||
for candidate in self.candidates:
|
||||
try:
|
||||
Speaker.objects.add(
|
||||
candidate, self.list_of_speakers, skip_autoupdate=True
|
||||
)
|
||||
except OpenSlidesError:
|
||||
# The Speaker is already on the list. Do nothing.
|
||||
# TODO: Find a smart way not to catch the error concerning AnonymousUser.
|
||||
pass
|
||||
inform_changed_data(self.list_of_speakers)
|
||||
|
||||
return poll
|
||||
|
||||
def vote_results(self, only_published):
|
||||
"""
|
||||
Returns a table represented as a list with all candidates from all
|
||||
@ -332,88 +275,76 @@ class AssignmentVote(RESTModelMixin, BaseVote):
|
||||
class Meta:
|
||||
default_permissions = ()
|
||||
|
||||
def get_root_rest_element(self):
|
||||
"""
|
||||
Returns the assignment to this instance which is the root REST element.
|
||||
"""
|
||||
return self.option.poll.assignment
|
||||
|
||||
|
||||
class AssignmentOption(RESTModelMixin, BaseOption):
|
||||
vote_class = AssignmentVote
|
||||
|
||||
poll = models.ForeignKey(
|
||||
"AssignmentPoll", on_delete=models.CASCADE, related_name="options"
|
||||
)
|
||||
candidate = models.ForeignKey(
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True
|
||||
)
|
||||
weight = models.IntegerField(default=0)
|
||||
|
||||
vote_class = AssignmentVote
|
||||
|
||||
class Meta:
|
||||
default_permissions = ()
|
||||
|
||||
def __str__(self):
|
||||
return str(self.candidate)
|
||||
|
||||
def get_root_rest_element(self):
|
||||
"""
|
||||
Returns the assignment to this instance which is the root REST element.
|
||||
"""
|
||||
return self.poll.assignment
|
||||
return self.poll
|
||||
|
||||
|
||||
# Meta-TODO: Is this todo resolved?
|
||||
# TODO: remove the type-ignoring in the next line, after this is solved:
|
||||
# https://github.com/python/mypy/issues/3855
|
||||
class AssignmentPoll( # type: ignore
|
||||
RESTModelMixin, CollectDefaultVotesMixin, PublishPollMixin, BasePoll
|
||||
):
|
||||
class AssignmentPoll(RESTModelMixin, BasePoll):
|
||||
access_permissions = AssignmentPollAccessPermissions()
|
||||
option_class = AssignmentOption
|
||||
|
||||
assignment = models.ForeignKey(
|
||||
Assignment, on_delete=models.CASCADE, related_name="polls"
|
||||
)
|
||||
pollmethod = models.CharField(max_length=5, default="yna")
|
||||
description = models.CharField(max_length=79, blank=True)
|
||||
|
||||
votesabstain = models.DecimalField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
""" General abstain votes, used for pollmethod 'votes' """
|
||||
votesno = models.DecimalField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
""" General no votes, used for pollmethod 'votes' """
|
||||
POLLMETHOD_YN = "YN"
|
||||
POLLMETHOD_YNA = "YNA"
|
||||
POLLMETHOD_VOTES = "votes"
|
||||
POLLMETHODS = (("YN", "YN"), ("YNA", "YNA"), ("votes", "votes"))
|
||||
pollmethod = models.CharField(max_length=5, choices=POLLMETHODS)
|
||||
|
||||
global_abstain = models.BooleanField(default=True)
|
||||
global_no = models.BooleanField(default=True)
|
||||
|
||||
votes_amount = models.IntegerField(default=1, validators=[MinValueValidator(1)])
|
||||
""" For "votes" mode: The amount of votes a voter can give. """
|
||||
|
||||
allow_multiple_votes_per_candidate = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
default_permissions = ()
|
||||
|
||||
def get_assignment(self):
|
||||
return self.assignment
|
||||
def create_options(self):
|
||||
related_users = AssignmentRelatedUser.objects.filter(
|
||||
assignment__id=self.assignment.id
|
||||
).exclude(elected=True)
|
||||
options = [
|
||||
AssignmentOption(
|
||||
user=related_user.user, weight=related_user.weight, poll=self
|
||||
)
|
||||
for related_user in related_users
|
||||
]
|
||||
AssignmentOption.objects.bulk_create(options)
|
||||
inform_changed_data(self)
|
||||
|
||||
def get_vote_values(self):
|
||||
if self.pollmethod == "yna":
|
||||
return ["Yes", "No", "Abstain"]
|
||||
if self.pollmethod == "yn":
|
||||
return ["Yes", "No"]
|
||||
return ["Votes"]
|
||||
|
||||
def get_ballot(self):
|
||||
return self.assignment.polls.filter(id__lte=self.pk).count()
|
||||
|
||||
def get_percent_base_choice(self):
|
||||
return config["assignments_poll_100_percent_base"]
|
||||
|
||||
def get_root_rest_element(self):
|
||||
"""
|
||||
Returns the assignment to this instance which is the root REST element.
|
||||
"""
|
||||
return self.assignment
|
||||
# Add all candidates to list of speakers of related agenda item
|
||||
if config["assignments_add_candidates_to_list_of_speakers"]:
|
||||
for related_user in related_users:
|
||||
try:
|
||||
Speaker.objects.add(
|
||||
related_user.user,
|
||||
self.assignment.list_of_speakers,
|
||||
skip_autoupdate=True,
|
||||
)
|
||||
except OpenSlidesError:
|
||||
# The Speaker is already on the list. Do nothing.
|
||||
pass
|
||||
inform_changed_data(self.assignment.list_of_speakers)
|
||||
|
@ -1,18 +1,20 @@
|
||||
from django.db import transaction
|
||||
|
||||
from openslides.poll.serializers import default_votes_validator
|
||||
from openslides.poll.serializers import (
|
||||
BASE_OPTION_FIELDS,
|
||||
BASE_POLL_FIELDS,
|
||||
BASE_VOTE_FIELDS,
|
||||
)
|
||||
from openslides.utils.rest_api import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
DecimalField,
|
||||
DictField,
|
||||
IdPrimaryKeyRelatedField,
|
||||
IntegerField,
|
||||
ListField,
|
||||
ModelSerializer,
|
||||
SerializerMethodField,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
from ..utils.auth import has_perm
|
||||
from ..utils.auth import get_group_model, has_perm
|
||||
from ..utils.autoupdate import inform_changed_data
|
||||
from ..utils.validate import validate_html
|
||||
from .models import (
|
||||
@ -42,13 +44,7 @@ class AssignmentRelatedUserSerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = AssignmentRelatedUser
|
||||
fields = (
|
||||
"id",
|
||||
"user",
|
||||
"elected",
|
||||
"assignment",
|
||||
"weight",
|
||||
) # js-data needs the assignment-id in the nested object to define relations.
|
||||
fields = ("id", "user", "elected", "weight")
|
||||
|
||||
|
||||
class AssignmentVoteSerializer(ModelSerializer):
|
||||
@ -56,9 +52,15 @@ class AssignmentVoteSerializer(ModelSerializer):
|
||||
Serializer for assignment.models.AssignmentVote objects.
|
||||
"""
|
||||
|
||||
pollstate = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AssignmentVote
|
||||
fields = ("weight", "value")
|
||||
fields = ("pollstate",) + BASE_VOTE_FIELDS
|
||||
read_only_fields = BASE_VOTE_FIELDS
|
||||
|
||||
def get_pollstate(self, vote):
|
||||
return vote.option.poll.state
|
||||
|
||||
|
||||
class AssignmentOptionSerializer(ModelSerializer):
|
||||
@ -66,24 +68,21 @@ class AssignmentOptionSerializer(ModelSerializer):
|
||||
Serializer for assignment.models.AssignmentOption objects.
|
||||
"""
|
||||
|
||||
votes = AssignmentVoteSerializer(many=True, read_only=True)
|
||||
is_elected = SerializerMethodField()
|
||||
yes = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True)
|
||||
no = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True)
|
||||
abstain = DecimalField(
|
||||
max_digits=15, decimal_places=6, min_value=-2, read_only=True
|
||||
)
|
||||
|
||||
votes = IdPrimaryKeyRelatedField(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AssignmentOption
|
||||
fields = ("id", "candidate", "is_elected", "votes", "poll", "weight")
|
||||
|
||||
def get_is_elected(self, obj):
|
||||
"""
|
||||
Returns the election status of the candidate of this option.
|
||||
If the candidate is None (e.g. deleted) the result is False.
|
||||
"""
|
||||
if not obj.candidate:
|
||||
return False
|
||||
return obj.poll.assignment.is_elected(obj.candidate)
|
||||
fields = ("user",) + BASE_OPTION_FIELDS
|
||||
read_only_fields = ("user",) + BASE_OPTION_FIELDS
|
||||
|
||||
|
||||
class AssignmentAllPollSerializer(ModelSerializer):
|
||||
class AssignmentPollSerializer(ModelSerializer):
|
||||
"""
|
||||
Serializer for assignment.models.AssignmentPoll objects.
|
||||
|
||||
@ -91,103 +90,42 @@ class AssignmentAllPollSerializer(ModelSerializer):
|
||||
"""
|
||||
|
||||
options = AssignmentOptionSerializer(many=True, read_only=True)
|
||||
votes = ListField(
|
||||
child=DictField(
|
||||
child=DecimalField(max_digits=15, decimal_places=6, min_value=-2)
|
||||
),
|
||||
write_only=True,
|
||||
required=False,
|
||||
|
||||
title = CharField(allow_blank=False, required=True)
|
||||
groups = IdPrimaryKeyRelatedField(
|
||||
many=True, required=False, queryset=get_group_model().objects.all()
|
||||
)
|
||||
voted = IdPrimaryKeyRelatedField(many=True, read_only=True)
|
||||
|
||||
votesvalid = DecimalField(
|
||||
max_digits=15, decimal_places=6, min_value=-2, read_only=True
|
||||
)
|
||||
votesinvalid = DecimalField(
|
||||
max_digits=15, decimal_places=6, min_value=-2, read_only=True
|
||||
)
|
||||
votescast = DecimalField(
|
||||
max_digits=15, decimal_places=6, min_value=-2, read_only=True
|
||||
)
|
||||
has_votes = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AssignmentPoll
|
||||
fields = (
|
||||
"id",
|
||||
"pollmethod",
|
||||
"description",
|
||||
"published",
|
||||
"options",
|
||||
"votesabstain",
|
||||
"votesno",
|
||||
"votesvalid",
|
||||
"votesinvalid",
|
||||
"votescast",
|
||||
"votes",
|
||||
"has_votes",
|
||||
"assignment",
|
||||
) # js-data needs the assignment-id in the nested object to define relations.
|
||||
read_only_fields = ("pollmethod",)
|
||||
validators = (default_votes_validator,)
|
||||
"pollmethod",
|
||||
"votes_amount",
|
||||
"allow_multiple_votes_per_candidate",
|
||||
"global_no",
|
||||
"global_abstain",
|
||||
) + BASE_POLL_FIELDS
|
||||
read_only_fields = ("state",)
|
||||
|
||||
def get_has_votes(self, obj):
|
||||
"""
|
||||
Returns True if this poll has some votes.
|
||||
"""
|
||||
return obj.has_votes()
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
Customized update method for polls. To update votes use the write
|
||||
only field 'votes'.
|
||||
|
||||
Example data for a 'pollmethod'='yna' poll with two candidates:
|
||||
|
||||
"votes": [{"Yes": 10, "No": 4, "Abstain": -2},
|
||||
{"Yes": -1, "No": 0, "Abstain": -2}]
|
||||
|
||||
Example data for a 'pollmethod' ='yn' poll with two candidates:
|
||||
"votes": [{"Votes": 10}, {"Votes": 0}]
|
||||
"""
|
||||
# Update votes.
|
||||
votes = validated_data.get("votes")
|
||||
if votes:
|
||||
options = list(instance.get_options())
|
||||
if len(votes) != len(options):
|
||||
raise ValidationError(
|
||||
{
|
||||
"detail": "You have to submit data for {0} candidates.",
|
||||
"args": [len(options)],
|
||||
}
|
||||
)
|
||||
for index, option in enumerate(options):
|
||||
if len(votes[index]) != len(instance.get_vote_values()):
|
||||
raise ValidationError(
|
||||
{
|
||||
"detail": "You have to submit data for {0} vote values",
|
||||
"args": [len(instance.get_vote_values())],
|
||||
}
|
||||
)
|
||||
for vote_value, __ in votes[index].items():
|
||||
if vote_value not in instance.get_vote_values():
|
||||
raise ValidationError(
|
||||
{
|
||||
"detail": "Vote value {0} is invalid.",
|
||||
"args": [vote_value],
|
||||
}
|
||||
)
|
||||
instance.set_vote_objects_with_values(
|
||||
option, votes[index], skip_autoupdate=True
|
||||
)
|
||||
|
||||
# Update remaining writeable fields.
|
||||
instance.description = validated_data.get("description", instance.description)
|
||||
instance.published = validated_data.get("published", instance.published)
|
||||
instance.votesabstain = validated_data.get(
|
||||
"votesabstain", instance.votesabstain
|
||||
)
|
||||
instance.votesno = validated_data.get("votesno", instance.votesno)
|
||||
instance.votesvalid = validated_data.get("votesvalid", instance.votesvalid)
|
||||
instance.votesinvalid = validated_data.get(
|
||||
"votesinvalid", instance.votesinvalid
|
||||
)
|
||||
instance.votescast = validated_data.get("votescast", instance.votescast)
|
||||
instance.save()
|
||||
return instance
|
||||
""" Prevent from updating the assignment """
|
||||
validated_data.pop("assignment", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class AssignmentFullSerializer(ModelSerializer):
|
||||
class AssignmentSerializer(ModelSerializer):
|
||||
"""
|
||||
Serializer for assignment.models.Assignment objects. With all polls.
|
||||
"""
|
||||
@ -195,7 +133,7 @@ class AssignmentFullSerializer(ModelSerializer):
|
||||
assignment_related_users = AssignmentRelatedUserSerializer(
|
||||
many=True, read_only=True
|
||||
)
|
||||
polls = AssignmentAllPollSerializer(many=True, read_only=True)
|
||||
polls = IdPrimaryKeyRelatedField(many=True, read_only=True)
|
||||
agenda_create = BooleanField(write_only=True, required=False, allow_null=True)
|
||||
agenda_type = IntegerField(
|
||||
write_only=True, required=False, min_value=1, max_value=3, allow_null=True
|
||||
|
@ -1,21 +1,21 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
|
||||
from openslides.poll.views import BasePollViewSet, BaseVoteViewSet
|
||||
from openslides.utils.auth import has_perm
|
||||
from openslides.utils.autoupdate import inform_changed_data
|
||||
from openslides.utils.rest_api import (
|
||||
DestroyModelMixin,
|
||||
GenericViewSet,
|
||||
ModelViewSet,
|
||||
Response,
|
||||
UpdateModelMixin,
|
||||
ValidationError,
|
||||
detail_route,
|
||||
)
|
||||
from openslides.utils.utils import is_int
|
||||
|
||||
from ..utils.auth import has_perm
|
||||
from .access_permissions import AssignmentAccessPermissions
|
||||
from .models import Assignment, AssignmentPoll, AssignmentRelatedUser
|
||||
from .serializers import AssignmentAllPollSerializer
|
||||
from .models import Assignment, AssignmentPoll, AssignmentRelatedUser, AssignmentVote
|
||||
|
||||
|
||||
# Viewsets for the REST API
|
||||
@ -48,7 +48,6 @@ class AssignmentViewSet(ModelViewSet):
|
||||
"update",
|
||||
"destroy",
|
||||
"mark_elected",
|
||||
"create_poll",
|
||||
"sort_related_users",
|
||||
):
|
||||
result = has_perm(self.request.user, "assignments.can_see") and has_perm(
|
||||
@ -98,7 +97,7 @@ class AssignmentViewSet(ModelViewSet):
|
||||
# To nominate self during voting you have to be a manager.
|
||||
self.permission_denied(request)
|
||||
# If the request.user is already a candidate he can nominate himself nevertheless.
|
||||
assignment.set_candidate(request.user)
|
||||
assignment.add_candidate(request.user)
|
||||
# Send new candidate via autoupdate because users without permission
|
||||
# to see users may not have it but can get it now.
|
||||
inform_changed_data([request.user])
|
||||
@ -121,7 +120,7 @@ class AssignmentViewSet(ModelViewSet):
|
||||
raise ValidationError(
|
||||
{"detail": "You are not a candidate of this election."}
|
||||
)
|
||||
assignment.delete_related_user(request.user)
|
||||
assignment.remove_candidate(request.user)
|
||||
return "You have withdrawn your candidature successfully."
|
||||
|
||||
def get_user_from_request_data(self, request):
|
||||
@ -186,7 +185,7 @@ class AssignmentViewSet(ModelViewSet):
|
||||
raise ValidationError(
|
||||
{"detail": "User {0} is already nominated.", "args": [str(user)]}
|
||||
)
|
||||
assignment.set_candidate(user)
|
||||
assignment.add_candidate(user)
|
||||
# Send new candidate via autoupdate because users without permission
|
||||
# to see users may not have it but can get it now.
|
||||
inform_changed_data(user)
|
||||
@ -211,7 +210,7 @@ class AssignmentViewSet(ModelViewSet):
|
||||
"args": [str(user)],
|
||||
}
|
||||
)
|
||||
assignment.delete_related_user(user)
|
||||
assignment.remove_candidate(user)
|
||||
return Response(
|
||||
{"detail": "Candidate {0} was withdrawn successfully.", "args": [str(user)]}
|
||||
)
|
||||
@ -243,7 +242,7 @@ class AssignmentViewSet(ModelViewSet):
|
||||
"args": [str(user)],
|
||||
}
|
||||
)
|
||||
assignment.set_candidate(user)
|
||||
assignment.add_candidate(user)
|
||||
message = "User {0} was successfully unelected."
|
||||
return Response({"detail": message, "args": [str(user)]})
|
||||
|
||||
@ -309,7 +308,7 @@ class AssignmentViewSet(ModelViewSet):
|
||||
return Response({"detail": "Assignment related users successfully sorted."})
|
||||
|
||||
|
||||
class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
|
||||
class AssignmentPollViewSet(BasePollViewSet):
|
||||
"""
|
||||
API endpoint for assignment polls.
|
||||
|
||||
@ -317,12 +316,246 @@ class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet)
|
||||
"""
|
||||
|
||||
queryset = AssignmentPoll.objects.all()
|
||||
serializer_class = AssignmentAllPollSerializer
|
||||
|
||||
def check_view_permissions(self):
|
||||
def has_manage_permissions(self):
|
||||
"""
|
||||
Returns True if the user has required permissions.
|
||||
"""
|
||||
return has_perm(self.request.user, "assignments.can_see") and has_perm(
|
||||
self.request.user, "assignments.can_manage"
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
assignment = serializer.validated_data["assignment"]
|
||||
if not assignment.candidates.exists():
|
||||
raise ValidationError(
|
||||
{"detail": "Can not create poll because there are no candidates."}
|
||||
)
|
||||
|
||||
super().perform_create(serializer)
|
||||
|
||||
def handle_analog_vote(self, data, poll, user):
|
||||
"""
|
||||
Request data:
|
||||
{
|
||||
"options": {<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")
|
||||
|
10
openslides/core/migrations/0026_remove_history_restricted.py
Normal file
10
openslides/core/migrations/0026_remove_history_restricted.py
Normal 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")]
|
@ -273,7 +273,6 @@ class HistoryManager(models.Manager):
|
||||
),
|
||||
now=history_time,
|
||||
information=element.get("information", []),
|
||||
restricted=element.get("restricted", False),
|
||||
user_id=element.get("user_id"),
|
||||
full_data=data,
|
||||
)
|
||||
@ -324,8 +323,6 @@ class History(models.Model):
|
||||
|
||||
information = JSONField()
|
||||
|
||||
restricted = models.BooleanField(default=False)
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
|
||||
)
|
||||
|
@ -179,3 +179,64 @@ class StateAccessPermissions(BaseAccessPermissions):
|
||||
"""
|
||||
|
||||
base_permission = "motions.can_see"
|
||||
|
||||
|
||||
class MotionPollAccessPermissions(BaseAccessPermissions):
|
||||
base_permission = "motions.can_see"
|
||||
STATE_PUBLISHED = 4
|
||||
|
||||
async def get_restricted_data(
|
||||
self, full_data: List[Dict[str, Any]], user_id: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Poll-managers have full access, even during an active poll.
|
||||
Non-published polls will be restricted:
|
||||
- Remove votes* values from the poll
|
||||
- Remove yes/no/abstain fields from options
|
||||
- Remove voted_id field from the poll
|
||||
"""
|
||||
|
||||
if await async_has_perm(user_id, "motions.can_manage_polls"):
|
||||
data = full_data
|
||||
else:
|
||||
data = []
|
||||
for poll in full_data:
|
||||
if poll["state"] != self.STATE_PUBLISHED:
|
||||
poll = json.loads(
|
||||
json.dumps(poll)
|
||||
) # copy, so we can remove some fields.
|
||||
del poll["votesvalid"]
|
||||
del poll["votesinvalid"]
|
||||
del poll["votescast"]
|
||||
del poll["voted_id"]
|
||||
for option in poll["options"]:
|
||||
del option["yes"]
|
||||
del option["no"]
|
||||
del option["abstain"]
|
||||
data.append(poll)
|
||||
return data
|
||||
|
||||
|
||||
class MotionVoteAccessPermissions(BaseAccessPermissions):
|
||||
base_permission = "motions.can_see"
|
||||
STATE_PUBLISHED = 4
|
||||
|
||||
async def get_restricted_data(
|
||||
self, full_data: List[Dict[str, Any]], user_id: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Poll-managers have full access, even during an active poll.
|
||||
Every user can see it's own votes.
|
||||
If the pollstate is published, everyone can see the votes.
|
||||
"""
|
||||
|
||||
if await async_has_perm(user_id, "motions.can_manage_polls"):
|
||||
data = full_data
|
||||
else:
|
||||
data = [
|
||||
vote
|
||||
for vote in full_data
|
||||
if vote["pollstate"] == self.STATE_PUBLISHED
|
||||
or vote["user_id"] == user_id
|
||||
]
|
||||
return data
|
||||
|
@ -20,6 +20,7 @@ class MotionsAppConfig(AppConfig):
|
||||
StatuteParagraphViewSet,
|
||||
MotionViewSet,
|
||||
MotionCommentSectionViewSet,
|
||||
MotionVoteViewSet,
|
||||
MotionBlockViewSet,
|
||||
MotionPollViewSet,
|
||||
MotionChangeRecommendationViewSet,
|
||||
@ -66,6 +67,9 @@ class MotionsAppConfig(AppConfig):
|
||||
router.register(
|
||||
self.get_model("MotionPoll").get_collection_string(), MotionPollViewSet
|
||||
)
|
||||
router.register(
|
||||
self.get_model("MotionVote").get_collection_string(), MotionVoteViewSet
|
||||
)
|
||||
router.register(self.get_model("State").get_collection_string(), StateViewSet)
|
||||
|
||||
# Register required_users
|
||||
@ -92,6 +96,8 @@ class MotionsAppConfig(AppConfig):
|
||||
"State",
|
||||
"MotionChangeRecommendation",
|
||||
"MotionCommentSection",
|
||||
"MotionPoll",
|
||||
"MotionVote",
|
||||
):
|
||||
yield self.get_model(model_name)
|
||||
|
||||
|
146
openslides/motions/migrations/0033_auto_20191017_1100.py
Normal file
146
openslides/motions/migrations/0033_auto_20191017_1100.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
@ -8,12 +8,7 @@ from openslides.agenda.mixins import AgendaItemWithListOfSpeakersMixin
|
||||
from openslides.core.config import config
|
||||
from openslides.core.models import Tag
|
||||
from openslides.mediafiles.models import Mediafile
|
||||
from openslides.poll.models import (
|
||||
BaseOption,
|
||||
BasePoll,
|
||||
BaseVote,
|
||||
CollectDefaultVotesMixin,
|
||||
)
|
||||
from openslides.poll.models import BaseOption, BasePoll, BaseVote
|
||||
from openslides.utils.autoupdate import inform_changed_data
|
||||
from openslides.utils.exceptions import OpenSlidesError
|
||||
from openslides.utils.models import RESTModelMixin
|
||||
@ -26,6 +21,8 @@ from .access_permissions import (
|
||||
MotionBlockAccessPermissions,
|
||||
MotionChangeRecommendationAccessPermissions,
|
||||
MotionCommentSectionAccessPermissions,
|
||||
MotionPollAccessPermissions,
|
||||
MotionVoteAccessPermissions,
|
||||
StateAccessPermissions,
|
||||
StatuteParagraphAccessPermissions,
|
||||
WorkflowAccessPermissions,
|
||||
@ -80,6 +77,11 @@ class MotionManager(models.Manager):
|
||||
"agenda_items",
|
||||
"lists_of_speakers",
|
||||
"polls",
|
||||
"polls__groups",
|
||||
"polls__voted",
|
||||
"polls__options",
|
||||
"polls__options__votes",
|
||||
"polls__options__votes__user",
|
||||
"attachments",
|
||||
"tags",
|
||||
"submitters",
|
||||
@ -269,6 +271,7 @@ class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model):
|
||||
("can_support", "Can support motions"),
|
||||
("can_manage_metadata", "Can manage motion metadata"),
|
||||
("can_manage", "Can manage motions"),
|
||||
("can_manage_polls", "Can manage motion polls"),
|
||||
)
|
||||
ordering = ("identifier",)
|
||||
verbose_name = "Motion"
|
||||
@ -424,22 +427,6 @@ class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model):
|
||||
"""
|
||||
return user in self.supporters.all()
|
||||
|
||||
def create_poll(self, skip_autoupdate=False):
|
||||
"""
|
||||
Create a new poll for this motion.
|
||||
|
||||
Return the new poll object.
|
||||
"""
|
||||
if self.state.allow_create_poll:
|
||||
poll = MotionPoll(motion=self)
|
||||
poll.save(skip_autoupdate=skip_autoupdate)
|
||||
poll.set_options(skip_autoupdate=skip_autoupdate)
|
||||
return poll
|
||||
else:
|
||||
raise WorkflowError(
|
||||
f"You can not create a poll in state {self.state.name}."
|
||||
)
|
||||
|
||||
@property
|
||||
def workflow_id(self):
|
||||
"""
|
||||
@ -881,82 +868,48 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode
|
||||
|
||||
|
||||
class MotionVote(RESTModelMixin, BaseVote):
|
||||
"""Saves the votes for a MotionPoll.
|
||||
|
||||
There should allways be three MotionVote objects for each poll,
|
||||
one for 'yes', 'no', and 'abstain'."""
|
||||
|
||||
option = models.ForeignKey("MotionOption", on_delete=models.CASCADE)
|
||||
"""The option object, to witch the vote belongs."""
|
||||
access_permissions = MotionVoteAccessPermissions()
|
||||
option = models.ForeignKey(
|
||||
"MotionOption", on_delete=models.CASCADE, related_name="votes"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
default_permissions = ()
|
||||
|
||||
def get_root_rest_element(self):
|
||||
"""
|
||||
Returns the motion to this instance which is the root REST element.
|
||||
"""
|
||||
return self.option.poll.motion
|
||||
|
||||
|
||||
class MotionOption(RESTModelMixin, BaseOption):
|
||||
"""Links between the MotionPollClass and the MotionVoteClass.
|
||||
|
||||
There should be one MotionOption object for each poll."""
|
||||
|
||||
poll = models.ForeignKey("MotionPoll", on_delete=models.CASCADE)
|
||||
"""The poll object, to witch the object belongs."""
|
||||
|
||||
vote_class = MotionVote
|
||||
"""The VoteClass, to witch this Class links."""
|
||||
|
||||
poll = models.ForeignKey(
|
||||
"MotionPoll", related_name="options", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
default_permissions = ()
|
||||
|
||||
def get_root_rest_element(self):
|
||||
"""
|
||||
Returns the motion to this instance which is the root REST element.
|
||||
"""
|
||||
return self.poll.motion
|
||||
return self.poll
|
||||
|
||||
|
||||
# Meta-TODO: Is this todo resolved?
|
||||
# TODO: remove the type-ignoring in the next line, after this is solved:
|
||||
# https://github.com/python/mypy/issues/3855
|
||||
class MotionPoll(RESTModelMixin, CollectDefaultVotesMixin, BasePoll): # type: ignore
|
||||
"""The Class to saves the vote result for a motion poll."""
|
||||
class MotionPoll(RESTModelMixin, BasePoll):
|
||||
access_permissions = MotionPollAccessPermissions()
|
||||
option_class = MotionOption
|
||||
|
||||
motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls")
|
||||
"""The motion to witch the object belongs."""
|
||||
|
||||
option_class = MotionOption
|
||||
"""The option class, witch links between this object the the votes."""
|
||||
|
||||
vote_values = ["Yes", "No", "Abstain"]
|
||||
"""The possible anwers for the poll. 'Yes, 'No' and 'Abstain'."""
|
||||
POLLMETHOD_YN = "YN"
|
||||
POLLMETHOD_YNA = "YNA"
|
||||
POLLMETHODS = (("YN", "YN"), ("YNA", "YNA"))
|
||||
pollmethod = models.CharField(max_length=3, choices=POLLMETHODS)
|
||||
|
||||
class Meta:
|
||||
default_permissions = ()
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
Representation method only for debugging purposes.
|
||||
"""
|
||||
return f"MotionPoll for motion {self.motion}"
|
||||
|
||||
def set_options(self, skip_autoupdate=False):
|
||||
"""Create the option class for this poll."""
|
||||
# TODO: maybe it is possible with .create() to call this without poll=self
|
||||
# or call this in save()
|
||||
self.get_option_class()(poll=self).save(skip_autoupdate=skip_autoupdate)
|
||||
|
||||
def get_percent_base_choice(self):
|
||||
return config["motions_poll_100_percent_base"]
|
||||
|
||||
def get_root_rest_element(self):
|
||||
"""
|
||||
Returns the motion to this instance which is the root REST element.
|
||||
"""
|
||||
return self.motion
|
||||
def create_options(self):
|
||||
MotionOption.objects.create(poll=self)
|
||||
|
||||
|
||||
class State(RESTModelMixin, models.Model):
|
||||
|
@ -1,17 +1,19 @@
|
||||
from typing import Dict, Optional
|
||||
|
||||
import jsonschema
|
||||
from django.db import transaction
|
||||
|
||||
from openslides.poll.serializers import (
|
||||
BASE_OPTION_FIELDS,
|
||||
BASE_POLL_FIELDS,
|
||||
BASE_VOTE_FIELDS,
|
||||
)
|
||||
|
||||
from ..core.config import config
|
||||
from ..poll.serializers import default_votes_validator
|
||||
from ..utils.auth import get_group_model, has_perm
|
||||
from ..utils.autoupdate import inform_changed_data
|
||||
from ..utils.rest_api import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
DecimalField,
|
||||
DictField,
|
||||
Field,
|
||||
IdPrimaryKeyRelatedField,
|
||||
IntegerField,
|
||||
@ -28,7 +30,9 @@ from .models import (
|
||||
MotionChangeRecommendation,
|
||||
MotionComment,
|
||||
MotionCommentSection,
|
||||
MotionOption,
|
||||
MotionPoll,
|
||||
MotionVote,
|
||||
State,
|
||||
StatuteParagraph,
|
||||
Submitter,
|
||||
@ -220,116 +224,65 @@ class AmendmentParagraphsJSONSerializerField(Field):
|
||||
return data
|
||||
|
||||
|
||||
class MotionVoteSerializer(ModelSerializer):
|
||||
pollstate = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MotionVote
|
||||
fields = ("pollstate",) + BASE_VOTE_FIELDS
|
||||
read_only_fields = BASE_VOTE_FIELDS
|
||||
|
||||
def get_pollstate(self, vote):
|
||||
return vote.option.poll.state
|
||||
|
||||
|
||||
class MotionOptionSerializer(ModelSerializer):
|
||||
yes = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True)
|
||||
no = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True)
|
||||
abstain = DecimalField(
|
||||
max_digits=15, decimal_places=6, min_value=-2, read_only=True
|
||||
)
|
||||
|
||||
votes = IdPrimaryKeyRelatedField(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = MotionOption
|
||||
fields = BASE_OPTION_FIELDS
|
||||
read_only_fields = BASE_OPTION_FIELDS
|
||||
|
||||
|
||||
class MotionPollSerializer(ModelSerializer):
|
||||
"""
|
||||
Serializer for motion.models.MotionPoll objects.
|
||||
"""
|
||||
|
||||
yes = SerializerMethodField()
|
||||
no = SerializerMethodField()
|
||||
abstain = SerializerMethodField()
|
||||
votes = DictField(
|
||||
child=DecimalField(
|
||||
max_digits=15, decimal_places=6, min_value=-2, allow_null=True
|
||||
),
|
||||
write_only=True,
|
||||
options = MotionOptionSerializer(many=True, read_only=True)
|
||||
|
||||
title = CharField(allow_blank=False, required=True)
|
||||
groups = IdPrimaryKeyRelatedField(
|
||||
many=True, required=False, queryset=get_group_model().objects.all()
|
||||
)
|
||||
voted = IdPrimaryKeyRelatedField(many=True, read_only=True)
|
||||
|
||||
votesvalid = DecimalField(
|
||||
max_digits=15, decimal_places=6, min_value=-2, read_only=True
|
||||
)
|
||||
votesinvalid = DecimalField(
|
||||
max_digits=15, decimal_places=6, min_value=-2, read_only=True
|
||||
)
|
||||
votescast = DecimalField(
|
||||
max_digits=15, decimal_places=6, min_value=-2, read_only=True
|
||||
)
|
||||
has_votes = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MotionPoll
|
||||
fields = (
|
||||
"id",
|
||||
"motion",
|
||||
"yes",
|
||||
"no",
|
||||
"abstain",
|
||||
"votesvalid",
|
||||
"votesinvalid",
|
||||
"votescast",
|
||||
"votes",
|
||||
"has_votes",
|
||||
)
|
||||
validators = (default_votes_validator,)
|
||||
fields = ("motion", "pollmethod") + BASE_POLL_FIELDS
|
||||
read_only_fields = ("state",)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# The following dictionary is just a cache for several votes.
|
||||
self._votes_dicts: Dict[int, Dict[int, int]] = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_yes(self, obj):
|
||||
try:
|
||||
result: Optional[str] = str(self.get_votes_dict(obj)["Yes"])
|
||||
except KeyError:
|
||||
result = None
|
||||
return result
|
||||
|
||||
def get_no(self, obj):
|
||||
try:
|
||||
result: Optional[str] = str(self.get_votes_dict(obj)["No"])
|
||||
except KeyError:
|
||||
result = None
|
||||
return result
|
||||
|
||||
def get_abstain(self, obj):
|
||||
try:
|
||||
result: Optional[str] = str(self.get_votes_dict(obj)["Abstain"])
|
||||
except KeyError:
|
||||
result = None
|
||||
return result
|
||||
|
||||
def get_votes_dict(self, obj):
|
||||
try:
|
||||
votes_dict = self._votes_dicts[obj.pk]
|
||||
except KeyError:
|
||||
votes_dict = self._votes_dicts[obj.pk] = {}
|
||||
for vote in obj.get_votes():
|
||||
votes_dict[vote.value] = vote.weight
|
||||
return votes_dict
|
||||
|
||||
def get_has_votes(self, obj):
|
||||
"""
|
||||
Returns True if this poll has some votes.
|
||||
"""
|
||||
return obj.has_votes()
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
Customized update method for polls. To update votes use the write
|
||||
only field 'votes'.
|
||||
|
||||
Example data:
|
||||
|
||||
"votes": {"Yes": 10, "No": 4, "Abstain": -2}
|
||||
"""
|
||||
# Update votes.
|
||||
votes = validated_data.get("votes")
|
||||
if votes:
|
||||
if len(votes) != len(instance.get_vote_values()):
|
||||
raise ValidationError(
|
||||
{
|
||||
"detail": "You have to submit data for {0} vote values.",
|
||||
"args": [len(instance.get_vote_values())],
|
||||
}
|
||||
)
|
||||
for vote_value in votes.keys():
|
||||
if vote_value not in instance.get_vote_values():
|
||||
raise ValidationError(
|
||||
{"detail": "Vote value {0} is invalid.", "args": [vote_value]}
|
||||
)
|
||||
instance.set_vote_objects_with_values(
|
||||
instance.get_options().get(), votes, skip_autoupdate=True
|
||||
)
|
||||
|
||||
# Update remaining writeable fields.
|
||||
instance.votesvalid = validated_data.get("votesvalid", instance.votesvalid)
|
||||
instance.votesinvalid = validated_data.get(
|
||||
"votesinvalid", instance.votesinvalid
|
||||
)
|
||||
instance.votescast = validated_data.get("votescast", instance.votescast)
|
||||
instance.save()
|
||||
return instance
|
||||
""" Prevent from updating the motion """
|
||||
validated_data.pop("motion", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class MotionChangeRecommendationSerializer(ModelSerializer):
|
||||
@ -418,7 +371,7 @@ class MotionSerializer(ModelSerializer):
|
||||
"""
|
||||
|
||||
comments = MotionCommentSerializer(many=True, read_only=True)
|
||||
polls = MotionPollSerializer(many=True, read_only=True)
|
||||
polls = IdPrimaryKeyRelatedField(many=True, read_only=True)
|
||||
modified_final_version = CharField(allow_blank=True, required=False)
|
||||
reason = CharField(allow_blank=True, required=False)
|
||||
state_restriction = SerializerMethodField()
|
||||
|
@ -1,3 +1,4 @@
|
||||
from decimal import Decimal
|
||||
from typing import List, Set
|
||||
|
||||
import jsonschema
|
||||
@ -8,17 +9,16 @@ from django.db.models.deletion import ProtectedError
|
||||
from django.http.request import QueryDict
|
||||
from rest_framework import status
|
||||
|
||||
from openslides.poll.views import BasePollViewSet, BaseVoteViewSet
|
||||
|
||||
from ..core.config import config
|
||||
from ..core.models import Tag
|
||||
from ..utils.auth import has_perm, in_some_groups
|
||||
from ..utils.autoupdate import inform_changed_data, inform_deleted_data
|
||||
from ..utils.rest_api import (
|
||||
DestroyModelMixin,
|
||||
GenericViewSet,
|
||||
ModelViewSet,
|
||||
Response,
|
||||
ReturnDict,
|
||||
UpdateModelMixin,
|
||||
ValidationError,
|
||||
detail_route,
|
||||
list_route,
|
||||
@ -34,7 +34,6 @@ from .access_permissions import (
|
||||
StatuteParagraphAccessPermissions,
|
||||
WorkflowAccessPermissions,
|
||||
)
|
||||
from .exceptions import WorkflowError
|
||||
from .models import (
|
||||
Category,
|
||||
Motion,
|
||||
@ -43,13 +42,13 @@ from .models import (
|
||||
MotionComment,
|
||||
MotionCommentSection,
|
||||
MotionPoll,
|
||||
MotionVote,
|
||||
State,
|
||||
StatuteParagraph,
|
||||
Submitter,
|
||||
Workflow,
|
||||
)
|
||||
from .numbering import numbering
|
||||
from .serializers import MotionPollSerializer
|
||||
|
||||
|
||||
# Viewsets for the REST API
|
||||
@ -87,7 +86,6 @@ class MotionViewSet(TreeSortMixin, ModelViewSet):
|
||||
"follow_recommendation",
|
||||
"manage_multiple_submitters",
|
||||
"manage_multiple_tags",
|
||||
"create_poll",
|
||||
):
|
||||
result = has_perm(self.request.user, "motions.can_see") and has_perm(
|
||||
self.request.user, "motions.can_manage_metadata"
|
||||
@ -400,9 +398,7 @@ class MotionViewSet(TreeSortMixin, ModelViewSet):
|
||||
message = ["Comment {arg1} deleted", section.name]
|
||||
|
||||
# Fire autoupdate again to save information to OpenSlides history.
|
||||
inform_changed_data(
|
||||
motion, information=message, user_id=request.user.pk, restricted=True
|
||||
)
|
||||
inform_changed_data(motion, information=message, user_id=request.user.pk)
|
||||
|
||||
return Response({"detail": message})
|
||||
|
||||
@ -1042,31 +1038,6 @@ class MotionViewSet(TreeSortMixin, ModelViewSet):
|
||||
|
||||
return Response({"detail": "Recommendation followed successfully."})
|
||||
|
||||
@detail_route(methods=["post"])
|
||||
def create_poll(self, request, pk=None):
|
||||
"""
|
||||
View to create a poll. It is a POST request without any data.
|
||||
"""
|
||||
motion = self.get_object()
|
||||
if not motion.state.allow_create_poll:
|
||||
raise ValidationError(
|
||||
{"detail": "You can not create a poll in this motion state."}
|
||||
)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
poll = motion.create_poll(skip_autoupdate=True)
|
||||
except WorkflowError as err:
|
||||
raise ValidationError({"detail": err})
|
||||
|
||||
# Fire autoupdate again to save information to OpenSlides history.
|
||||
inform_changed_data(
|
||||
motion, information=["Vote created"], user_id=request.user.pk
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"detail": "Vote created successfully.", "createdPollId": poll.pk}
|
||||
)
|
||||
|
||||
@list_route(methods=["post"])
|
||||
@transaction.atomic
|
||||
def manage_multiple_tags(self, request):
|
||||
@ -1137,7 +1108,7 @@ class MotionViewSet(TreeSortMixin, ModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
|
||||
class MotionPollViewSet(BasePollViewSet):
|
||||
"""
|
||||
API endpoint for motion polls.
|
||||
|
||||
@ -1145,9 +1116,8 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
|
||||
"""
|
||||
|
||||
queryset = MotionPoll.objects.all()
|
||||
serializer_class = MotionPollSerializer
|
||||
|
||||
def check_view_permissions(self):
|
||||
def has_manage_permissions(self):
|
||||
"""
|
||||
Returns True if the user has required permissions.
|
||||
"""
|
||||
@ -1155,16 +1125,30 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
|
||||
self.request.user, "motions.can_manage_metadata"
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
motion = serializer.validated_data["motion"]
|
||||
if not motion.state.allow_create_poll:
|
||||
raise ValidationError(
|
||||
{"detail": "You can not create a poll in this motion state."}
|
||||
)
|
||||
|
||||
super().perform_create(serializer)
|
||||
|
||||
# Fire autoupdate again to save information to OpenSlides history.
|
||||
inform_changed_data(
|
||||
motion, information=["Poll created"], user_id=self.request.user.pk
|
||||
)
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
"""
|
||||
Customized view endpoint to update a motion poll.
|
||||
"""
|
||||
response = super().update(*args, **kwargs)
|
||||
poll = self.get_object()
|
||||
|
||||
# Fire autoupdate again to save information to OpenSlides history.
|
||||
poll = self.get_object()
|
||||
inform_changed_data(
|
||||
poll.motion, information=["Vote updated"], user_id=self.request.user.pk
|
||||
poll.motion, information=["Poll updated"], user_id=self.request.user.pk
|
||||
)
|
||||
|
||||
return response
|
||||
@ -1178,11 +1162,75 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
|
||||
|
||||
# Fire autoupdate again to save information to OpenSlides history.
|
||||
inform_changed_data(
|
||||
poll.motion, information=["Vote deleted"], user_id=self.request.user.pk
|
||||
poll.motion, information=["Poll deleted"], user_id=self.request.user.pk
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def handle_analog_vote(self, data, poll, user):
|
||||
"""
|
||||
Request data:
|
||||
{ "Y": <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):
|
||||
"""
|
||||
@ -1620,7 +1668,6 @@ class StateViewSet(ModelViewSet, ProtectedErrorMessageMixin):
|
||||
"""
|
||||
|
||||
queryset = State.objects.all()
|
||||
# serializer_class = StateSerializer
|
||||
access_permissions = StateAccessPermissions()
|
||||
|
||||
def check_view_permissions(self):
|
||||
|
@ -1,20 +1,44 @@
|
||||
import locale
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Type
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
from ..utils.autoupdate import inform_deleted_data
|
||||
from ..utils.models import SET_NULL_AND_AUTOUPDATE
|
||||
|
||||
|
||||
class BaseVote(models.Model):
|
||||
"""
|
||||
All subclasses must have option attribute with the related name "votes"
|
||||
"""
|
||||
|
||||
weight = models.DecimalField(
|
||||
default=Decimal("1"),
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
value = models.CharField(max_length=1, choices=(("Y", "Y"), ("N", "N"), ("A", "A")))
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
default=None,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=SET_NULL_AND_AUTOUPDATE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_root_rest_element(self):
|
||||
return self.option.get_root_rest_element()
|
||||
|
||||
|
||||
class BaseOption(models.Model):
|
||||
"""
|
||||
Base option class for a poll.
|
||||
|
||||
Subclasses have to define a poll field. This must be a ForeignKeyField
|
||||
to a subclass of BasePoll. There must also be a vote_class attribute
|
||||
which has to be a subclass of BaseVote. Otherwise you have to override the
|
||||
get_vote_class method.
|
||||
All subclasses must have poll attribute with the related name "options"
|
||||
"""
|
||||
|
||||
vote_class: Optional[Type["BaseVote"]] = None
|
||||
@ -22,146 +46,135 @@ class BaseOption(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_votes(self):
|
||||
return self.get_vote_class().objects.filter(option=self)
|
||||
@property
|
||||
def yes(self) -> Decimal:
|
||||
return self.sum_weight("Y")
|
||||
|
||||
def get_vote_class(self):
|
||||
if self.vote_class is None:
|
||||
@property
|
||||
def no(self) -> Decimal:
|
||||
return self.sum_weight("N")
|
||||
|
||||
@property
|
||||
def abstain(self) -> Decimal:
|
||||
return self.sum_weight("A")
|
||||
|
||||
def sum_weight(self, value):
|
||||
# We could do this in a nice .aggregate(Sum...) querystatement,
|
||||
# but these might be expensive DB queries, because they are not preloaded.
|
||||
# With this in-logic-counting, we operate inmemory.
|
||||
weight_sum = Decimal(0)
|
||||
for vote in self.votes.all():
|
||||
if vote.value == value:
|
||||
weight_sum += vote.weight
|
||||
return weight_sum
|
||||
|
||||
@classmethod
|
||||
def get_vote_class(cls):
|
||||
if cls.vote_class is None:
|
||||
raise NotImplementedError(
|
||||
f"The option class {self} has to have an attribute vote_class."
|
||||
f"The option class {cls} has to have an attribute vote_class."
|
||||
)
|
||||
return self.vote_class
|
||||
return cls.vote_class
|
||||
|
||||
def __getitem__(self, name):
|
||||
try:
|
||||
return self.get_votes().get(value=name)
|
||||
except self.get_vote_class().DoesNotExist:
|
||||
raise KeyError
|
||||
|
||||
|
||||
class BaseVote(models.Model):
|
||||
"""
|
||||
Base vote class for an option.
|
||||
|
||||
Subclasses have to define an option field. This must be a ForeignKeyField
|
||||
to a subclass of BasePoll.
|
||||
"""
|
||||
|
||||
weight = models.DecimalField(
|
||||
default=Decimal("1"),
|
||||
null=True,
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
value = models.CharField(max_length=255, null=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return self.print_weight()
|
||||
|
||||
def get_value(self):
|
||||
return self.value
|
||||
|
||||
def print_weight(self, raw=False):
|
||||
if raw:
|
||||
return self.weight
|
||||
try:
|
||||
percent_base = self.option.poll.get_percent_base()
|
||||
except AttributeError:
|
||||
# The poll class is no child of CollectVotesCast
|
||||
percent_base = 0
|
||||
return print_value(self.weight, percent_base)
|
||||
|
||||
|
||||
class CollectDefaultVotesMixin(models.Model):
|
||||
"""
|
||||
Mixin for a poll to collect the default vote values for valid votes,
|
||||
invalid votes and votes cast.
|
||||
"""
|
||||
|
||||
votesvalid = models.DecimalField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
votesinvalid = models.DecimalField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
votescast = models.DecimalField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_percent_base_choice(self):
|
||||
"""
|
||||
Returns one of the strings of the percent base.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"You have to provide a get_percent_base_choice() method."
|
||||
)
|
||||
|
||||
|
||||
class PublishPollMixin(models.Model):
|
||||
"""
|
||||
Mixin for a poll to add a flag whether the poll is published or not.
|
||||
"""
|
||||
|
||||
published = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def set_published(self, published):
|
||||
self.published = published
|
||||
self.save()
|
||||
def get_root_rest_element(self):
|
||||
return self.poll.get_root_rest_element()
|
||||
|
||||
|
||||
class BasePoll(models.Model):
|
||||
"""
|
||||
Base poll class.
|
||||
"""
|
||||
option_class: Optional[Type["BaseOption"]] = None
|
||||
|
||||
vote_values = ["Votes"]
|
||||
STATE_CREATED = 1
|
||||
STATE_STARTED = 2
|
||||
STATE_FINISHED = 3
|
||||
STATE_PUBLISHED = 4
|
||||
STATES = (
|
||||
(STATE_CREATED, "Created"),
|
||||
(STATE_STARTED, "Started"),
|
||||
(STATE_FINISHED, "Finished"),
|
||||
(STATE_PUBLISHED, "Published"),
|
||||
)
|
||||
state = models.IntegerField(choices=STATES, default=STATE_CREATED)
|
||||
|
||||
TYPE_ANALOG = "analog"
|
||||
TYPE_NAMED = "named"
|
||||
TYPE_PSEUDOANONYMOUS = "pseudoanonymous"
|
||||
TYPES = (
|
||||
(TYPE_ANALOG, "Analog"),
|
||||
(TYPE_NAMED, "Named"),
|
||||
(TYPE_PSEUDOANONYMOUS, "Pseudoanonymous"),
|
||||
)
|
||||
type = models.CharField(max_length=64, blank=False, null=False, choices=TYPES)
|
||||
|
||||
title = models.CharField(max_length=255, blank=True, null=False)
|
||||
groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True)
|
||||
voted = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
|
||||
|
||||
db_votesvalid = models.DecimalField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
db_votesinvalid = models.DecimalField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
db_votescast = models.DecimalField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("-2"))],
|
||||
max_digits=15,
|
||||
decimal_places=6,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def has_votes(self):
|
||||
"""
|
||||
Returns True if there are votes in the poll.
|
||||
"""
|
||||
if self.get_votes().exists():
|
||||
return True
|
||||
return False
|
||||
def get_votesvalid(self):
|
||||
if self.type == self.TYPE_ANALOG:
|
||||
return self.db_votesvalid
|
||||
else:
|
||||
return Decimal(self.count_users_voted())
|
||||
|
||||
def set_options(self, options_data=None, skip_autoupdate=False):
|
||||
"""
|
||||
Adds new option objects to the poll.
|
||||
def set_votesvalid(self, value):
|
||||
if self.type != self.TYPE_ANALOG:
|
||||
raise ValueError("Do not set votesvalid for non analog polls")
|
||||
self.db_votesvalid = value
|
||||
|
||||
option_data: A list of arguments for the option.
|
||||
"""
|
||||
if options_data is None:
|
||||
options_data = []
|
||||
votesvalid = property(get_votesvalid, set_votesvalid)
|
||||
|
||||
for option_data in options_data:
|
||||
option = self.get_option_class()(**option_data)
|
||||
option.poll = self
|
||||
option.save(skip_autoupdate=skip_autoupdate)
|
||||
def get_votesinvalid(self):
|
||||
if self.type == self.TYPE_ANALOG:
|
||||
return self.db_votesinvalid
|
||||
else:
|
||||
return Decimal(0)
|
||||
|
||||
def set_votesinvalid(self, value):
|
||||
if self.type != self.TYPE_ANALOG:
|
||||
raise ValueError("Do not set votesinvalid for non analog polls")
|
||||
self.db_votesinvalid = value
|
||||
|
||||
votesinvalid = property(get_votesinvalid, set_votesinvalid)
|
||||
|
||||
def get_votescast(self):
|
||||
if self.type == self.TYPE_ANALOG:
|
||||
return self.db_votescast
|
||||
else:
|
||||
return Decimal(self.count_users_voted())
|
||||
|
||||
def set_votescast(self, value):
|
||||
if self.type != self.TYPE_ANALOG:
|
||||
raise ValueError("Do not set votescast for non analog polls")
|
||||
self.db_votescast = value
|
||||
|
||||
votescast = property(get_votescast, set_votescast)
|
||||
|
||||
def count_users_voted(self):
|
||||
return self.voted.all().count()
|
||||
|
||||
def get_options(self):
|
||||
"""
|
||||
@ -169,75 +182,49 @@ class BasePoll(models.Model):
|
||||
"""
|
||||
return self.get_option_class().objects.filter(poll=self)
|
||||
|
||||
def get_option_class(self):
|
||||
"""
|
||||
Returns the option class for the poll. Default is self.option_class.
|
||||
"""
|
||||
return self.option_class
|
||||
def create_options(self):
|
||||
""" Should be called after creation of this model. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_vote_values(self):
|
||||
"""
|
||||
Returns the possible values for the poll. Default is as list.
|
||||
"""
|
||||
return self.vote_values
|
||||
@classmethod
|
||||
def get_option_class(cls):
|
||||
if cls.option_class is None:
|
||||
raise NotImplementedError(
|
||||
f"The poll class {cls} has to have an attribute option_class."
|
||||
)
|
||||
return cls.option_class
|
||||
|
||||
def get_vote_class(self):
|
||||
"""
|
||||
Returns the related vote class.
|
||||
"""
|
||||
return self.get_option_class().vote_class
|
||||
@classmethod
|
||||
def get_vote_class(cls):
|
||||
return cls.get_option_class().get_vote_class()
|
||||
|
||||
def get_votes(self):
|
||||
"""
|
||||
Return a QuerySet with all vote objects related to this poll.
|
||||
|
||||
TODO: This might be a performance issue when used in properties that are serialized.
|
||||
"""
|
||||
return self.get_vote_class().objects.filter(option__poll__id=self.id)
|
||||
|
||||
def set_vote_objects_with_values(self, option, data, skip_autoupdate=False):
|
||||
"""
|
||||
Creates or updates the vote objects for the poll.
|
||||
"""
|
||||
for value in self.get_vote_values():
|
||||
try:
|
||||
vote = self.get_votes().filter(option=option).get(value=value)
|
||||
except ObjectDoesNotExist:
|
||||
vote = self.get_vote_class()(option=option, value=value)
|
||||
vote.weight = data[value]
|
||||
vote.save(skip_autoupdate=skip_autoupdate)
|
||||
def pseudoanonymize(self):
|
||||
for vote in self.get_votes():
|
||||
vote.user = None
|
||||
vote.save()
|
||||
|
||||
def get_vote_objects_with_values(self, option_id):
|
||||
"""
|
||||
Returns the vote values and their weight as a list with two elements.
|
||||
"""
|
||||
values = []
|
||||
for value in self.get_vote_values():
|
||||
try:
|
||||
vote = self.get_votes().filter(option=option_id).get(value=value)
|
||||
except ObjectDoesNotExist:
|
||||
values.append(self.get_vote_class()(value=value, weight=""))
|
||||
else:
|
||||
values.append(vote)
|
||||
return values
|
||||
def reset(self):
|
||||
self.voted.clear()
|
||||
|
||||
# Delete votes
|
||||
votes = self.get_votes()
|
||||
votes_id = [vote.id for vote in votes]
|
||||
votes.delete()
|
||||
collection = self.get_vote_class().get_collection_string()
|
||||
inform_deleted_data((collection, id) for id in votes_id)
|
||||
|
||||
def print_value(value, percent_base=0):
|
||||
"""
|
||||
Returns a human readable string for the vote value. It is 'majority',
|
||||
'undocumented' or the vote value with percent value if so.
|
||||
"""
|
||||
if value == -1:
|
||||
verbose_value = "majority"
|
||||
elif value == -2:
|
||||
verbose_value = "undocumented"
|
||||
elif value is None:
|
||||
verbose_value = "undocumented"
|
||||
else:
|
||||
if percent_base:
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
verbose_value = "%d (%s %%)" % (
|
||||
value,
|
||||
locale.format("%.1f", value * percent_base),
|
||||
)
|
||||
else:
|
||||
verbose_value = value
|
||||
return verbose_value
|
||||
# Reset state
|
||||
self.state = BasePoll.STATE_CREATED
|
||||
if self.type == self.TYPE_ANALOG:
|
||||
self.votesvalid = None
|
||||
self.votesinvalid = None
|
||||
self.votescast = None
|
||||
self.save()
|
||||
|
@ -1,19 +1,16 @@
|
||||
from ..utils.rest_api import ValidationError
|
||||
BASE_POLL_FIELDS = (
|
||||
"state",
|
||||
"type",
|
||||
"title",
|
||||
"groups",
|
||||
"votesvalid",
|
||||
"votesinvalid",
|
||||
"votescast",
|
||||
"options",
|
||||
"voted",
|
||||
"id",
|
||||
)
|
||||
|
||||
BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain", "votes")
|
||||
|
||||
def default_votes_validator(data):
|
||||
"""
|
||||
Use this validator in your poll serializer. It checks that the values
|
||||
for the default votes (see models.CollectDefaultVotesMixin) are greater
|
||||
than or equal to -2.
|
||||
"""
|
||||
for key in data:
|
||||
if (
|
||||
key in ("votesvalid", "votesinvalid", "votescast")
|
||||
and data[key] is not None
|
||||
and data[key] < -2
|
||||
):
|
||||
raise ValidationError(
|
||||
{"detail": "Value for {0} must not be less than -2", "args": [key]}
|
||||
)
|
||||
return data
|
||||
BASE_VOTE_FIELDS = ("id", "weight", "value", "user")
|
||||
|
165
openslides/poll/views.py
Normal file
165
openslides/poll/views.py
Normal 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
|
@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Model
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
from .cache import element_cache
|
||||
|
||||
@ -111,28 +112,40 @@ async def async_has_perm(user_id: int, perm: str) -> bool:
|
||||
return has_perm
|
||||
|
||||
|
||||
def in_some_groups(user_id: int, groups: List[int]) -> bool:
|
||||
def in_some_groups(
|
||||
user_id: int, groups: Union[List[int], QuerySet], exact: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Checks that user is in at least one given group. Groups can be given as a list
|
||||
of ids or group instances. If the user is in the admin group (pk = 2) the result
|
||||
is always true, even if no groups are given.
|
||||
of ids or a QuerySet.
|
||||
|
||||
If exact is false (default) and the user is in the admin group (pk = 2),
|
||||
the result is always true, even if no groups are given.
|
||||
|
||||
If exact is true, the user must be in one of the groups, ignoring the possible
|
||||
superadmin-status of the user.
|
||||
|
||||
user_id 0 means anonymous user.
|
||||
"""
|
||||
# Convert user to right type
|
||||
# TODO: Remove this and make use, that user has always the right type
|
||||
user_id = user_to_user_id(user_id)
|
||||
return async_to_sync(async_in_some_groups)(user_id, groups)
|
||||
return async_to_sync(async_in_some_groups)(user_id, groups, exact)
|
||||
|
||||
|
||||
async def async_in_some_groups(user_id: int, groups: List[int]) -> bool:
|
||||
async def async_in_some_groups(
|
||||
user_id: int, groups: Union[List[int], QuerySet], exact: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Checks that user is in at least one given group. Groups can be given as a list
|
||||
of ids. If the user is in the admin group (pk = 2) the result
|
||||
of ids or a QuerySet. If the user is in the admin group (pk = 2) the result
|
||||
is always true, even if no groups are given.
|
||||
|
||||
user_id 0 means anonymous user.
|
||||
"""
|
||||
if isinstance(groups, QuerySet):
|
||||
groups = [group.pk for group in groups]
|
||||
|
||||
if not user_id and not await async_anonymous_is_enabled():
|
||||
in_some_groups = False
|
||||
elif not user_id:
|
||||
@ -144,7 +157,7 @@ async def async_in_some_groups(user_id: int, groups: List[int]) -> bool:
|
||||
)
|
||||
if user_data is None:
|
||||
raise UserDoesNotExist()
|
||||
if GROUP_ADMIN_PK in user_data["groups_id"]:
|
||||
if not exact and GROUP_ADMIN_PK in user_data["groups_id"]:
|
||||
# User in admin group (pk 2) grants all permissions.
|
||||
in_some_groups = True
|
||||
else:
|
||||
|
@ -28,13 +28,18 @@ class Element(ElementBase, total=False):
|
||||
if full_data is None, it means, that the element was deleted. If reload is
|
||||
True, full_data is ignored and reloaded from the database later in the
|
||||
process.
|
||||
|
||||
no_delete_on_restriction is a flag, which is saved into the models in the cache
|
||||
as the _no_delete_on_restriction key. If this is true, there should neither be an
|
||||
entry for one specific model in the changed *nor the deleted* part of the
|
||||
autoupdate, if the model was restricted.
|
||||
"""
|
||||
|
||||
information: List[str]
|
||||
restricted: bool
|
||||
user_id: Optional[int]
|
||||
disable_history: bool
|
||||
reload: bool
|
||||
no_delete_on_restriction: bool
|
||||
|
||||
|
||||
AutoupdateFormat = TypedDict(
|
||||
@ -53,7 +58,7 @@ def inform_changed_data(
|
||||
instances: Union[Iterable[Model], Model],
|
||||
information: List[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
restricted: bool = False,
|
||||
no_delete_on_restriction: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Informs the autoupdate system and the caching system about the creation or
|
||||
@ -84,8 +89,8 @@ def inform_changed_data(
|
||||
collection_string=root_instance.get_collection_string(),
|
||||
full_data=root_instance.get_full_data(),
|
||||
information=information,
|
||||
restricted=restricted,
|
||||
user_id=user_id,
|
||||
no_delete_on_restriction=no_delete_on_restriction,
|
||||
)
|
||||
|
||||
bundle = autoupdate_bundle.get(threading.get_ident())
|
||||
@ -101,7 +106,6 @@ def inform_deleted_data(
|
||||
deleted_elements: Iterable[Tuple[str, int]],
|
||||
information: List[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
restricted: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Informs the autoupdate system and the caching system about the deletion of
|
||||
@ -119,7 +123,6 @@ def inform_deleted_data(
|
||||
collection_string=deleted_element[0],
|
||||
full_data=None,
|
||||
information=information,
|
||||
restricted=restricted,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@ -197,7 +200,12 @@ def handle_changed_elements(elements: Iterable[Element]) -> None:
|
||||
cache_elements: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||
for element in elements:
|
||||
element_id = get_element_id(element["collection_string"], element["id"])
|
||||
cache_elements[element_id] = element["full_data"]
|
||||
full_data = element["full_data"]
|
||||
if full_data:
|
||||
full_data["_no_delete_on_restriction"] = element.get(
|
||||
"no_delete_on_restriction", False
|
||||
)
|
||||
cache_elements[element_id] = full_data
|
||||
return await element_cache.change_elements(cache_elements)
|
||||
|
||||
async def async_handle_collection_elements(elements: Iterable[Element]) -> None:
|
||||
|
@ -204,7 +204,11 @@ class ElementCache:
|
||||
all_data: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for element_id, data in (await self.cache_provider.get_all_data()).items():
|
||||
collection_string, _ = split_element_id(element_id)
|
||||
all_data[collection_string].append(json.loads(data.decode()))
|
||||
element = json.loads(data.decode())
|
||||
element.pop(
|
||||
"_no_delete_on_restriction", False
|
||||
) # remove special field for get_data_since
|
||||
all_data[collection_string].append(element)
|
||||
|
||||
if user_id is not None:
|
||||
for collection_string in all_data.keys():
|
||||
@ -226,7 +230,11 @@ class ElementCache:
|
||||
all_data: Dict[str, Dict[int, Dict[str, Any]]] = defaultdict(dict)
|
||||
for element_id, data in (await self.cache_provider.get_all_data()).items():
|
||||
collection_string, id = split_element_id(element_id)
|
||||
all_data[collection_string][id] = json.loads(data.decode())
|
||||
element = json.loads(data.decode())
|
||||
element.pop(
|
||||
"_no_delete_on_restriction", False
|
||||
) # remove special field for get_data_since
|
||||
all_data[collection_string][id] = element
|
||||
return dict(all_data)
|
||||
|
||||
async def get_collection_data(
|
||||
@ -241,6 +249,9 @@ class ElementCache:
|
||||
collection_data = {}
|
||||
for id in encoded_collection_data.keys():
|
||||
collection_data[id] = json.loads(encoded_collection_data[id].decode())
|
||||
collection_data[id].pop(
|
||||
"_no_delete_on_restriction", False
|
||||
) # remove special field for get_data_since
|
||||
return collection_data
|
||||
|
||||
async def get_element_data(
|
||||
@ -257,6 +268,9 @@ class ElementCache:
|
||||
if encoded_element is None:
|
||||
return None
|
||||
element = json.loads(encoded_element.decode()) # type: ignore
|
||||
element.pop(
|
||||
"_no_delete_on_restriction", False
|
||||
) # remove special field for get_data_since
|
||||
|
||||
if user_id is not None:
|
||||
element = await self.restrict_element_data(
|
||||
@ -319,6 +333,16 @@ class ElementCache:
|
||||
# the list(...) is important, because `changed_elements` will be
|
||||
# altered during iteration and restricting data
|
||||
for collection_string, elements in list(changed_elements.items()):
|
||||
# Remove the _no_delete_on_restriction from each element. Collect all ids, where
|
||||
# this field is absent or False.
|
||||
unrestricted_ids = set()
|
||||
for element in elements:
|
||||
no_delete_on_restriction = element.pop(
|
||||
"_no_delete_on_restriction", False
|
||||
)
|
||||
if not no_delete_on_restriction:
|
||||
unrestricted_ids.add(element["id"])
|
||||
|
||||
cacheable = self.cachables[collection_string]
|
||||
restricted_elements = await cacheable.restrict_elements(
|
||||
user_id, elements
|
||||
@ -327,11 +351,12 @@ class ElementCache:
|
||||
# If the model is personalized, it must not be deleted for other users
|
||||
if not cacheable.personalized_model:
|
||||
# Add removed objects (through restricter) to deleted elements.
|
||||
element_ids = set([element["id"] for element in elements])
|
||||
restricted_element_ids = set(
|
||||
[element["id"] for element in restricted_elements]
|
||||
)
|
||||
for id in element_ids - restricted_element_ids:
|
||||
# Delete all ids, that are allowed to be deleted (see unrestricted_ids) and are
|
||||
# not present after restricting the data.
|
||||
for id in unrestricted_ids - restricted_element_ids:
|
||||
deleted_elements.append(get_element_id(collection_string, id))
|
||||
|
||||
if not restricted_elements:
|
||||
|
@ -8,7 +8,7 @@ from . import logging
|
||||
from .access_permissions import BaseAccessPermissions
|
||||
from .autoupdate import Element, inform_changed_data, inform_changed_elements
|
||||
from .rest_api import model_serializer_classes
|
||||
from .utils import convert_camel_case_to_pseudo_snake_case
|
||||
from .utils import convert_camel_case_to_pseudo_snake_case, get_element_id
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -90,7 +90,16 @@ class RESTModelMixin:
|
||||
"""
|
||||
return self.pk # type: ignore
|
||||
|
||||
def save(self, skip_autoupdate: bool = False, *args: Any, **kwargs: Any) -> Any:
|
||||
def get_element_id(self) -> str:
|
||||
return get_element_id(self.get_collection_string(), self.get_rest_pk())
|
||||
|
||||
def save(
|
||||
self,
|
||||
skip_autoupdate: bool = False,
|
||||
no_delete_on_restriction: bool = False,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Calls Django's save() method and afterwards hits the autoupdate system.
|
||||
|
||||
@ -104,7 +113,10 @@ class RESTModelMixin:
|
||||
|
||||
return_value = super().save(*args, **kwargs) # type: ignore
|
||||
if not skip_autoupdate:
|
||||
inform_changed_data(self.get_root_rest_element())
|
||||
inform_changed_data(
|
||||
self.get_root_rest_element(),
|
||||
no_delete_on_restriction=no_delete_on_restriction,
|
||||
)
|
||||
return return_value
|
||||
|
||||
def delete(self, skip_autoupdate: bool = False, *args: Any, **kwargs: Any) -> Any:
|
||||
|
@ -1,7 +0,0 @@
|
||||
from django.test import TestCase as _TestCase
|
||||
|
||||
|
||||
class TestCase(_TestCase):
|
||||
"""
|
||||
Does currently nothing.
|
||||
"""
|
@ -1,7 +1,7 @@
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
from typing import Dict, Generator, Optional, Tuple, Type, Union
|
||||
from typing import Any, Dict, Generator, Optional, Tuple, Type, Union
|
||||
|
||||
import roman
|
||||
from django.apps import apps
|
||||
@ -64,6 +64,14 @@ def str_dict_to_bytes(str_dict: Dict[str, str]) -> Dict[bytes, bytes]:
|
||||
return out
|
||||
|
||||
|
||||
def is_int(obj: Any) -> bool:
|
||||
try:
|
||||
int(obj)
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
_models_to_collection_string: Dict[str, Type[Model]] = {}
|
||||
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
from openslides.agenda.models import Item
|
||||
from openslides.topics.models import Topic
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
|
||||
class TestItemManager(TestCase):
|
||||
|
@ -16,7 +16,7 @@ from openslides.motions.models import Motion
|
||||
from openslides.topics.models import Topic
|
||||
from openslides.users.models import Group
|
||||
from openslides.utils.autoupdate import inform_changed_data
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
from ...common_groups import GROUP_DEFAULT_PK
|
||||
from ..helpers import count_queries
|
||||
@ -284,25 +284,11 @@ class ManageSpeaker(TestCase):
|
||||
Tests managing speakers.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.client.login(username="admin", password="admin")
|
||||
|
||||
def advancedSetUp(self):
|
||||
self.list_of_speakers = Topic.objects.create(
|
||||
title="test_title_aZaedij4gohn5eeQu8fe"
|
||||
).list_of_speakers
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="test_user_jooSaex1bo5ooPhuphae",
|
||||
password="test_password_e6paev4zeeh9n",
|
||||
)
|
||||
|
||||
def revoke_admin_rights(self):
|
||||
admin = get_user_model().objects.get(username="admin")
|
||||
group_admin = admin.groups.get(name="Admin")
|
||||
group_delegates = type(group_admin).objects.get(name="Delegates")
|
||||
admin.groups.add(group_delegates)
|
||||
admin.groups.remove(group_admin)
|
||||
inform_changed_data(admin)
|
||||
self.user, _ = self.create_user()
|
||||
|
||||
def test_add_oneself_once(self):
|
||||
response = self.client.post(
|
||||
@ -383,7 +369,7 @@ class ManageSpeaker(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_add_someone_else_non_admin(self):
|
||||
self.revoke_admin_rights()
|
||||
self.make_admin_delegate()
|
||||
|
||||
response = self.client.post(
|
||||
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
|
||||
@ -392,6 +378,7 @@ class ManageSpeaker(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_remove_someone_else(self):
|
||||
print(self.user)
|
||||
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
|
||||
response = self.client.delete(
|
||||
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
|
||||
@ -419,7 +406,7 @@ class ManageSpeaker(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_remove_someone_else_non_admin(self):
|
||||
self.revoke_admin_rights()
|
||||
self.make_admin_delegate()
|
||||
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
|
||||
|
||||
response = self.client.delete(
|
||||
@ -433,14 +420,13 @@ class ManageSpeaker(TestCase):
|
||||
response = self.client.patch(
|
||||
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
|
||||
{"user": self.user.pk, "marked": True},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(Speaker.objects.get().marked)
|
||||
|
||||
def test_mark_speaker_non_admin(self):
|
||||
self.revoke_admin_rights()
|
||||
self.make_admin_delegate()
|
||||
Speaker.objects.add(self.user, self.list_of_speakers)
|
||||
|
||||
response = self.client.patch(
|
||||
@ -515,7 +501,7 @@ class ManageSpeaker(TestCase):
|
||||
|
||||
def test_readd_last_speaker_no_admin(self):
|
||||
self.util_add_user_as_last_speaker()
|
||||
self.revoke_admin_rights()
|
||||
self.make_admin_delegate()
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
|
507
tests/integration/assignments/test_polls.py
Normal file
507
tests/integration/assignments/test_polls.py
Normal 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())
|
@ -9,7 +9,7 @@ from openslides.assignments.models import Assignment
|
||||
from openslides.core.models import Tag
|
||||
from openslides.mediafiles.models import Mediafile
|
||||
from openslides.utils.autoupdate import inform_changed_data
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
from ..helpers import count_queries
|
||||
|
||||
@ -75,7 +75,7 @@ class CreateAssignment(TestCase):
|
||||
self.assertTrue(assignment.attachments.exists())
|
||||
|
||||
|
||||
class CanidatureSelf(TestCase):
|
||||
class CandidatureSelf(TestCase):
|
||||
"""
|
||||
Tests self candidation view.
|
||||
"""
|
||||
@ -99,7 +99,7 @@ class CanidatureSelf(TestCase):
|
||||
)
|
||||
|
||||
def test_nominate_self_twice(self):
|
||||
self.assignment.set_candidate(get_user_model().objects.get(username="admin"))
|
||||
self.assignment.add_candidate(get_user_model().objects.get(username="admin"))
|
||||
|
||||
response = self.client.post(
|
||||
reverse("assignment-candidature-self", args=[self.assignment.pk])
|
||||
@ -152,7 +152,7 @@ class CanidatureSelf(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_withdraw_self(self):
|
||||
self.assignment.set_candidate(get_user_model().objects.get(username="admin"))
|
||||
self.assignment.add_candidate(get_user_model().objects.get(username="admin"))
|
||||
|
||||
response = self.client.delete(
|
||||
reverse("assignment-candidature-self", args=[self.assignment.pk])
|
||||
@ -173,7 +173,7 @@ class CanidatureSelf(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_withdraw_self_when_finished(self):
|
||||
self.assignment.set_candidate(get_user_model().objects.get(username="admin"))
|
||||
self.assignment.add_candidate(get_user_model().objects.get(username="admin"))
|
||||
self.assignment.set_phase(Assignment.PHASE_FINISHED)
|
||||
self.assignment.save()
|
||||
|
||||
@ -184,7 +184,7 @@ class CanidatureSelf(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_withdraw_self_during_voting(self):
|
||||
self.assignment.set_candidate(get_user_model().objects.get(username="admin"))
|
||||
self.assignment.add_candidate(get_user_model().objects.get(username="admin"))
|
||||
self.assignment.set_phase(Assignment.PHASE_VOTING)
|
||||
self.assignment.save()
|
||||
|
||||
@ -198,7 +198,7 @@ class CanidatureSelf(TestCase):
|
||||
)
|
||||
|
||||
def test_withdraw_self_during_voting_non_admin(self):
|
||||
self.assignment.set_candidate(get_user_model().objects.get(username="admin"))
|
||||
self.assignment.add_candidate(get_user_model().objects.get(username="admin"))
|
||||
self.assignment.set_phase(Assignment.PHASE_VOTING)
|
||||
self.assignment.save()
|
||||
admin = get_user_model().objects.get(username="admin")
|
||||
@ -267,7 +267,7 @@ class CandidatureOther(TestCase):
|
||||
)
|
||||
|
||||
def test_nominate_other_twice(self):
|
||||
self.assignment.set_candidate(
|
||||
self.assignment.add_candidate(
|
||||
get_user_model().objects.get(username="test_user_eeheekai4Phue6cahtho")
|
||||
)
|
||||
response = self.client.post(
|
||||
@ -321,7 +321,7 @@ class CandidatureOther(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_delete_other(self):
|
||||
self.assignment.set_candidate(self.user)
|
||||
self.assignment.add_candidate(self.user)
|
||||
response = self.client.delete(
|
||||
reverse("assignment-candidature-other", args=[self.assignment.pk]),
|
||||
{"user": self.user.pk},
|
||||
@ -343,7 +343,7 @@ class CandidatureOther(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_delete_other_when_finished(self):
|
||||
self.assignment.set_candidate(self.user)
|
||||
self.assignment.add_candidate(self.user)
|
||||
self.assignment.set_phase(Assignment.PHASE_FINISHED)
|
||||
self.assignment.save()
|
||||
|
||||
@ -355,7 +355,7 @@ class CandidatureOther(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_delete_other_during_voting(self):
|
||||
self.assignment.set_candidate(self.user)
|
||||
self.assignment.add_candidate(self.user)
|
||||
self.assignment.set_phase(Assignment.PHASE_VOTING)
|
||||
self.assignment.save()
|
||||
|
||||
@ -372,7 +372,7 @@ class CandidatureOther(TestCase):
|
||||
)
|
||||
|
||||
def test_delete_other_during_voting_non_admin(self):
|
||||
self.assignment.set_candidate(self.user)
|
||||
self.assignment.add_candidate(self.user)
|
||||
self.assignment.set_phase(Assignment.PHASE_VOTING)
|
||||
self.assignment.save()
|
||||
admin = get_user_model().objects.get(username="admin")
|
||||
@ -408,7 +408,7 @@ class MarkElectedOtherUser(TestCase):
|
||||
)
|
||||
|
||||
def test_mark_elected(self):
|
||||
self.assignment.set_candidate(
|
||||
self.assignment.add_candidate(
|
||||
get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev")
|
||||
)
|
||||
response = self.client.post(
|
||||
@ -437,46 +437,3 @@ class MarkElectedOtherUser(TestCase):
|
||||
.elected.filter(username="test_user_Oonei3rahji5jugh1eev")
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
class UpdateAssignmentPoll(TestCase):
|
||||
"""
|
||||
Tests updating polls of assignments.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.client.login(username="admin", password="admin")
|
||||
self.assignment = Assignment.objects.create(
|
||||
title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1
|
||||
)
|
||||
self.assignment.set_candidate(get_user_model().objects.get(username="admin"))
|
||||
self.poll = self.assignment.create_poll()
|
||||
|
||||
def test_invalid_votesvalid_value(self):
|
||||
response = self.client.put(
|
||||
reverse("assignmentpoll-detail", args=[self.poll.pk]),
|
||||
{"assignment_id": self.assignment.pk, "votesvalid": "-3"},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_invalid_votesinvalid_value(self):
|
||||
response = self.client.put(
|
||||
reverse("assignmentpoll-detail", args=[self.poll.pk]),
|
||||
{"assignment_id": self.assignment.pk, "votesinvalid": "-3"},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_invalid_votescast_value(self):
|
||||
response = self.client.put(
|
||||
reverse("assignmentpoll-detail", args=[self.poll.pk]),
|
||||
{"assignment_id": self.assignment.pk, "votescast": "-3"},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_empty_value_for_votesvalid(self):
|
||||
response = self.client.put(
|
||||
reverse("assignmentpoll-detail", args=[self.poll.pk]),
|
||||
{"assignment_id": self.assignment.pk, "votesvalid": ""},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
@ -9,7 +9,7 @@ from openslides import __license__ as license, __url__ as url, __version__ as ve
|
||||
from openslides.core.config import ConfigVariable, config
|
||||
from openslides.core.models import Projector
|
||||
from openslides.utils.rest_api import ValidationError
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=False)
|
||||
|
@ -13,8 +13,8 @@ from openslides.core.models import Projector, Tag
|
||||
from openslides.users.models import User
|
||||
from openslides.utils.auth import get_group_model
|
||||
from openslides.utils.autoupdate import inform_changed_data
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.common_groups import GROUP_ADMIN_PK, GROUP_DELEGATE_PK
|
||||
from tests.test_case import TestCase
|
||||
|
||||
from ..helpers import count_queries
|
||||
|
||||
@ -107,7 +107,6 @@ class Projection(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("projector-project", args=[self.projector.pk]),
|
||||
{"elements": elements},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.projector = Projector.objects.get(pk=1)
|
||||
@ -117,9 +116,7 @@ class Projection(TestCase):
|
||||
|
||||
def test_add_element_without_name(self):
|
||||
response = self.client.post(
|
||||
reverse("projector-project", args=[self.projector.pk]),
|
||||
{"elements": [{}]},
|
||||
format="json",
|
||||
reverse("projector-project", args=[self.projector.pk]), {"elements": [{}]}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.projector = Projector.objects.get(pk=1)
|
||||
@ -134,7 +131,7 @@ class Projection(TestCase):
|
||||
inform_changed_data(admin)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("projector-project", args=[self.projector.pk]), {}, format="json"
|
||||
reverse("projector-project", args=[self.projector.pk]), {}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@ -142,9 +139,7 @@ class Projection(TestCase):
|
||||
self.projector.elements = [{"name": "core/clock"}]
|
||||
self.projector.save()
|
||||
response = self.client.post(
|
||||
reverse("projector-project", args=[self.projector.pk]),
|
||||
{"elements": []},
|
||||
format="json",
|
||||
reverse("projector-project", args=[self.projector.pk]), {"elements": []}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.projector = Projector.objects.get(pk=1)
|
||||
@ -157,7 +152,6 @@ class Projection(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("projector-project", args=[self.projector.pk]),
|
||||
{"append_to_history": element},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.projector = Projector.objects.get(pk=1)
|
||||
@ -173,7 +167,6 @@ class Projection(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("projector-project", args=[self.projector.pk]),
|
||||
{"delete_last_history_element": True},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.projector = Projector.objects.get(pk=1)
|
||||
@ -186,7 +179,6 @@ class Projection(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("projector-project", args=[self.projector.pk]),
|
||||
{"preview": elements},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.projector = Projector.objects.get(pk=1)
|
||||
@ -265,7 +257,6 @@ class ConfigViewSet(TestCase):
|
||||
response = self.client.put(
|
||||
reverse("config-detail", args=["agenda_start_event_date_time"]),
|
||||
{"value": None},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(config["agenda_start_event_date_time"], None)
|
||||
@ -277,7 +268,6 @@ class ConfigViewSet(TestCase):
|
||||
response = self.client.put(
|
||||
reverse("config-detail", args=["motions_identifier_min_digits"]),
|
||||
{"value": None},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@ -316,9 +306,7 @@ class ConfigViewSet(TestCase):
|
||||
self.degrade_admin(can_manage_logos_and_fonts=True)
|
||||
value = self.get_static_config_value()
|
||||
response = self.client.put(
|
||||
reverse("config-detail", args=[self.logo_config_key]),
|
||||
{"value": value},
|
||||
format="json",
|
||||
reverse("config-detail", args=[self.logo_config_key]), {"value": value}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(config[self.logo_config_key], value)
|
||||
@ -332,7 +320,6 @@ class ConfigViewSet(TestCase):
|
||||
{"key": self.string_config_key, "value": string_value},
|
||||
{"key": self.logo_config_key, "value": logo_value},
|
||||
],
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["errors"], {})
|
||||
@ -345,7 +332,6 @@ class ConfigViewSet(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("config-bulk-update"),
|
||||
[{"key": self.string_config_key, "value": string_value}],
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(config[self.string_config_key], "OpenSlides")
|
||||
@ -355,7 +341,6 @@ class ConfigViewSet(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("config-bulk-update"),
|
||||
{"key": self.string_config_key, "value": string_value},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(config[self.string_config_key], "OpenSlides")
|
||||
@ -363,16 +348,14 @@ class ConfigViewSet(TestCase):
|
||||
def test_bulk_update_no_key(self):
|
||||
string_value = "test_value_glwe32qc&Lml2lclmqmc"
|
||||
response = self.client.post(
|
||||
reverse("config-bulk-update"), [{"value": string_value}], format="json"
|
||||
reverse("config-bulk-update"), [{"value": string_value}]
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(config[self.string_config_key], "OpenSlides")
|
||||
|
||||
def test_bulk_update_no_value(self):
|
||||
response = self.client.post(
|
||||
reverse("config-bulk-update"),
|
||||
[{"key": self.string_config_key}],
|
||||
format="json",
|
||||
reverse("config-bulk-update"), [{"key": self.string_config_key}]
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(config[self.string_config_key], "OpenSlides")
|
||||
@ -384,7 +367,7 @@ class ConfigViewSet(TestCase):
|
||||
"motions_preamble"
|
||||
] = "test_preamble_2390jvwohjwo1oigefoq" # Group motions
|
||||
response = self.client.post(
|
||||
reverse("config-reset-groups"), ["General", "Agenda"], format="json"
|
||||
reverse("config-reset-groups"), ["General", "Agenda"]
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(config["general_event_name"], "OpenSlides")
|
||||
@ -394,15 +377,11 @@ class ConfigViewSet(TestCase):
|
||||
)
|
||||
|
||||
def test_reset_group_wrong_format_1(self):
|
||||
response = self.client.post(
|
||||
reverse("config-reset-groups"), {"wrong": "format"}, format="json"
|
||||
)
|
||||
response = self.client.post(reverse("config-reset-groups"), {"wrong": "format"})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_reset_group_wrong_format_2(self):
|
||||
response = self.client.post(
|
||||
reverse("config-reset-groups"),
|
||||
["some_string", {"wrong": "format"}],
|
||||
format="json",
|
||||
reverse("config-reset-groups"), ["some_string", {"wrong": "format"}]
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
@ -7,7 +7,7 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from openslides.mediafiles.models import Mediafile
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
from ..helpers import count_queries
|
||||
|
||||
@ -41,6 +41,7 @@ class TestCreation(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("mediafile-list"),
|
||||
{"title": "test_title_ahyo1uifoo9Aiph2av5a", "mediafile": self.file},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
mediafile = Mediafile.objects.get()
|
||||
@ -170,8 +171,8 @@ class TestCreation(TestCase):
|
||||
reverse("mediafile-list"),
|
||||
{
|
||||
"title": "test_title_dggjwevBnUngelkdviom",
|
||||
"mediafile": self.file,
|
||||
"access_groups_id": json.dumps([2, 4]),
|
||||
"is_directory": True,
|
||||
"access_groups_id": [2, 4],
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
@ -268,7 +269,6 @@ class TestUpdate(TestCase):
|
||||
response = self.client.put(
|
||||
reverse("mediafile-detail", args=[self.mediafileA.pk]),
|
||||
{"title": self.mediafileA.title, "parent_id": None},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
mediafile = Mediafile.objects.get(pk=self.mediafileA.pk)
|
||||
|
980
tests/integration/motions/test_motions.py
Normal file
980
tests/integration/motions/test_motions.py
Normal 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"
|
||||
)
|
1107
tests/integration/motions/test_polls.py
Normal file
1107
tests/integration/motions/test_polls.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@ from django.test.client import Client
|
||||
|
||||
from openslides.core.config import config
|
||||
from openslides.motions.models import Motion
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
|
||||
class AnonymousRequests(TestCase):
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@ from rest_framework import status
|
||||
|
||||
from openslides.agenda.models import Item
|
||||
from openslides.topics.models import Topic
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
from ..helpers import count_queries
|
||||
|
||||
|
@ -3,12 +3,15 @@ import json
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
|
||||
class TestWhoAmIView(TestCase):
|
||||
url = reverse("user_whoami")
|
||||
|
||||
def setUp(self):
|
||||
pass
|
||||
|
||||
def test_get_anonymous(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
@ -44,6 +47,9 @@ class TestWhoAmIView(TestCase):
|
||||
class TestUserLogoutView(TestCase):
|
||||
url = reverse("user_logout")
|
||||
|
||||
def setUp(self):
|
||||
pass
|
||||
|
||||
def test_get(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
|
@ -8,7 +8,7 @@ from rest_framework.test import APIClient
|
||||
from openslides.core.config import config
|
||||
from openslides.users.models import Group, PersonalNote, User
|
||||
from openslides.utils.autoupdate import inform_changed_data
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
from ...common_groups import (
|
||||
GROUP_ADMIN_PK,
|
||||
@ -196,7 +196,6 @@ class UserUpdate(TestCase):
|
||||
response = admin_client.patch(
|
||||
reverse("user-detail", args=[user_pk]),
|
||||
{"username": "admin", "is_active": False},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
@ -268,7 +267,7 @@ class UserDelete(TestCase):
|
||||
ids.append(user.id)
|
||||
|
||||
response = self.admin_client.post(
|
||||
reverse("user-bulk-delete"), {"user_ids": ids}, format="json"
|
||||
reverse("user-bulk-delete"), {"user_ids": ids}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.assertFalse(User.objects.filter(pk__in=ids).exists())
|
||||
@ -276,7 +275,7 @@ class UserDelete(TestCase):
|
||||
def test_bulk_delete_self(self):
|
||||
""" The own id should be excluded, so nothing should happen. """
|
||||
response = self.admin_client.post(
|
||||
reverse("user-bulk-delete"), {"user_ids": [1]}, format="json"
|
||||
reverse("user-bulk-delete"), {"user_ids": [1]}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.assertTrue(User.objects.filter(pk=1).exists())
|
||||
@ -416,9 +415,7 @@ class UserPassword(TestCase):
|
||||
self.assertTrue(user2.check_password(default_password2))
|
||||
|
||||
response = self.admin_client.post(
|
||||
reverse("user-bulk-generate-passwords"),
|
||||
{"user_ids": [user1.id, user2.id]},
|
||||
format="json",
|
||||
reverse("user-bulk-generate-passwords"), {"user_ids": [user1.id, user2.id]}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@ -450,7 +447,6 @@ class UserPassword(TestCase):
|
||||
response = self.admin_client.post(
|
||||
reverse("user-bulk-reset-passwords-to-default"),
|
||||
{"user_ids": [user1.id, user2.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@ -478,7 +474,6 @@ class UserBulkSetState(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("user-bulk-set-state"),
|
||||
{"user_ids": [1], "field": "is_present", "value": False},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(User.objects.get().is_active)
|
||||
@ -489,7 +484,6 @@ class UserBulkSetState(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("user-bulk-set-state"),
|
||||
{"user_ids": [1], "field": "invalid", "value": False},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertTrue(User.objects.get().is_active)
|
||||
@ -500,7 +494,6 @@ class UserBulkSetState(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("user-bulk-set-state"),
|
||||
{"user_ids": [1], "field": "is_active", "value": "invalid"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertTrue(User.objects.get().is_active)
|
||||
@ -511,7 +504,6 @@ class UserBulkSetState(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("user-bulk-set-state"),
|
||||
{"user_ids": [1], "field": "is_active", "value": False},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(User.objects.get().is_active)
|
||||
@ -539,7 +531,6 @@ class UserBulkAlterGroups(TestCase):
|
||||
"action": "add",
|
||||
"group_ids": [GROUP_DELEGATE_PK, GROUP_STAFF_PK],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(self.user.groups.count(), 2)
|
||||
@ -558,7 +549,6 @@ class UserBulkAlterGroups(TestCase):
|
||||
"action": "remove",
|
||||
"group_ids": [GROUP_DEFAULT_PK, GROUP_STAFF_PK],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(self.user.groups.count(), 1)
|
||||
@ -574,7 +564,6 @@ class UserBulkAlterGroups(TestCase):
|
||||
"action": "add",
|
||||
"group_ids": [GROUP_DELEGATE_PK],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(self.admin.groups.count(), 1)
|
||||
@ -588,7 +577,6 @@ class UserBulkAlterGroups(TestCase):
|
||||
"action": "invalid",
|
||||
"group_ids": [GROUP_DELEGATE_PK],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@ -614,7 +602,7 @@ class UserMassImport(TestCase):
|
||||
"groups_id": [],
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse("user-mass-import"), {"users": [user_1, user_2]}, format="json"
|
||||
reverse("user-mass-import"), {"users": [user_1, user_2]}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(User.objects.count(), 3)
|
||||
@ -640,9 +628,7 @@ class UserSendIntivationEmail(TestCase):
|
||||
"subject": config["users_email_subject"],
|
||||
"message": config["users_email_body"],
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse("user-mass-invite-email"), data, format="json"
|
||||
)
|
||||
response = self.client.post(reverse("user-mass-invite-email"), data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
@ -666,6 +652,9 @@ class GroupMetadata(TestCase):
|
||||
|
||||
|
||||
class GroupReceive(TestCase):
|
||||
def setUp(self):
|
||||
pass
|
||||
|
||||
def test_get_groups_as_anonymous_deactivated(self):
|
||||
"""
|
||||
Test to get the groups with an anonymous user, when they are deactivated.
|
||||
@ -849,7 +838,6 @@ class GroupUpdate(TestCase):
|
||||
response = admin_client.put(
|
||||
reverse("group-detail", args=[group.pk]),
|
||||
{"name": "new_group_name_Chie6duwaepoo8aech7r", "permissions": permissions},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@ -869,7 +857,6 @@ class GroupUpdate(TestCase):
|
||||
response = admin_client.post(
|
||||
reverse("group-set-permission", args=[GROUP_DEFAULT_PK]),
|
||||
{"perm": "users.can_manage", "set": True},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@ -887,7 +874,6 @@ class GroupUpdate(TestCase):
|
||||
response = admin_client.post(
|
||||
reverse("group-set-permission", args=[GROUP_DEFAULT_PK]),
|
||||
{"perm": "not_existing.permission", "set": True},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
@ -899,7 +885,6 @@ class GroupUpdate(TestCase):
|
||||
response = admin_client.post(
|
||||
reverse("group-set-permission", args=[GROUP_DEFAULT_PK]),
|
||||
{"perm": "users.can_see_name", "set": False},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@ -972,7 +957,6 @@ class PersonalNoteTest(TestCase):
|
||||
{"collection": "example-model", "id": 1, "content": content1},
|
||||
{"collection": "example-model", "id": 2, "content": content2},
|
||||
],
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(PersonalNote.objects.exists())
|
||||
@ -985,9 +969,7 @@ class PersonalNoteTest(TestCase):
|
||||
|
||||
def test_anonymous_create(self):
|
||||
guest_client = APIClient()
|
||||
response = guest_client.post(
|
||||
reverse("personalnote-create-or-update"), [], format="json"
|
||||
)
|
||||
response = guest_client.post(reverse("personalnote-create-or-update"), [])
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertFalse(PersonalNote.objects.exists())
|
||||
|
||||
@ -1007,7 +989,6 @@ class PersonalNoteTest(TestCase):
|
||||
"content": "test_note_do2ncoi7ci2fm93LjwlO",
|
||||
}
|
||||
],
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
personal_note = PersonalNote.objects.get()
|
||||
|
@ -2,7 +2,7 @@ from openslides.agenda.models import ListOfSpeakers, Speaker
|
||||
from openslides.topics.models import Topic
|
||||
from openslides.users.models import User
|
||||
from openslides.utils.exceptions import OpenSlidesError
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
|
||||
class ListOfSpeakerModelTests(TestCase):
|
||||
|
@ -1,6 +1,6 @@
|
||||
from openslides.core.config import ConfigVariable, config
|
||||
from openslides.core.exceptions import ConfigError
|
||||
from openslides.utils.test import TestCase
|
||||
from tests.test_case import TestCase
|
||||
|
||||
|
||||
class TTestConfigException(Exception):
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user