Merge pull request #4088 from MaximilianKrambach/motionpoll

motion polls
This commit is contained in:
Sean 2019-01-17 15:40:34 +01:00 committed by GitHub
commit 19a3fcebf3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 999 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -304,6 +304,16 @@
<h4 translate>Origin</h4>
{{ motion.origin }}
</div>
<!-- motion polls -->
<div *ngIf="!editMotion">
<os-motion-poll *ngFor="let poll of motion.motion.polls; let i = index" [rawPoll]="poll" [pollIndex]="i">
</os-motion-poll>
<button mat-button *ngIf="perms.isAllowed('createpoll', motion)" (click)="createPoll()">
<mat-icon class="main-nav-color">poll</mat-icon>
<span translate>Create poll</span>
</button>
</div>
</div>
</ng-template>
@ -601,16 +611,28 @@
<!-- Line number Menu -->
<mat-menu #lineNumberingMenu="matMenu">
<div *ngIf="motion">
<button mat-menu-item translate (click)="setLineNumberingMode(LineNumberingMode.None)"
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.None }">
<button
mat-menu-item
translate
(click)="setLineNumberingMode(LineNumberingMode.None)"
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.None }"
>
none
</button>
<button mat-menu-item translate (click)="setLineNumberingMode(LineNumberingMode.Inside)"
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Inside }">
<button
mat-menu-item
translate
(click)="setLineNumberingMode(LineNumberingMode.Inside)"
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Inside }"
>
inline
</button>
<button mat-menu-item translate (click)="setLineNumberingMode(LineNumberingMode.Outside)"
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Outside }">
<button
mat-menu-item
translate
(click)="setLineNumberingMode(LineNumberingMode.Outside)"
[ngClass]="{ selected: motion.lnMode === LineNumberingMode.Outside }"
>
outside
</button>
</div>
@ -618,12 +640,36 @@
<!-- Diff View Menu -->
<mat-menu #changeRecoMenu="matMenu">
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Original)"
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Original }">Original version</button>
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Changed)"
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Changed }">Changed version</button>
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Diff)"
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Diff }">Diff version</button>
<button mat-menu-item translate (click)="setChangeRecoMode(ChangeRecoMode.Final)"
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Final }">Final version</button>
<button
mat-menu-item
translate
(click)="setChangeRecoMode(ChangeRecoMode.Original)"
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Original }"
>
Original version
</button>
<button
mat-menu-item
translate
(click)="setChangeRecoMode(ChangeRecoMode.Changed)"
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Changed }"
>
Changed version
</button>
<button
mat-menu-item
translate
(click)="setChangeRecoMode(ChangeRecoMode.Diff)"
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Diff }"
>
Diff version
</button>
<button
mat-menu-item
translate
(click)="setChangeRecoMode(ChangeRecoMode.Final)"
[ngClass]="{ selected: motion?.crMode === ChangeRecoMode.Final }"
>
Final version
</button>
</mat-menu>

View File

@ -259,3 +259,7 @@ span {
opacity: 1;
}
}
.main-nav-color {
color: rgba(0, 0, 0, 0.54);
}

View File

@ -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<void> {
await this.repo.createPoll(this.motion);
}
}

View File

@ -0,0 +1,20 @@
<h2><span translate>Vote form</span></h2>
<div class="meta-text">
<span translate>Special values</span>:<br />
<mat-chip>-1</mat-chip>&nbsp;=&nbsp;
<span translate>majority</span><br />
<mat-chip color="accent">-2</mat-chip>&nbsp;=&nbsp;
<span translate>undocumented</span>
</div>
<div *ngFor="let key of pollKeys">
<mat-form-field>
<mat-label translate>{{ getLabel(key) | translate }}</mat-label>
<input type="number" matInput [(ngModel)]="data[key]" />
<!-- TODO mark required fields -->
</mat-form-field>
</div>
<div class="submit-buttons">
<button mat-button (click)="cancel()" translate>Cancel</button>
<button mat-button (click)="submit()" translate>Save</button>
</div>

View File

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

View File

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

View File

@ -0,0 +1,67 @@
<os-meta-text-block showActionRow="true">
<ng-container class="meta-text-block-title">
<span translate>Poll</span> <span *ngIf="pollIndex">&nbsp;{{ pollIndex + 1 }}</span>
</ng-container>
<ng-container class="meta-text-block-content">
<div *ngIf="poll.has_votes" class="on-transition-fade poll-result">
<div *ngFor="let key of pollValues">
<div class="poll-progress on-transition-fade" *ngIf="poll[key] !== undefined">
<mat-icon class="main-nav-color" matTooltip="{{ getLabel(key) | translate }}"> {{ getIcon(key) }} </mat-icon>
<div class="progress-container">
<div>
<span translate>{{ getLabel(key) }}</span>:&nbsp;{{ getNumber(key) }}
<span *ngIf="!isAbstractValue(key)">({{ getPercent(key) }}%)</span>
</div>
<div *ngIf="!isAbstractValue(key)" class="poll-progress-bar">
<mat-progress-bar
mode="determinate"
[value]="getPercent(key)"
[ngClass]="getProgressBarColor(key)"
>
</mat-progress-bar>
</div>
</div>
</div>
<hr *ngIf="key ==='abstain'" flex />
</div>
<!-- quorum -->
<div *ngIf="abstractPoll"><span translate>Quorum not calculable.</span></div>
<div class="poll-quorum-line" *ngIf="!abstractPoll">
<span>
<span *ngIf="yesQuorum">
<mat-icon color="warn" *ngIf="!quorumYesReached"> thumb_down </mat-icon>
<mat-icon color="primary" *ngIf="quorumYesReached"> thumb_up </mat-icon>
</span>
<button mat-button [matMenuTriggerFor]="majorityMenu">
&nbsp;<span translate>{{ getQuorumLabel() }}</span>
&nbsp;<span *ngIf="majorityChoice !== 'disabled'">({{ yesQuorum }})</span>
</button>
<span *ngIf="majorityChoice !== 'disabled'">
<span *ngIf="quorumYesReached" translate> reached.</span>
<span *ngIf="!quorumYesReached" translate> not reached.</span>
</span>
<span *ngIf="majorityChoice === 'disabled'"
>&nbsp;&mdash;&nbsp; <span translate>No quorum calculated</span>
</span>
</span>
</div>
</div>
</ng-container>
<ng-container class="meta-text-block-action-row" *osPerms="'motions.can_manage'">
<button mat-icon-button class="main-nav-color" matTooltip="{{ 'Edit poll' | translate }}" (click)="editPoll()">
<mat-icon inline>edit</mat-icon>
</button>
<button mat-icon-button class="main-nav-color" matTooltip="{{ 'Print ballots' | translate }}" (click)="printBallots()">
<mat-icon inline>local_printshop</mat-icon>
</button>
<button mat-icon-button class="main-nav-color" matTooltip="{{ 'Delete poll' | translate }}" (click)="deletePoll()">
<mat-icon inline>delete</mat-icon>
</button>
</ng-container>
</os-meta-text-block>
<mat-menu #majorityMenu="matMenu">
<button mat-menu-item *ngFor="let option of majorityChoices" (click)="majorityChoice = option.value">
<span translate>{{ option.display_name }}</span>
</button>
</mat-menu>

View File

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

View File

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

View File

@ -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<void> {
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 : '';
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ViewMotion, Motion>
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<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 haap 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);
}
}