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 }} + + +
+ + + +
@@ -601,16 +611,28 @@
- - -
@@ -618,12 +640,36 @@ - - - - + + + + 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 }} + + + +
+
+ + +
+ 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 + + + + reached. + not reached. + +  —  No quorum calculated + + +
+
+
+ + + + + +
+ + + + 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); + } }