Enhance voting

- cleaned up a lot of code
- removed required majotiry from forms
- renamed verbose "Majority method" to "Required majority"
- poll-progress-bar only counts present user
- enhanced motion poll tile chart layout
- removed PercentBase.Votes
- added pollPercentBase pipe
- Show the voting percent next to chart in motion detail
- change the head bar to "Voting is open"
  and "Ballot is open"
- merged the voting configs to their corresponding config-categories
- re-add ballot paper configs
- Add "more" button to motion polls
- Adjusted the motion results table
  - Hide entries without information
  - Show icons for Y N A
  - Show percentage next to Y N A
This commit is contained in:
Sean 2020-02-04 16:25:42 +01:00 committed by FinnStutzenstein
parent 6c1317e25f
commit 524a97cdcc
22 changed files with 413 additions and 145 deletions

View File

@ -172,6 +172,7 @@ _('Number of all delegates');
_('Number of all participants'); _('Number of all participants');
_('Use the following custom number'); _('Use the following custom number');
_('Custom number of ballot papers'); _('Custom number of ballot papers');
_('Voting');
// subgroup PDF export // subgroup PDF export
_('PDF export'); _('PDF export');
_('Title for PDF documents of motions'); _('Title for PDF documents of motions');
@ -278,8 +279,8 @@ _('Next states');
// other translations // other translations
_('Searching for candidates'); _('Searching for candidates');
_('Voting');
_('Finished'); _('Finished');
_('In the election process');
// ** Users ** // ** Users **
// permission strings (see models.py of each Django app) // permission strings (see models.py of each Django app)

View File

@ -74,9 +74,9 @@ export class VotingBannerService {
private getTextForPoll(poll: ViewBasePoll): string { private getTextForPoll(poll: ViewBasePoll): string {
return poll instanceof ViewMotionPoll return poll instanceof ViewMotionPoll
? `${this.translate.instant('Motion') + ' ' + poll.motion.getIdentifierOrTitle()}: ${this.translate.instant( ? `${this.translate.instant('Motion') + ' ' + poll.motion.getIdentifierOrTitle()}: ${this.translate.instant(
'Voting started' 'Voting is open'
)}!` )}`
: `${poll.getTitle()}: ${this.translate.instant('Ballots opened')}!`; : `${poll.getTitle()}: ${this.translate.instant('Ballot is open')}`;
} }
/** /**

View File

@ -27,7 +27,6 @@ export enum PercentBase {
YN = 'YN', YN = 'YN',
YNA = 'YNA', YNA = 'YNA',
Valid = 'valid', Valid = 'valid',
Votes = 'votes',
Cast = 'cast', Cast = 'cast',
Disabled = 'disabled' Disabled = 'disabled'
} }
@ -68,6 +67,10 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
return this.state === PollState.Published; return this.state === PollState.Published;
} }
public get isPercentBaseValidOrCast(): boolean {
return this.onehundred_percent_base === PercentBase.Valid || this.onehundred_percent_base === PercentBase.Cast;
}
/** /**
* If the state is finished. * If the state is finished.
*/ */

View File

@ -0,0 +1,8 @@
import { PollPercentBasePipe } from './poll-percent-base.pipe';
describe('PollPercentBasePipe', () => {
it('create an instance', () => {
const pipe = new PollPercentBasePipe();
expect(pipe).toBeTruthy();
});
});

View File

@ -0,0 +1,36 @@
import { Pipe, PipeTransform } from '@angular/core';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
/**
* Uses a number and a ViewPoll-object.
* Converts the number to the voting percent base using the
* given 100%-Base option in the poll object
*
* returns null if a percent calculation is not possible
* or the result is 0
*
* @example
* ```html
* <span> {{ voteYes | pollPercentBase: poll }} </span>
* ```
*/
@Pipe({
name: 'pollPercentBase'
})
export class PollPercentBasePipe implements PipeTransform {
private decimalPlaces = 3;
public transform(value: number, viewPoll: ViewBasePoll): string | null {
const totalByBase = viewPoll.getPercentBase();
if (totalByBase) {
const percentNumber = (value / totalByBase) * 100;
if (percentNumber > 0) {
const result = percentNumber % 1 === 0 ? percentNumber : percentNumber.toFixed(this.decimalPlaces);
return `(${result}%)`;
}
}
return null;
}
}

View File

@ -122,6 +122,7 @@ import { AssignmentPollDialogComponent } from 'app/site/assignments/components/a
import { ParsePollNumberPipe } from './pipes/parse-poll-number.pipe'; import { ParsePollNumberPipe } from './pipes/parse-poll-number.pipe';
import { ReversePipe } from './pipes/reverse.pipe'; import { ReversePipe } from './pipes/reverse.pipe';
import { PollKeyVerbosePipe } from './pipes/poll-key-verbose.pipe'; import { PollKeyVerbosePipe } from './pipes/poll-key-verbose.pipe';
import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
/** /**
* Share Module for all "dumb" components and pipes. * Share Module for all "dumb" components and pipes.
@ -285,7 +286,8 @@ import { PollKeyVerbosePipe } from './pipes/poll-key-verbose.pipe';
AssignmentPollDialogComponent, AssignmentPollDialogComponent,
ParsePollNumberPipe, ParsePollNumberPipe,
ReversePipe, ReversePipe,
PollKeyVerbosePipe PollKeyVerbosePipe,
PollPercentBasePipe
], ],
declarations: [ declarations: [
PermsDirective, PermsDirective,
@ -342,7 +344,8 @@ import { PollKeyVerbosePipe } from './pipes/poll-key-verbose.pipe';
AssignmentPollDialogComponent, AssignmentPollDialogComponent,
ParsePollNumberPipe, ParsePollNumberPipe,
ReversePipe, ReversePipe,
PollKeyVerbosePipe PollKeyVerbosePipe,
PollPercentBasePipe
], ],
providers: [ providers: [
{ {
@ -361,7 +364,8 @@ import { PollKeyVerbosePipe } from './pipes/poll-key-verbose.pipe';
LocalizedDatePipe, LocalizedDatePipe,
ParsePollNumberPipe, ParsePollNumberPipe,
ReversePipe, ReversePipe,
PollKeyVerbosePipe PollKeyVerbosePipe,
PollPercentBasePipe
], ],
entryComponents: [ entryComponents: [
SortBottomSheetComponent, SortBottomSheetComponent,

View File

@ -86,6 +86,10 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
return data; return data;
} }
public getPercentBase(): number {
return 0;
}
} }
export interface ViewAssignmentPoll extends AssignmentPoll { export interface ViewAssignmentPoll extends AssignmentPoll {

View File

@ -27,7 +27,7 @@ export const AssignmentPhases: { name: string; value: number; display_name: stri
{ {
name: 'PHASE_VOTING', name: 'PHASE_VOTING',
value: 1, value: 1,
display_name: 'Voting' display_name: 'In the election process'
}, },
{ {
name: 'PHASE_FINISHED', name: 'PHASE_FINISHED',

View File

@ -9,6 +9,19 @@ export class ViewMotionOption extends BaseViewModel<MotionOption> {
} }
public static COLLECTIONSTRING = MotionOption.COLLECTIONSTRING; public static COLLECTIONSTRING = MotionOption.COLLECTIONSTRING;
protected _collectionString = MotionOption.COLLECTIONSTRING; protected _collectionString = MotionOption.COLLECTIONSTRING;
public sumYN(): number {
let sum = 0;
sum += this.yes > 0 ? this.yes : 0;
sum += this.no > 0 ? this.no : 0;
return sum;
}
public sumYNA(): number {
let sum = this.sumYN();
sum += this.abstain > 0 ? this.abstain : 0;
return sum;
}
} }
interface TIMotionOptionRelations { interface TIMotionOptionRelations {

View File

@ -1,6 +1,6 @@
import { ChartData } from 'app/shared/components/charts/charts.component'; import { ChartData } from 'app/shared/components/charts/charts.component';
import { MotionPoll, MotionPollMethods } from 'app/shared/models/motions/motion-poll'; import { MotionPoll, MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { PollColor, PollState } from 'app/shared/models/poll/base-poll'; import { PercentBase, PollColor, PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
@ -16,14 +16,57 @@ export const MotionPollMethodsVerbose = {
YNA: 'Yes/No/Abstain' YNA: 'Yes/No/Abstain'
}; };
interface TableKey {
vote: string;
icon?: string;
canHide: boolean;
showPercent: boolean;
}
export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPollTitleInformation { export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPollTitleInformation {
public static COLLECTIONSTRING = MotionPoll.COLLECTIONSTRING; public static COLLECTIONSTRING = MotionPoll.COLLECTIONSTRING;
protected _collectionString = MotionPoll.COLLECTIONSTRING; protected _collectionString = MotionPoll.COLLECTIONSTRING;
public readonly pollClassType: 'assignment' | 'motion' = 'motion'; public readonly pollClassType: 'assignment' | 'motion' = 'motion';
private tableKeys = ['yes', 'no', 'abstain']; private tableKeys: TableKey[] = [
private voteKeys = ['votesvalid', 'votesinvalid', 'votescast']; {
vote: 'yes',
icon: 'thumb_up',
canHide: false,
showPercent: true
},
{
vote: 'no',
icon: 'thumb_down',
canHide: false,
showPercent: true
},
{
vote: 'abstain',
icon: 'trip_origin',
canHide: false,
showPercent: true
}
];
private voteKeys: TableKey[] = [
{
vote: 'votesvalid',
canHide: true,
showPercent: this.poll.isPercentBaseValidOrCast
},
{
vote: 'votesinvalid',
canHide: true,
showPercent: this.poll.isPercentBaseValidOrCast
},
{
vote: 'votescast',
canHide: true,
showPercent: this.poll.isPercentBaseValidOrCast
}
];
public get hasVotes(): boolean { public get hasVotes(): boolean {
return !!this.options[0].votes.length; return !!this.options[0].votes.length;
@ -38,9 +81,19 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
} }
public generateTableData(): PollData[] { public generateTableData(): PollData[] {
let tableData = this.options.flatMap(vote => this.tableKeys.map(key => ({ key: key, value: vote[key] }))); let tableData = this.options.flatMap(vote =>
tableData.push(...this.voteKeys.map(key => ({ key: key, value: this[key] }))); this.tableKeys.map(key => ({
tableData = tableData.map(entry => (entry.value >= 0 ? entry : { key: entry.key, value: null })); key: key.vote,
value: vote[key.vote],
canHide: key.canHide,
icon: key.icon,
showPercent: key.showPercent
}))
);
tableData.push(
...this.voteKeys.map(key => ({ key: key.vote, value: this[key.vote], showPercent: key.showPercent }))
);
tableData = tableData.filter(entry => entry.canHide === false || entry.value || entry.value !== -2);
return tableData; return tableData;
} }
@ -76,6 +129,10 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
return MotionPollMethodsVerbose[this.pollmethod]; return MotionPollMethodsVerbose[this.pollmethod];
} }
public anySpecialVotes(): boolean {
return this.options[0].yes < 0 || this.options[0].no < 0 || this.options[0].abstain < 0;
}
/** /**
* Override from base poll to skip started state in analog poll type * Override from base poll to skip started state in analog poll type
*/ */
@ -85,6 +142,40 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
} }
return super.getNextStates(); return super.getNextStates();
} }
public getPercentBase(): number {
const base: PercentBase = this.poll.onehundred_percent_base;
const options = this.options[0];
let totalByBase: number;
switch (base) {
case PercentBase.YN:
if (options.yes >= 0 && options.no >= 0) {
totalByBase = options.sumYN();
}
break;
case PercentBase.YNA:
if (options.yes >= 0 && options.no >= 0 && options.abstain >= 0) {
totalByBase = options.sumYNA();
}
break;
case PercentBase.Valid:
// auslagern
if (options.yes >= 0 && options.no >= 0 && options.abstain >= 0) {
totalByBase = this.poll.votesvalid;
}
break;
case PercentBase.Cast:
totalByBase = this.poll.votescast;
break;
case PercentBase.Disabled:
break;
default:
throw new Error('The given poll has no percent base: ' + this);
}
return totalByBase;
}
} }
export interface ViewMotionPoll extends MotionPoll { export interface ViewMotionPoll extends MotionPoll {

View File

@ -43,24 +43,37 @@
<th translate>Votes</th> <th translate>Votes</th>
</tr> </tr>
<tr *ngFor="let row of poll.tableData"> <tr *ngFor="let row of poll.tableData">
<td>{{ row.key | pollKeyVerbose | translate }}</td> <td>
<td class="result-cell-definition">{{ row.value }}</td> <os-icon-container *ngIf="row.icon" [icon]="row.icon">
{{ row.key | pollKeyVerbose | translate }}
</os-icon-container>
<span *ngIf="!row.icon">
{{ row.key | pollKeyVerbose | translate }}
</span>
</td>
<td class="result-cell-definition">
{{ row.value | parsePollNumber }}
<span *ngIf="row.showPercent">
{{ row.value | pollPercentBase: poll }}
</span>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Named table: only show if votes are present --> <!-- Named table: only show if votes are present -->
<div class="named-result-table" *ngIf="poll.type === 'named' && votesDataSource.data"> <div class="named-result-table" *ngIf="poll.type === 'named'">
<h3>{{ 'Single votes' | translate }}</h3> <h3>{{ 'Single votes' | translate }}</h3>
<div *ngIf="votesDataSource.data">
<mat-form-field> <mat-form-field>
<input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter" /> <input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter" />
</mat-form-field> </mat-form-field>
<mat-table [dataSource]="votesDataSource"> <mat-table [dataSource]="votesDataSource">
<ng-container matColumnDef="key" sticky> <ng-container matColumnDef="key" sticky>
<mat-header-cell *matHeaderCellDef>{{ 'User' | translate }}</mat-header-cell> <mat-header-cell *matHeaderCellDef>{{ 'Participant' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let vote"> <mat-cell *matCellDef="let vote">
<div *ngIf="vote.user">{{ vote.user.getFullName() }}</div> <div *ngIf="vote.user">{{ vote.user.getFullName() }}</div>
<div *ngIf="!vote.user">{{ 'Unknown user' | translate }}</div> <div *ngIf="!vote.user">{{ 'Anonymous' | translate }}</div>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="value" sticky> <ng-container matColumnDef="value" sticky>
@ -72,6 +85,10 @@
<mat-row *matRowDef="let vote; columns: columnDefinition"></mat-row> <mat-row *matRowDef="let vote; columns: columnDefinition"></mat-row>
</mat-table> </mat-table>
</div> </div>
<div *ngIf="!votesDataSource.data">
{{ 'The individual votes were made anonymous.' | translate }}
</div>
</div>
</div> </div>
</div> </div>

View File

@ -42,7 +42,7 @@
</div> </div>
<!-- Publish immediately button. Only show for new polls --> <!-- Publish immediately button. Only show for new polls -->
<div *ngIf="!pollData.state"> <div *ngIf="!pollData.isStatePublished">
<mat-checkbox [(ngModel)]="publishImmediately" (change)="publishStateChanged($event.checked)"> <mat-checkbox [(ngModel)]="publishImmediately" (change)="publishStateChanged($event.checked)">
<span translate>Publish immediately</span> <span translate>Publish immediately</span>
</mat-checkbox> </mat-checkbox>

View File

@ -55,53 +55,60 @@
<ng-template #viewTemplate> <ng-template #viewTemplate>
<div class="poll-chart-wrapper"> <div class="poll-chart-wrapper">
<!-- empty helper div to center the grid wrapper -->
<div></div>
<div class="doughnut-chart"> <div class="doughnut-chart">
<os-charts *ngIf="showChart" [type]="'doughnut'" [data]="chartDataSubject" [showLegend]="false"> <os-charts
*ngIf="showChart"
[type]="'doughnut'"
[data]="chartDataSubject"
[showLegend]="false"
[hasPadding]="false"
>
</os-charts> </os-charts>
</div> </div>
<div class="vote-legend"> <div class="vote-legend">
<div class="votes-yes" *ngIf="isVoteDocumented(voteYes)"> <div class="votes-yes" *ngIf="isVoteDocumented(voteYes)">
<os-icon-container icon="thumb_up" size="large"> <os-icon-container icon="thumb_up" size="large">
{{ voteYes | parsePollNumber }} {{ voteYes | parsePollNumber }}
{{ voteYes | pollPercentBase: poll }}
</os-icon-container> </os-icon-container>
</div> </div>
<div class="votes-no" *ngIf="isVoteDocumented(voteNo)"> <div class="votes-no" *ngIf="isVoteDocumented(voteNo)">
<os-icon-container icon="thumb_down" size="large"> <os-icon-container icon="thumb_down" size="large">
{{ voteNo | parsePollNumber }} {{ voteNo | parsePollNumber }}
{{ voteNo | pollPercentBase: poll }}
</os-icon-container> </os-icon-container>
</div> </div>
<div class="votes-abstain" *ngIf="isVoteDocumented(voteAbstain)"> <div class="votes-abstain" *ngIf="isVoteDocumented(voteAbstain)">
<os-icon-container icon="trip_origin" size="large"> <os-icon-container icon="trip_origin" size="large">
{{ voteAbstain | parsePollNumber }} {{ voteAbstain | parsePollNumber }}
{{ voteAbstain | pollPercentBase: poll }}
</os-icon-container> </os-icon-container>
</div> </div>
</div> </div>
</div> </div>
<div class="poll-detail-button-wrapper" *ngIf="poll.type !== 'analog'"> <div class="poll-detail-button-wrapper">
<button mat-button [routerLink]="pollLink"> <button mat-button [routerLink]="pollLink">
{{ 'Single Votes' | translate }} {{ 'More' | translate }}
</button> </button>
</div> </div>
</ng-template> </ng-template>
<ng-template #emptyTemplate> <ng-template #emptyTemplate>
<div *osPerms="'motions.can_manage_polls'"> <div *osPerms="'motions.can_manage_polls'">
{{ 'An empty poll - you have to enter votes.' | translate }} {{ 'Edit to enter votes.' | translate }}
</div> </div>
</ng-template> </ng-template>
<!-- More Menu --> <!-- More Menu -->
<mat-menu #pollDetailMenu="matMenu"> <mat-menu #pollDetailMenu="matMenu">
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button *osPerms="'motions.can_manage_polls'" mat-menu-item (click)="openDialog()"> <button *osPerms="'motions.can_manage_polls'" mat-menu-item (click)="openDialog()">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
<span translate>Edit</span> <span translate>Edit</span>
</button> </button>
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button mat-menu-item (click)="downloadPdf()"> <button mat-menu-item (click)="downloadPdf()">
<mat-icon>picture_as_pdf</mat-icon> <mat-icon>picture_as_pdf</mat-icon>
<span translate>PDF</span> <span translate>Ballot paper</span>
</button> </button>
<div *osPerms="'motions.can_manage_polls'"> <div *osPerms="'motions.can_manage_polls'">
<mat-divider></mat-divider> <mat-divider></mat-divider>

View File

@ -51,15 +51,20 @@
.poll-chart-wrapper { .poll-chart-wrapper {
cursor: pointer; cursor: pointer;
display: grid; display: grid;
grid-gap: 10px;
grid-template-areas: 'placeholder chart legend';
grid-template-columns: auto minmax(50px, 20%) auto; grid-template-columns: auto minmax(50px, 20%) auto;
.doughnut-chart { .doughnut-chart {
grid-area: chart;
margin-top: auto; margin-top: auto;
margin-bottom: auto; margin-bottom: auto;
} }
.vote-legend { .vote-legend {
margin: auto 10px; grid-area: legend;
margin-top: auto;
margin-bottom: auto;
div + div { div + div {
margin-top: 10px; margin-top: 10px;

View File

@ -1,23 +1,25 @@
<div class="os-form-card-mobile"> <div class="os-form-card-mobile">
<!-- Poll Title -->
<form [formGroup]="contentForm"> <form [formGroup]="contentForm">
<mat-form-field> <mat-form-field>
<h2 class="poll-preview--title"> <h2 class="poll-preview-title">
<input matInput required formControlName="title" [placeholder]="'Title' | translate" /> <input matInput required formControlName="title" [placeholder]="'Title' | translate" />
</h2> </h2>
</mat-form-field> </mat-form-field>
</form> </form>
<div *ngIf="data && data.state > 1" class="poll-preview-meta-info"> <div *ngIf="data && data.state > 1" class="poll-preview-meta-info">
<span class="short-description" *ngFor="let value of pollValues"> <span class="short-description" *ngFor="let value of pollValues">
<span class="short-description--label subtitle" translate> <span class="short-description-label subtitle" translate>
{{ value[0] }} {{ value[0] }}
</span> </span>
<span class="short-description--value" translate> <span class="short-description-value" translate>
{{ value[1] }} {{ value[1] }}
</span> </span>
</span> </span>
</div> </div>
<form [formGroup]="contentForm" class="poll-preview--meta-info-form"> <form [formGroup]="contentForm" class="poll-preview-meta-info-form">
<ng-container *ngIf="!data || !data.state || data.isStateCreated"> <ng-container *ngIf="!data || !data.state || data.isStateCreated">
<!-- Poll Type -->
<mat-form-field *ngIf="pollService.isElectronicVotingEnabled"> <mat-form-field *ngIf="pollService.isElectronicVotingEnabled">
<mat-select [placeholder]="PollPropertyVerbose.type | translate" formControlName="type" required> <mat-select [placeholder]="PollPropertyVerbose.type | translate" formControlName="type" required>
<mat-option *ngFor="let option of pollTypes | keyvalue" [value]="option.key"> <mat-option *ngFor="let option of pollTypes | keyvalue" [value]="option.key">
@ -26,7 +28,9 @@
</mat-select> </mat-select>
<mat-error translate>This field is required</mat-error> <mat-error translate>This field is required</mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field *ngIf="contentForm.get('type').value && contentForm.get('type').value != 'analog'">
<!-- Groups entitled to Vote -->
<mat-form-field *ngIf="contentForm.get('type').value && contentForm.get('type').value !== 'analog'">
<os-search-value-selector <os-search-value-selector
formControlName="groups_id" formControlName="groups_id"
[multiple]="true" [multiple]="true"
@ -36,8 +40,14 @@
[inputListValues]="groupObservable" [inputListValues]="groupObservable"
></os-search-value-selector> ></os-search-value-selector>
</mat-form-field> </mat-form-field>
<!-- Poll Methods -->
<mat-form-field *ngIf="pollMethods"> <mat-form-field *ngIf="pollMethods">
<mat-select [placeholder]="PollPropertyVerbose.pollmethod | translate" formControlName="pollmethod" required> <mat-select
[placeholder]="PollPropertyVerbose.pollmethod | translate"
formControlName="pollmethod"
required
>
<mat-option *ngFor="let option of pollMethods | keyvalue" [value]="option.key"> <mat-option *ngFor="let option of pollMethods | keyvalue" [value]="option.key">
{{ option.value }} {{ option.value }}
</mat-option> </mat-option>
@ -46,29 +56,37 @@
</mat-form-field> </mat-form-field>
</ng-container> </ng-container>
<!-- 100 Percent Base -->
<mat-form-field> <mat-form-field>
<mat-select placeholder="{{ PollPropertyVerbose.onehundred_percent_base | translate }}" formControlName="onehundred_percent_base" required> <mat-select
placeholder="{{ PollPropertyVerbose.onehundred_percent_base | translate }}"
formControlName="onehundred_percent_base"
required
>
<ng-container *ngFor="let option of percentBases | keyvalue"> <ng-container *ngFor="let option of percentBases | keyvalue">
<mat-option [value]="option.key">{{ option.value | translate }}</mat-option> <mat-option [value]="option.key">{{ option.value | translate }}</mat-option>
</ng-container> </ng-container>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field>
<mat-select placeholder="{{ PollPropertyVerbose.majority_method | translate }}" formControlName="majority_method" required>
<mat-option *ngFor="let option of majorityMethods | keyvalue" [value]="option.key">
{{ option.value | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<ng-container *ngIf="!data || !data.state || data.state === 1"> <!-- Amount of Votes -->
<ng-container *ngIf="contentForm.get('pollmethod').value === 'votes'"> <ng-container
*ngIf="contentForm.get('pollmethod').value === 'votes' && (!data || !data.state || data.isStateCreated)"
>
<mat-form-field> <mat-form-field>
<input type="number" matInput placeholder="{{ PollPropertyVerbose.votes_amount | translate }}" formControlName="votes_amount" min="1" required> <input
type="number"
matInput
placeholder="{{ PollPropertyVerbose.votes_amount | translate }}"
formControlName="votes_amount"
min="1"
required
/>
</mat-form-field> </mat-form-field>
<mat-checkbox formControlName="global_no">{{ PollPropertyVerbose.global_no | translate }}</mat-checkbox> <mat-checkbox formControlName="global_no">{{ PollPropertyVerbose.global_no | translate }}</mat-checkbox>
<mat-checkbox formControlName="global_abstain">{{ PollPropertyVerbose.global_abstain | translate }}</mat-checkbox> <mat-checkbox formControlName="global_abstain">{{
</ng-container> PollPropertyVerbose.global_abstain | translate
}}</mat-checkbox>
</ng-container> </ng-container>
</form> </form>
</div> </div>

View File

@ -1,4 +1,4 @@
.poll-preview--title { .poll-preview-title {
margin: 0; margin: 0;
} }
@ -14,13 +14,13 @@
span { span {
display: block; display: block;
} }
&--label { &-label {
font-size: 75%; font-size: 75%;
} }
} }
} }
.poll-preview--meta-info-form { .poll-preview-meta-info-form {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -114,9 +114,10 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
this.data.groups_id = this.configService.instant('motion_poll_default_groups'); this.data.groups_id = this.configService.instant('motion_poll_default_groups');
} }
} }
Object.keys(this.contentForm.controls).forEach(key => { Object.keys(this.contentForm.controls).forEach(key => {
if (this.data[key]) { if (this.data[key]) {
this.contentForm.get(key).setValue(this.data[key]); this.contentForm.get(key).patchValue(this.data[key]);
} }
}); });
} }
@ -132,9 +133,9 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
this.contentForm.get('pollmethod').valueChanges.subscribe(method => { this.contentForm.get('pollmethod').valueChanges.subscribe(method => {
let forbiddenBases: string[]; let forbiddenBases: string[];
if (method === 'YN') { if (method === 'YN') {
forbiddenBases = [PercentBase.YNA, PercentBase.Votes]; forbiddenBases = [PercentBase.YNA, PercentBase.Cast];
} else if (method === 'YNA') { } else if (method === 'YNA') {
forbiddenBases = [PercentBase.Votes]; forbiddenBases = [PercentBase.Cast];
} else if (method === 'votes') { } else if (method === 'votes') {
forbiddenBases = [PercentBase.YN, PercentBase.YNA]; forbiddenBases = [PercentBase.YN, PercentBase.YNA];
} }

View File

@ -1,3 +1,3 @@
<span>{{ poll.voted_id.length }} von {{ max }} haben abgestimmt.</span> <span> {{ 'Casted votes' }}: {{ poll.voted_id.length }} / {{ max }} </span>
<mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar> <mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar>

View File

@ -40,7 +40,11 @@ export class PollProgressComponent extends BaseViewComponent implements OnInit {
public ngOnInit(): void { public ngOnInit(): void {
this.userRepo this.userRepo
.getViewModelListObservable() .getViewModelListObservable()
.pipe(map(users => users.filter(user => this.poll.groups_id.intersect(user.groups_id).length))) .pipe(
map(users =>
users.filter(user => user.is_present && this.poll.groups_id.intersect(user.groups_id).length)
)
)
.subscribe(users => { .subscribe(users => {
this.max = users.length; this.max = users.length;
}); });

View File

@ -21,8 +21,11 @@ export interface PollData {
value?: number; value?: number;
yes?: number; yes?: number;
no?: number; no?: number;
abstain: number; abstain?: number;
user?: string; user?: string;
canHide?: boolean;
icon?: string;
showPercent?: boolean;
} }
export const PollClassTypeVerbose = { export const PollClassTypeVerbose = {
@ -40,7 +43,7 @@ export const PollStateVerbose = {
export const PollStateChangeActionVerbose = { export const PollStateChangeActionVerbose = {
1: 'Reset', 1: 'Reset',
2: 'Start voting', 2: 'Start voting',
3: 'End voting', 3: 'Stop voting',
4: 'Publish' 4: 'Publish'
}; };
@ -51,7 +54,7 @@ export const PollTypeVerbose = {
}; };
export const PollPropertyVerbose = { export const PollPropertyVerbose = {
majority_method: 'Majority method', majority_method: 'Required majority',
onehundred_percent_base: '100% base', onehundred_percent_base: '100% base',
type: 'Poll type', type: 'Poll type',
pollmethod: 'Poll method', pollmethod: 'Poll method',
@ -69,10 +72,12 @@ export const MajorityMethodVerbose = {
disabled: 'Disabled' disabled: 'Disabled'
}; };
/**
* TODO: These need to be in order
*/
export const PercentBaseVerbose = { export const PercentBaseVerbose = {
YN: 'Yes/No', YN: 'Yes/No',
YNA: 'Yes/No/Abstain', YNA: 'Yes/No/Abstain',
votes: 'All votes',
valid: 'Valid votes', valid: 'Valid votes',
cast: 'Total votes cast', cast: 'Total votes cast',
disabled: 'Disabled' disabled: 'Disabled'
@ -145,6 +150,8 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
public abstract generateChartData(): ChartData; public abstract generateChartData(): ChartData;
public abstract generateTableData(): PollData[]; public abstract generateTableData(): PollData[];
public abstract getPercentBase(): number;
} }
export interface ViewBasePoll<M extends BasePoll<M, any> = any> extends BasePoll<M, any> { export interface ViewBasePoll<M extends BasePoll<M, any> = any> extends BasePoll<M, any> {

View File

@ -1,3 +1,5 @@
from django.core.validators import MinValueValidator
from openslides.assignments.models import AssignmentPoll from openslides.assignments.models import AssignmentPoll
from openslides.core.config import ConfigVariable from openslides.core.config import ConfigVariable
@ -19,8 +21,8 @@ def get_config_variables():
for base in AssignmentPoll.PERCENT_BASES for base in AssignmentPoll.PERCENT_BASES
), ),
weight=400, weight=400,
group="Voting", group="Elections",
subgroup="Elections", subgroup="Voting",
) )
yield ConfigVariable( yield ConfigVariable(
@ -35,18 +37,8 @@ def get_config_variables():
help_text="Default method to check whether a candidate has reached the required majority.", help_text="Default method to check whether a candidate has reached the required majority.",
weight=405, weight=405,
hidden=True, hidden=True,
group="Voting", group="Elections",
subgroup="Elections", subgroup="Voting",
)
yield ConfigVariable(
name="assignment_poll_add_candidates_to_list_of_speakers",
default_value=True,
input_type="boolean",
label="Put all candidates on the list of speakers",
weight=410,
group="Voting",
subgroup="Elections",
) )
yield ConfigVariable( yield ConfigVariable(
@ -54,9 +46,52 @@ def get_config_variables():
default_value=[], default_value=[],
input_type="groups", input_type="groups",
label="Default groups for named and pseudoanonymous assignment polls", label="Default groups for named and pseudoanonymous assignment polls",
weight=410,
group="Elections",
subgroup="Voting",
)
yield ConfigVariable(
name="assignment_poll_add_candidates_to_list_of_speakers",
default_value=True,
input_type="boolean",
label="Put all candidates on the list of speakers",
weight=415, weight=415,
group="Voting", group="Elections",
subgroup="Elections", subgroup="Voting",
)
# Ballot Paper
yield ConfigVariable(
name="assignments_pdf_ballot_papers_selection",
default_value="CUSTOM_NUMBER",
input_type="choice",
label="Number of ballot papers (selection)",
choices=(
{"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"},
{
"value": "NUMBER_OF_ALL_PARTICIPANTS",
"display_name": "Number of all participants",
},
{
"value": "CUSTOM_NUMBER",
"display_name": "Use the following custom number",
},
),
weight=430,
group="Elections",
subgroup="Ballot papers",
)
yield ConfigVariable(
name="assignments_pdf_ballot_papers_number",
default_value=8,
input_type="integer",
label="Custom number of ballot papers",
weight=435,
group="Elections",
subgroup="Ballot papers",
validators=(MinValueValidator(1),),
) )
# PDF # PDF

View File

@ -332,22 +332,77 @@ def get_config_variables():
# Voting and ballot papers # Voting and ballot papers
yield ConfigVariable( yield ConfigVariable(
name="motions_poll_100_percent_base", name="motion_poll_default_100_percent_base",
default_value="YES_NO_ABSTAIN", default_value="YNA",
input_type="choice", input_type="choice",
label="The 100 % base of a voting result consists of", label="The 100 % base of a voting result consists of",
choices=( choices=tuple(
{"value": "YES_NO_ABSTAIN", "display_name": "Yes/No/Abstain"}, {"value": base[0], "display_name": base[1]}
{"value": "YES_NO", "display_name": "Yes/No"}, for base in MotionPoll.PERCENT_BASES
{"value": "VALID", "display_name": "All valid ballots"},
{"value": "CAST", "display_name": "All casted ballots"},
{"value": "DISABLED", "display_name": "Disabled (no percents)"},
), ),
weight=370, weight=370,
group="Motions", group="Motions",
subgroup="Voting and ballot papers", subgroup="Voting and ballot papers",
) )
yield ConfigVariable(
name="motion_poll_default_majority_method",
default_value="simple",
input_type="choice",
choices=tuple(
{"value": method[0], "display_name": method[1]}
for method in MotionPoll.MAJORITY_METHODS
),
label="Required majority",
help_text="Default method to check whether a motion has reached the required majority.",
weight=371,
hidden=True,
group="Motions",
subgroup="Voting and ballot papers",
)
yield ConfigVariable(
name="motion_poll_default_groups",
default_value=[],
input_type="groups",
label="Default groups for named and pseudoanonymous motion polls",
weight=372,
group="Motions",
subgroup="Voting and ballot papers",
)
yield ConfigVariable(
name="motions_pdf_ballot_papers_selection",
default_value="CUSTOM_NUMBER",
input_type="choice",
label="Number of ballot papers (selection)",
choices=(
{"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"},
{
"value": "NUMBER_OF_ALL_PARTICIPANTS",
"display_name": "Number of all participants",
},
{
"value": "CUSTOM_NUMBER",
"display_name": "Use the following custom number",
},
),
weight=373,
group="Motions",
subgroup="Voting and ballot papers",
)
yield ConfigVariable(
name="motions_pdf_ballot_papers_number",
default_value=8,
input_type="integer",
label="Custom number of ballot papers",
weight=374,
group="Motions",
subgroup="Voting and ballot papers",
validators=(MinValueValidator(1),),
)
# PDF export # PDF export
yield ConfigVariable( yield ConfigVariable(
@ -387,44 +442,3 @@ def get_config_variables():
group="Motions", group="Motions",
subgroup="PDF export", subgroup="PDF export",
) )
# Voting
yield ConfigVariable(
name="motion_poll_default_100_percent_base",
default_value="YNA",
input_type="choice",
label="The 100 % base of a voting result consists of",
choices=tuple(
{"value": base[0], "display_name": base[1]}
for base in MotionPoll.PERCENT_BASES
),
weight=420,
group="Voting",
subgroup="Motions",
)
yield ConfigVariable(
name="motion_poll_default_majority_method",
default_value="simple",
input_type="choice",
choices=tuple(
{"value": method[0], "display_name": method[1]}
for method in MotionPoll.MAJORITY_METHODS
),
label="Required majority",
help_text="Default method to check whether a motion has reached the required majority.",
weight=425,
hidden=True,
group="Voting",
subgroup="Motions",
)
yield ConfigVariable(
name="motion_poll_default_groups",
default_value=[],
input_type="groups",
label="Default groups for named and pseudoanonymous motion polls",
weight=430,
group="Voting",
subgroup="Motions",
)