diff --git a/client/src/app/core/poll.service.spec.ts b/client/src/app/core/poll.service.spec.ts
new file mode 100644
index 000000000..35d93f8ad
--- /dev/null
+++ b/client/src/app/core/poll.service.spec.ts
@@ -0,0 +1,12 @@
+import { TestBed } from '@angular/core/testing';
+
+import { PollService } from './poll.service';
+
+describe('PollService', () => {
+ beforeEach(() => TestBed.configureTestingModule({}));
+
+ it('should be created', () => {
+ const service: PollService = TestBed.get(PollService);
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/client/src/app/core/poll.service.ts b/client/src/app/core/poll.service.ts
new file mode 100644
index 000000000..86e178009
--- /dev/null
+++ b/client/src/app/core/poll.service.ts
@@ -0,0 +1,124 @@
+import { Injectable } from '@angular/core';
+
+/**
+ * The possible keys of a poll object that represent numbers.
+ * TODO Should be 'key of MotionPoll if type of key is number'
+ * TODO: normalize MotionPoll model and other poll models
+ * TODO: reuse more motion-poll-service stuff
+ */
+export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain';
+
+/**
+ * Shared service class for polls.
+ * TODO: For now, motionPolls only. TODO See if reusable for assignment polls etc.
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class PollService {
+ /**
+ * The chosen and currently used base for percentage calculations. Is set by
+ * the config service
+ */
+ public percentBase: string;
+
+ /**
+ * The default majority method (as set per config).
+ */
+ public defaultMajorityMethod: string;
+
+ /**
+ * An array of value - label pairs for special value signifiers.
+ * TODO: Should be given by the server, and editable. For now: hard coded
+ */
+ private _specialPollVotes: [number, string][] = [[-1, 'Majority'], [-2, 'Undocumented']];
+
+ /**
+ * getter for the special votes
+ *
+ * @returns an array of special (non-positive) numbers used in polls and
+ * their descriptive strings
+ */
+ public get specialPollVotes(): [number, string][] {
+ return this._specialPollVotes;
+ }
+
+ /**
+ * empty constructor
+ */
+ public constructor() {}
+
+ /**
+ * TODO not implemented yet. Should print the ballots for a motion poll,
+ * depending on the motion and on the configuration
+ */
+ public printBallots(): void {
+ console.log('TODO: Ballot printing Not yet implemented');
+ }
+
+ /**
+ * Gets an icon for a Poll Key
+ *
+ * @param key
+ * @returns a string for material-icons to represent the icon for
+ * this key(e.g. yes: positiv sign, no: negative sign)
+ */
+ public getIcon(key: CalculablePollKey): string {
+ switch (key) {
+ case 'yes':
+ return 'thumb_up';
+ case 'no':
+ return 'thumb_down';
+ case 'abstain':
+ return 'not_interested';
+ // case 'votescast':
+ // sum
+ case 'votesvalid':
+ return 'check';
+ case 'votesinvalid':
+ return 'cancel';
+ default:
+ return '';
+ }
+ }
+
+ /**
+ * Gets a label for a poll Key
+ *
+ * @returns A short descriptive name for the poll keys
+ */
+ public getLabel(key: CalculablePollKey): string {
+ switch (key) {
+ case 'yes':
+ return 'Yes';
+ case 'no':
+ return 'No';
+ case 'abstain':
+ return 'Abstain';
+ case 'votescast':
+ return 'Total votes cast';
+ case 'votesvalid':
+ return 'Valid votes';
+ case 'votesinvalid':
+ return 'Invalid votes';
+ default:
+ return '';
+ }
+ }
+
+ /**
+ * retrieve special labels for a poll value
+ *
+ * @param value
+ * @returns the label for a non-positive value, according to
+ * {@link specialPollVotes}. Positive values will return as string
+ * representation of themselves
+ */
+ public getSpecialLabel(value: number): string {
+ if (value >= 0) {
+ return value.toString();
+ }
+ const vote = this.specialPollVotes.find(special => special[0] === value);
+ return vote ? vote[1] : 'Undocumented special (negative) value';
+ }
+}
diff --git a/client/src/app/shared/models/motions/motion-poll.ts b/client/src/app/shared/models/motions/motion-poll.ts
new file mode 100644
index 000000000..12e1c1370
--- /dev/null
+++ b/client/src/app/shared/models/motions/motion-poll.ts
@@ -0,0 +1,36 @@
+import { Deserializer } from '../base/deserializer';
+
+/**
+ * Class representing a poll for a motion.
+ */
+export class MotionPoll extends Deserializer {
+ 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);
+ }
+}
diff --git a/client/src/app/shared/models/motions/motion.ts b/client/src/app/shared/models/motions/motion.ts
index 403cb7b3f..4690acea8 100644
--- a/client/src/app/shared/models/motions/motion.ts
+++ b/client/src/app/shared/models/motions/motion.ts
@@ -3,6 +3,7 @@ import { MotionLog } from './motion-log';
import { MotionComment } from './motion-comment';
import { AgendaBaseModel } from '../base/agenda-base-model';
import { SearchRepresentation } from '../../../core/services/search.service';
+import { MotionPoll } from './motion-poll';
/**
* Representation of Motion.
@@ -35,7 +36,7 @@ export class Motion extends AgendaBaseModel {
public recommendation_extension: string;
public tags_id: number[];
public attachments_id: number[];
- public polls: Object[];
+ public polls: MotionPoll[];
public agenda_item_id: number;
public log_messages: MotionLog[];
public weight: number;
diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html
index 07823476b..66d2ef210 100644
--- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html
+++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html
@@ -304,6 +304,16 @@
Origin
{{ motion.origin }}
+
+
+
+
+
+
+ poll
+ Create poll
+
+
@@ -601,16 +611,28 @@
-
+
none
-
+
inline
-
+
outside
@@ -618,12 +640,36 @@
- Original version
- Changed version
- Diff version
- Final version
+
+ Original version
+
+
+ Changed version
+
+
+ Diff version
+
+
+ Final version
+
diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss
index 2a4ebc4ae..761439dd3 100644
--- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss
+++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss
@@ -259,3 +259,7 @@ span {
opacity: 1;
}
}
+
+.main-nav-color {
+ color: rgba(0, 0, 0, 0.54);
+}
diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts
index 303ad1680..0c6c0e1f3 100644
--- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts
+++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts
@@ -921,4 +921,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
public opCanEdit(): boolean {
return this.op.hasPerms('motions.can_manage', 'motions.can_manage_metadata');
}
+
+ public async createPoll(): Promise {
+ await this.repo.createPoll(this.motion);
+ }
}
diff --git a/client/src/app/site/motions/components/motion-poll/motion-poll-dialog.component.html b/client/src/app/site/motions/components/motion-poll/motion-poll-dialog.component.html
new file mode 100644
index 000000000..8d031a37f
--- /dev/null
+++ b/client/src/app/site/motions/components/motion-poll/motion-poll-dialog.component.html
@@ -0,0 +1,20 @@
+Vote form
+
+ Special values :
+ -1 =
+ majority
+ -2 =
+ undocumented
+
+
+
+ {{ getLabel(key) | translate }}
+
+
+
+
+
+ Cancel
+ Save
+
+
diff --git a/client/src/app/site/motions/components/motion-poll/motion-poll-dialog.component.scss b/client/src/app/site/motions/components/motion-poll/motion-poll-dialog.component.scss
new file mode 100644
index 000000000..22e1c860e
--- /dev/null
+++ b/client/src/app/site/motions/components/motion-poll/motion-poll-dialog.component.scss
@@ -0,0 +1,14 @@
+.submit-buttons {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.meta-text {
+ font-style: italic;
+ margin-left: 10px;
+ margin-right: 10px;
+ mat-chip {
+ margin-left: 5px;
+ margin-right: 2px;
+ }
+}
diff --git a/client/src/app/site/motions/components/motion-poll/motion-poll-dialog.component.ts b/client/src/app/site/motions/components/motion-poll/motion-poll-dialog.component.ts
new file mode 100644
index 000000000..43063b321
--- /dev/null
+++ b/client/src/app/site/motions/components/motion-poll/motion-poll-dialog.component.ts
@@ -0,0 +1,76 @@
+import { Component, Inject } from '@angular/core';
+import { MatDialogRef, MAT_DIALOG_DATA, MatSnackBar } from '@angular/material';
+import { TranslateService } from '@ngx-translate/core';
+
+import { MotionPoll } from 'app/shared/models/motions/motion-poll';
+import { MotionPollService, CalculablePollKey } from '../../services/motion-poll.service';
+
+/**
+ * A dialog for updating the values of a poll.
+ */
+@Component({
+ selector: 'os-motion-poll-dialog',
+ templateUrl: './motion-poll-dialog.component.html',
+ styleUrls: ['./motion-poll-dialog.component.scss']
+})
+export class MotionPollDialogComponent {
+ /**
+ * List of accepted special non-numerical values from {@link PollService}
+ */
+ public specialValues: [number, string][];
+
+ /**
+ * Array of vote entries in this component
+ */
+ public pollKeys: CalculablePollKey[];
+
+ /**
+ * Constructor. Retrieves necessary metadata from the pollService,
+ * injects the poll itself
+ */
+ public constructor(
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: MotionPoll,
+ private matSnackBar: MatSnackBar,
+ private translate: TranslateService,
+ private pollService: MotionPollService
+ ) {
+ this.pollKeys = this.pollService.pollValues;
+ this.specialValues = this.pollService.specialPollVotes;
+ }
+
+ /**
+ * Close the dialog, submitting nothing. Triggered by the cancel button and
+ * default angular cancelling behavior
+ */
+ public cancel(): void {
+ this.dialogRef.close();
+ }
+
+ /**
+ * validates if 'yes', 'no' and 'abstain' have values, submits and closes
+ * the dialog if successfull, else displays an error popup.
+ * TODO better validation
+ */
+ public submit(): void {
+ if (this.data.yes === undefined || this.data.no === undefined || this.data.abstain === undefined) {
+ this.matSnackBar.open(
+ this.translate.instant('Please fill in all required values'),
+ this.translate.instant('OK'),
+ {
+ duration: 1000
+ }
+ );
+ } else {
+ this.dialogRef.close(this.data);
+ }
+ }
+
+ /**
+ * Returns a label for a poll option
+ * @param key poll option to be labeled
+ */
+ public getLabel(key: CalculablePollKey): string {
+ return this.pollService.getLabel(key);
+ }
+}
diff --git a/client/src/app/site/motions/components/motion-poll/motion-poll.component.html b/client/src/app/site/motions/components/motion-poll/motion-poll.component.html
new file mode 100644
index 000000000..2cd150512
--- /dev/null
+++ b/client/src/app/site/motions/components/motion-poll/motion-poll.component.html
@@ -0,0 +1,67 @@
+
+
+ Poll {{ pollIndex + 1 }}
+
+
+
+
+
+
{{ getIcon(key) }}
+
+
+ {{ getLabel(key) }} : {{ getNumber(key) }}
+ ({{ getPercent(key) }}%)
+
+
+
+
+
+
+
+
+
+
+
Quorum not calculable.
+
+
+
+ thumb_down
+ thumb_up
+
+
+ {{ getQuorumLabel() }}
+ ({{ yesQuorum }})
+
+
+ reached.
+ not reached.
+
+ — No quorum calculated
+
+
+
+
+
+
+
+ edit
+
+
+ local_printshop
+
+
+ delete
+
+
+
+
+
+
+ {{ option.display_name }}
+
+
diff --git a/client/src/app/site/motions/components/motion-poll/motion-poll.component.scss b/client/src/app/site/motions/components/motion-poll/motion-poll.component.scss
new file mode 100644
index 000000000..7cc49c23a
--- /dev/null
+++ b/client/src/app/site/motions/components/motion-poll/motion-poll.component.scss
@@ -0,0 +1,60 @@
+::ng-deep .progress-green {
+ .mat-progress-bar-fill::after {
+ background-color: #4caf50;
+ }
+ .mat-progress-bar-buffer {
+ background-color: #d5ecd5;
+ }
+}
+
+::ng-deep .progress-red {
+ .mat-progress-bar-fill::after {
+ background-color: #f44336;
+ }
+ .mat-progress-bar-buffer {
+ background-color: #fcd2cf;
+ }
+}
+
+::ng-deep .progress-yellow {
+ .mat-progress-bar-fill::after {
+ background-color: #ffc107;
+ }
+ .mat-progress-bar-buffer {
+ background-color: #fff0c4;
+ }
+}
+
+.poll-result {
+ .poll-progress-bar {
+ height: 5px;
+ width: 100%;
+ .mat-progress-bar {
+ height: 100%;
+ width: 100%;
+ }
+ }
+ .poll-progress {
+ display: flex;
+ margin-bottom: 15px;
+ margin-top: 15px;
+ mat-icon {
+ min-width: 40px;
+ margin-right: 5px;
+ }
+ .progress-container {
+ width: 85%;
+ }
+ }
+}
+.main-nav-color {
+ color: rgba(0, 0, 0, 0.54);
+}
+
+.poll-quorum-line {
+ display: flex;
+ vertical-align: bottom;
+ .mat-button {
+ padding: 1px;
+ }
+}
diff --git a/client/src/app/site/motions/components/motion-poll/motion-poll.component.spec.ts b/client/src/app/site/motions/components/motion-poll/motion-poll.component.spec.ts
new file mode 100644
index 000000000..fc0ee30c5
--- /dev/null
+++ b/client/src/app/site/motions/components/motion-poll/motion-poll.component.spec.ts
@@ -0,0 +1,27 @@
+// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+// import { MotionPollComponent } from './motion-poll.component';
+// import { E2EImportsModule } from 'e2e-imports.module';
+// import { MetaTextBlockComponent } from '../meta-text-block/meta-text-block.component';
+
+describe('MotionPollComponent', () => {
+ // TODO testing fails if personalNotesModule (also having the MetaTextBlockComponent)
+ // is running its' test at the same time. One of the two tests fail, but run fine if tested
+ // separately; so this is some async duplication stuff
+ // let component: MotionPollComponent;
+ // let fixture: ComponentFixture;
+ // beforeEach(async(() => {
+ // TestBed.configureTestingModule({
+ // imports: [E2EImportsModule],
+ // declarations: [MetaTextBlockComponent, MotionPollComponent]
+ // }).compileComponents();
+ // }));
+ // beforeEach(() => {
+ // fixture = TestBed.createComponent(MotionPollComponent);
+ // component = fixture.componentInstance;
+ // fixture.detectChanges();
+ // });
+ // it('should create', () => {
+ // expect(component).toBeTruthy();
+ // });
+});
diff --git a/client/src/app/site/motions/components/motion-poll/motion-poll.component.ts b/client/src/app/site/motions/components/motion-poll/motion-poll.component.ts
new file mode 100644
index 000000000..594551630
--- /dev/null
+++ b/client/src/app/site/motions/components/motion-poll/motion-poll.component.ts
@@ -0,0 +1,248 @@
+import { Component, OnInit, Input } from '@angular/core';
+import { MatDialog } from '@angular/material';
+import { TranslateService } from '@ngx-translate/core';
+
+import { CalculablePollKey } from 'app/core/poll.service';
+import { ConstantsService } from 'app/core/services/constants.service';
+import { LocalPermissionsService } from '../../services/local-permissions.service';
+import { MotionPoll } from 'app/shared/models/motions/motion-poll';
+import { MotionPollService } from '../../services/motion-poll.service';
+import { MotionPollDialogComponent } from './motion-poll-dialog.component';
+import { MotionRepositoryService } from '../../services/motion-repository.service';
+import { PromptService } from 'app/core/services/prompt.service';
+
+/**
+ * A component used to display and edit polls of a motion.
+ */
+@Component({
+ selector: 'os-motion-poll',
+ templateUrl: './motion-poll.component.html',
+ styleUrls: ['./motion-poll.component.scss']
+})
+export class MotionPollComponent implements OnInit {
+ /**
+ * A representation of all values of the current poll.
+ */
+ public pollValues: CalculablePollKey[];
+
+ /**
+ * The motion poll as coming from the server. Needs conversion of strings to numbers first
+ * (see {@link ngOnInit})
+ */
+ @Input()
+ public rawPoll: any;
+
+ /**
+ * (optional) number of poll iffor dispaly purpose
+ */
+ @Input()
+ public pollIndex: number;
+
+ /**
+ * The current poll
+ */
+ public poll: MotionPoll;
+
+ /**
+ * The current choice for calulating a Quorum
+ */
+ public majorityChoice: string;
+
+ /**
+ * The constants available for calulating a quorum
+ */
+ public majorityChoices: { display_name: string; value: string }[] = [];
+
+ /**
+ * Getter for calulating the current quorum via pollService
+ *
+ * @returns the number required to be reached for a vote to match the quorum
+ */
+ public get yesQuorum(): number {
+ return this.pollService.calculateQuorum(this.poll, this.majorityChoice);
+ }
+
+ /**
+ * Indicates if the poll can be expressed with percentages and calculated quorums or is abstract
+ *
+ * @returns true if abstract (no calculations possible)
+ */
+ public get abstractPoll(): boolean {
+ return this.pollService.getBaseAmount(this.poll) <= 0;
+ }
+
+ /**
+ * Constructor. Subscribes to the constants and settings for motion polls
+ *
+ * @param dialog Dialog Service for entering poll data
+ * @param pollService MotionPollService
+ * @param motionRepo Subscribing to the motion to update poll from the server
+ * @param constants ConstantsService
+ * @param config ConfigService
+ * @param translate TranslateService
+ * @param perms LocalPermissionService
+ */
+ public constructor(
+ public dialog: MatDialog,
+ private pollService: MotionPollService,
+ private motionRepo: MotionRepositoryService,
+ private constants: ConstantsService,
+ private translate: TranslateService,
+ private promptService: PromptService,
+ public perms: LocalPermissionsService
+ ) {
+ this.pollValues = this.pollService.pollValues;
+ this.majorityChoice = this.pollService.defaultMajorityMethod;
+ this.subscribeMajorityChoices();
+ }
+
+ /**
+ * Subscribes to updates of itself
+ */
+ public ngOnInit(): void {
+ this.poll = new MotionPoll(this.rawPoll);
+ this.motionRepo.getViewModelObservable(this.poll.motion_id).subscribe(viewmotion => {
+ const updatePoll = viewmotion.motion.polls.find(poll => poll.id === this.poll.id);
+ if (updatePoll) {
+ this.poll = new MotionPoll(updatePoll);
+ }
+ });
+ }
+
+ /**
+ * Sends a delete request for this poll after a confirmation dialog has been accepted.
+ */
+ public async deletePoll(): Promise {
+ const content = this.translate.instant('The current poll will be deleted!');
+ if (await this.promptService.open('Are you sure?', content)) {
+ this.motionRepo.deletePoll(this.poll);
+ }
+ }
+
+ /**
+ * @returns the label for a poll option
+ */
+ public getLabel(key: CalculablePollKey): string {
+ return this.pollService.getLabel(key);
+ }
+
+ /**
+ * @returns the icon's name for the icon of a poll option
+ */
+ public getIcon(key: CalculablePollKey): string {
+ return this.pollService.getIcon(key);
+ }
+
+ /**
+ * Get the progressbar class for a decision key
+ *
+ * @param key
+ *
+ * @returns a css class designing a progress bar in a color, or an empty string
+ */
+ public getProgressBarColor(key: CalculablePollKey): string {
+ switch (key) {
+ case 'yes':
+ return 'progress-green';
+ case 'no':
+ return 'progress-red';
+ case 'abstain':
+ return 'progress-yellow';
+ default:
+ return '';
+ }
+ }
+
+ /**
+ * Transform special case numbers into their strings
+ * @param key
+ *
+ * @returns the number if positive or the special values' translated string
+ */
+ public getNumber(key: CalculablePollKey): number | string {
+ if (this.poll[key] >= 0) {
+ return this.poll[key];
+ } else {
+ return this.translate.instant(this.pollService.getSpecialLabel(this.poll[key]));
+ }
+ }
+
+ /**
+ * Check if the value cannot be expressed in percentages.
+ * @param key
+ * @returns if the value cannot be calculated
+ */
+ public isAbstractValue(key: CalculablePollKey): boolean {
+ return this.pollService.isAbstractValue(this.poll, key);
+ }
+
+ /**
+ * Calculates the percentages of a value. See {@link MotionPollService.getPercent}
+ *
+ * @param value
+ * @returns a number with two digits, 100.00 representing 100 percent. May be null if the value cannot be calulated
+ */
+ public getPercent(value: CalculablePollKey): number {
+ return this.pollService.calculatePercentage(this.poll, value);
+ }
+
+ /**
+ * TODO: not implemented. Print the buttons
+ */
+ public printBallots(): void {
+ this.pollService.printBallots();
+ }
+
+ /**
+ * Triggers the 'edit poll' dialog'
+ */
+ public editPoll(): void {
+ const dialogRef = this.dialog.open(MotionPollDialogComponent, {
+ data: { ...this.poll },
+ maxHeight: '90vh',
+ minWidth: '250px'
+ });
+ dialogRef.afterClosed().subscribe(result => {
+ if (result) {
+ this.motionRepo.updatePoll(result);
+ // TODO error handling
+ }
+ });
+ }
+
+ /**
+ * Indicates if the necessary quorum is reached by the 'yes' votes
+ *
+ * @returns true if the quorum is reached
+ */
+ public get quorumYesReached(): boolean {
+ return this.poll.yes >= this.yesQuorum;
+ }
+
+ /**
+ * Subscribe to the available majority choices as given in the server-side constants
+ */
+ private subscribeMajorityChoices(): void {
+ this.constants.get('OpenSlidesConfigVariables').subscribe(constants => {
+ const motionconst = constants.find(c => c.name === 'Motions');
+ if (motionconst) {
+ const ballotConst = motionconst.subgroups.find(s => s.name === 'Voting and ballot papers');
+ if (ballotConst) {
+ const methods = ballotConst.items.find(b => b.key === 'motions_poll_default_majority_method');
+ this.majorityChoices = methods.choices;
+ }
+ }
+ });
+ }
+
+ /**
+ * Get a label for the quorum selection button. See {@link majorityChoices}
+ * for possible values
+ *
+ * @returns a string from the angular material-icon font, or an empty string
+ */
+ public getQuorumLabel(): string {
+ const choice = this.majorityChoices.find(ch => ch.value === this.majorityChoice);
+ return choice ? choice.display_name : '';
+ }
+}
diff --git a/client/src/app/site/motions/motions.module.ts b/client/src/app/site/motions/motions.module.ts
index aa6940204..6c85b2824 100644
--- a/client/src/app/site/motions/motions.module.ts
+++ b/client/src/app/site/motions/motions.module.ts
@@ -20,6 +20,8 @@ import { MotionBlockListComponent } from './components/motion-block-list/motion-
import { MotionBlockDetailComponent } from './components/motion-block-detail/motion-block-detail.component';
import { MotionImportListComponent } from './components/motion-import-list/motion-import-list.component';
import { ManageSubmittersComponent } from './components/manage-submitters/manage-submitters.component';
+import { MotionPollComponent } from './components/motion-poll/motion-poll.component';
+import { MotionPollDialogComponent } from './components/motion-poll/motion-poll-dialog.component';
@NgModule({
imports: [CommonModule, MotionsRoutingModule, SharedModule],
@@ -40,7 +42,9 @@ import { ManageSubmittersComponent } from './components/manage-submitters/manage
MotionBlockListComponent,
MotionBlockDetailComponent,
MotionImportListComponent,
- ManageSubmittersComponent
+ ManageSubmittersComponent,
+ MotionPollComponent,
+ MotionPollDialogComponent
],
entryComponents: [
MotionChangeRecommendationComponent,
@@ -49,7 +53,8 @@ import { ManageSubmittersComponent } from './components/manage-submitters/manage
MotionCommentSectionListComponent,
MetaTextBlockComponent,
PersonalNoteComponent,
- ManageSubmittersComponent
+ ManageSubmittersComponent,
+ MotionPollDialogComponent
]
})
export class MotionsModule {}
diff --git a/client/src/app/site/motions/services/local-permissions.service.ts b/client/src/app/site/motions/services/local-permissions.service.ts
index 1cb655b11..36d1e1397 100644
--- a/client/src/app/site/motions/services/local-permissions.service.ts
+++ b/client/src/app/site/motions/services/local-permissions.service.ts
@@ -22,6 +22,8 @@ export class LocalPermissionsService {
*
* actions might be:
* - support
+ * - unsupport
+ * - createpoll
*
* @param action the action the user tries to perform
*/
@@ -38,6 +40,8 @@ export class LocalPermissionsService {
);
case 'unsupport':
return motion.state.allow_support && motion.supporters.indexOf(this.operator.user) !== -1;
+ case 'createpoll':
+ return this.operator.hasPerms('motions.can_manage') && motion.state.allow_create_poll;
default:
return false;
}
diff --git a/client/src/app/site/motions/services/motion-poll.service.spec.ts b/client/src/app/site/motions/services/motion-poll.service.spec.ts
new file mode 100644
index 000000000..53e999f3f
--- /dev/null
+++ b/client/src/app/site/motions/services/motion-poll.service.spec.ts
@@ -0,0 +1,12 @@
+import { TestBed } from '@angular/core/testing';
+
+import { MotionPollService } from './motion-poll.service';
+
+describe('PollService', () => {
+ beforeEach(() => TestBed.configureTestingModule({}));
+
+ it('should be created', () => {
+ const service: MotionPollService = TestBed.get(MotionPollService);
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/client/src/app/site/motions/services/motion-poll.service.ts b/client/src/app/site/motions/services/motion-poll.service.ts
new file mode 100644
index 000000000..1704329c9
--- /dev/null
+++ b/client/src/app/site/motions/services/motion-poll.service.ts
@@ -0,0 +1,181 @@
+import { Injectable } from '@angular/core';
+
+import { ConfigService } from 'app/core/services/config.service';
+import { MotionPoll } from 'app/shared/models/motions/motion-poll';
+import { PollService } from 'app/core/poll.service';
+
+export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain';
+
+/**
+ * Service class for motion polls.
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class MotionPollService extends PollService {
+ /**
+ * list of poll keys that are numbers and can be part of a quorum calculation
+ */
+ public pollValues: CalculablePollKey[] = ['yes', 'no', 'abstain', 'votesvalid', 'votesinvalid', 'votescast'];
+
+ /**
+ * Constructor. Subscribes to the configuration values needed
+ * @param config ConfigService
+ */
+ public constructor(config: ConfigService) {
+ super();
+ config.get('motions_poll_100_percent_base').subscribe(base => (this.percentBase = base));
+ config.get('motions_poll_default_majority_method').subscribe(method => (this.defaultMajorityMethod = method));
+ }
+
+ /**
+ * Calculates the percentage the given key reaches.
+ *
+ * @param poll
+ * @param key
+ * @returns a percentage number with two digits, null if the value cannot be calculated (consider 0 !== null)
+ */
+ public calculatePercentage(poll: MotionPoll, key: CalculablePollKey): number {
+ const baseNumber = this.getBaseAmount(poll);
+ if (!baseNumber) {
+ return null;
+ }
+ switch (key) {
+ case 'abstain':
+ if (this.percentBase === 'YES_NO') {
+ return null;
+ }
+ break;
+ case 'votesinvalid':
+ if (this.percentBase !== 'CAST') {
+ return null;
+ }
+ break;
+ case 'votesvalid':
+ if (!['CAST', 'VALID'].includes(this.percentBase)) {
+ return null;
+ }
+ break;
+ case 'votescast':
+ if (this.percentBase !== 'CAST') {
+ return null;
+ }
+ }
+ return Math.round(((poll[key] * 100) / baseNumber) * 100) / 100;
+ }
+
+ /**
+ * Gets the number representing 100 percent for a given MotionPoll, depending
+ * on the configuration and the votes given.
+ *
+ * @param poll
+ * @returns the positive number representing 100 percent of the poll, 0 if
+ * the base cannot be calculated
+ */
+ public getBaseAmount(poll: MotionPoll): number {
+ if (!poll) {
+ return 0;
+ }
+ switch (this.percentBase) {
+ case 'CAST':
+ if (!poll.votescast) {
+ return 0;
+ }
+ if (poll.votesinvalid < 0) {
+ return 0;
+ }
+ return poll.votescast;
+ case 'VALID':
+ if (poll.yes < 0 || poll.no < 0 || poll.abstain < 0) {
+ return 0;
+ }
+ return poll.votesvalid ? poll.votesvalid : 0;
+ case 'YES_NO_ABSTAIN':
+ if (poll.yes < 0 || poll.no < 0 || poll.abstain < 0) {
+ return 0;
+ }
+ return poll.yes + poll.no + poll.abstain;
+ case 'YES_NO':
+ if (poll.yes < 0 || poll.no < 0 || poll.abstain === -1) {
+ // It is not allowed to set 'Abstain' to 'majority' but exclude it from calculation.
+ // Setting 'Abstain' to 'undocumented' is possible, of course.
+ return 0;
+ }
+ return poll.yes + poll.no;
+ }
+ }
+
+ /**
+ * Calculates which number is needed for the quorum to be surpassed
+ * TODO: Methods still hard coded to mirror the server's.
+ *
+ * @param poll
+ * @param method (optional) majority calculation method. If none is given,
+ * the default as set in the config will be used.
+ * @returns the first integer number larger than the required majority,
+ * undefined if a quorum cannot be calculated.
+ */
+ public calculateQuorum(poll: MotionPoll, method?: string): number {
+ if (!method) {
+ method = this.defaultMajorityMethod;
+ }
+ const baseNumber = this.getBaseAmount(poll);
+ if (!baseNumber) {
+ return undefined;
+ }
+ let result: number;
+ switch (method) {
+ case 'simple_majority':
+ result = baseNumber * 0.5;
+ break;
+ case 'two-thirds_majority':
+ result = (baseNumber / 3) * 2;
+ break;
+ case 'three-quarters_majority':
+ result = (baseNumber / 4) * 3;
+ break;
+ default:
+ return undefined;
+ }
+ // rounding up, or if a integer was hit, adding one.
+ if (result % 1 !== 0) {
+ return Math.ceil(result);
+ } else {
+ return result + 1;
+ }
+ }
+
+ /**
+ * Determines if a value is abstract (percentages cannot be calculated)
+ *
+ * @param poll
+ * @param value
+ * @returns true if the percentages should not be calculated
+ */
+ public isAbstractValue(poll: MotionPoll, value: CalculablePollKey): boolean {
+ if (this.getBaseAmount(poll) === 0) {
+ return true;
+ }
+ switch (this.percentBase) {
+ case 'YES_NO':
+ if (['votescast', 'votesinvalid', 'votesvalid', 'abstain'].includes(value)) {
+ return true;
+ }
+ break;
+ case 'YES_NO_ABSTAIN':
+ if (['votescast', 'votesinvalid', 'votesvalid'].includes(value)) {
+ return true;
+ }
+ break;
+ case 'VALID':
+ if (['votesinvalid', 'votescast'].includes(value)) {
+ return true;
+ }
+ break;
+ }
+ if (poll[value] < 0) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts
index 643ebdac4..98ced21e6 100644
--- a/client/src/app/site/motions/services/motion-repository.service.ts
+++ b/client/src/app/site/motions/services/motion-repository.service.ts
@@ -29,6 +29,7 @@ import { CreateMotion } from '../models/create-motion';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { ConfigService } from '../../../core/services/config.service';
+import { MotionPoll } from 'app/shared/models/motions/motion-poll';
/**
* Repository Services for motions (and potentially categories)
@@ -632,4 +633,44 @@ export class MotionRepositoryService extends BaseRepository
duplicates.forEach(item => viewMotions.push(this.createViewModel(item)));
return viewMotions;
}
+
+ /**
+ * Sends a request to the server, creating a new poll for the motion
+ */
+ public async createPoll(motion: ViewMotion): Promise {
+ const url = '/rest/motions/motion/' + motion.id + '/create_poll/';
+ await this.httpService.post(url);
+ }
+
+ /**
+ * Sends an update request for a poll.
+ *
+ * @param poll
+ */
+ public async updatePoll(poll: MotionPoll): Promise {
+ const url = '/rest/motions/motion-poll/' + poll.id + '/';
+ const data = {
+ motion_id: poll.motion_id,
+ id: poll.id,
+ votescast: poll.votescast,
+ votesvalid: poll.votesvalid,
+ votesinvalid: poll.votesinvalid,
+ votes: {
+ Yes: poll.yes,
+ No: poll.no,
+ Abstain: poll.abstain
+ }
+ };
+ await this.httpService.put(url, data);
+ }
+
+ /**
+ * Sends a haap request to delete the given poll
+ *
+ * @param poll
+ */
+ public async deletePoll(poll: MotionPoll): Promise {
+ const url = '/rest/motions/motion-poll/' + poll.id + '/';
+ await this.httpService.delete(url);
+ }
}