Cleanup Voting, enhance UI and UX

removed certain unnecesary fields
cleaned up a lot of code
redone some of the UI
some database and server adjustments
This commit is contained in:
Sean Engelhardt 2020-02-24 16:55:07 +01:00 committed by FinnStutzenstein
parent 7598fc5367
commit 97a5bb4aa6
84 changed files with 893 additions and 1255 deletions

View File

@ -80,7 +80,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
private readonly restPath = '/rest/assignments/assignment/'; private readonly restPath = '/rest/assignments/assignment/';
private readonly candidatureOtherPath = '/candidature_other/'; private readonly candidatureOtherPath = '/candidature_other/';
private readonly candidatureSelfPath = '/candidature_self/'; private readonly candidatureSelfPath = '/candidature_self/';
private readonly markElectedPath = '/mark_elected/';
/** /**
* Constructor for the Assignment Repository. * Constructor for the Assignment Repository.
@ -158,26 +157,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath); await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath);
} }
/**
* change the 'elected' state of an election candidate
*
* @param assignmentRelatedUser
* @param assignment
* @param elected true if the candidate is to be elected, false if unelected
*/
public async markElected(
assignmentRelatedUser: ViewAssignmentRelatedUser,
assignment: ViewAssignment,
elected: boolean
): Promise<void> {
const data = { user: assignmentRelatedUser.user_id };
if (elected) {
await this.httpService.post(this.restPath + assignment.id + this.markElectedPath, data);
} else {
await this.httpService.delete(this.restPath + assignment.id + this.markElectedPath, data);
}
}
/** /**
* Sends a request to sort an assignment's candidates * Sends a request to sort an assignment's candidates
* *

View File

@ -125,6 +125,18 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
return name.trim(); return name.trim();
} }
public getLevelAndNumber(titleInformation: UserTitleInformation): string {
if (titleInformation.structure_level && titleInformation.number) {
return `${titleInformation.structure_level} · ${this.translate.instant('No.')} ${titleInformation.number}`;
} else if (titleInformation.structure_level) {
return titleInformation.structure_level;
} else if (titleInformation.number) {
return `${this.translate.instant('No.')} ${titleInformation.number}`;
} else {
return '';
}
}
public getVerboseName = (plural: boolean = false) => { public getVerboseName = (plural: boolean = false) => {
return this.translate.instant(plural ? 'Participants' : 'Participant'); return this.translate.instant(plural ? 'Participants' : 'Participant');
}; };
@ -145,12 +157,13 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User, UserTi
} }
/** /**
* Adds teh short and full name to the view user. * Adds the short and full name to the view user.
*/ */
protected createViewModelWithTitles(model: User): ViewUser { protected createViewModelWithTitles(model: User): ViewUser {
const viewModel = super.createViewModelWithTitles(model); const viewModel = super.createViewModelWithTitles(model);
viewModel.getFullName = () => this.getFullName(viewModel); viewModel.getFullName = () => this.getFullName(viewModel);
viewModel.getShortName = () => this.getShortName(viewModel); viewModel.getShortName = () => this.getShortName(viewModel);
viewModel.getLevelAndNumber = () => this.getLevelAndNumber(viewModel);
return viewModel; return viewModel;
} }

View File

@ -8,7 +8,6 @@ import { PollState, PollType } from 'app/shared/models/poll/base-poll';
import { mediumDialogSettings } from 'app/shared/utils/dialog-settings'; import { mediumDialogSettings } from 'app/shared/utils/dialog-settings';
import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component'; import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { PollService } from '../../site/polls/services/poll.service';
/** /**
* Abstract class for showing a poll dialog. Has to be subclassed to provide the right `PollService` * Abstract class for showing a poll dialog. Has to be subclassed to provide the right `PollService`
@ -17,42 +16,35 @@ import { PollService } from '../../site/polls/services/poll.service';
providedIn: 'root' providedIn: 'root'
}) })
export abstract class BasePollDialogService<V extends ViewBasePoll> { export abstract class BasePollDialogService<V extends ViewBasePoll> {
protected dialogComponent: ComponentType<BasePollDialogComponent>; protected dialogComponent: ComponentType<BasePollDialogComponent<V>>;
public constructor( public constructor(private dialog: MatDialog, private mapper: CollectionStringMapperService) {}
private dialog: MatDialog,
private mapper: CollectionStringMapperService,
private service: PollService
) {}
/** /**
* Opens the dialog to enter votes and edit the meta-info for a poll. * Opens the dialog to enter votes and edit the meta-info for a poll.
* *
* @param data Passing the (existing or new) data for the poll * @param data Passing the (existing or new) data for the poll
*/ */
public async openDialog(poll: Partial<V> & Collection): Promise<void> { public async openDialog(viewPoll: Partial<V> & Collection): Promise<void> {
if (!poll.poll) {
this.service.fillDefaultPollData(poll);
}
const dialogRef = this.dialog.open(this.dialogComponent, { const dialogRef = this.dialog.open(this.dialogComponent, {
data: poll, data: viewPoll,
...mediumDialogSettings ...mediumDialogSettings
}); });
const result = await dialogRef.afterClosed().toPromise(); const result = await dialogRef.afterClosed().toPromise();
if (result) { if (result) {
const repo = this.mapper.getRepository(poll.collectionString); const repo = this.mapper.getRepository(viewPoll.collectionString);
if (!poll.poll) { if (!viewPoll.poll) {
await repo.create(result); await repo.create(result);
} else { } else {
let update = result; let update = result;
if (poll.state !== PollState.Created) { if (viewPoll.state !== PollState.Created) {
update = { update = {
title: result.title, title: result.title,
onehundred_percent_base: result.onehundred_percent_base, onehundred_percent_base: result.onehundred_percent_base,
majority_method: result.majority_method, majority_method: result.majority_method,
description: result.description description: result.description
}; };
if (poll.type === PollType.Analog) { if (viewPoll.type === PollType.Analog) {
update = { update = {
...update, ...update,
votes: result.votes, votes: result.votes,
@ -60,7 +52,7 @@ export abstract class BasePollDialogService<V extends ViewBasePoll> {
}; };
} }
} }
await repo.patch(update, <V>poll); await repo.patch(update, <V>viewPoll);
} }
} }
} }

View File

@ -1,14 +0,0 @@
<div>
<mat-button-toggle-group>
<mat-button-toggle
*ngFor="let breadcrumb of breadcrumbList"
[disabled]="breadcrumb.action === null"
(click)="breadcrumb.action ? breadcrumb.action() : null"
[ngClass]="{ 'active-breadcrumb': breadcrumb.active }"
>
<span>
{{ breadcrumb.label }}
</span>
</mat-button-toggle>
</mat-button-toggle-group>
</div>

View File

@ -1,4 +0,0 @@
.active-breadcrumb {
// Theme
color: rgba($color: #317796, $alpha: 1);
}

View File

@ -1,26 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { BreadcrumbComponent } from './breadcrumb.component';
describe('BreadcrumbComponent', () => {
let component: BreadcrumbComponent;
let fixture: ComponentFixture<BreadcrumbComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BreadcrumbComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,69 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
/**
* Describes, how one breadcrumb can look like.
*/
export interface Breadcrumb {
label: string;
action: () => any;
active?: boolean;
}
@Component({
selector: 'os-breadcrumb',
templateUrl: './breadcrumb.component.html',
styleUrls: ['./breadcrumb.component.scss']
})
export class BreadcrumbComponent implements OnInit {
/**
* A list of all breadcrumbs, that should be rendered.
*
* @param labels A list of strings or the interface `Breadcrumb`.
*/
@Input()
public set breadcrumbs(labels: string[] | Breadcrumb[]) {
this.breadcrumbList = [];
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#caveats
for (const breadcrumb of labels) {
if (typeof breadcrumb === 'string') {
this.breadcrumbList.push({ label: breadcrumb, action: null });
} else {
this.breadcrumbList.push(breadcrumb);
}
}
}
/**
* The current active index, if not the last one.
*
* @param index The index as number.
*/
@Input()
public set activeIndex(index: number) {
for (const breadcrumb of this.breadcrumbList) {
breadcrumb.active = false;
}
this.breadcrumbList[index].active = true;
}
/**
* The list of the breadcrumbs built by the input.
*/
public breadcrumbList: Breadcrumb[] = [];
/**
* Default constructor.
*/
public constructor() {}
/**
* OnInit.
* Sets the last breadcrumb as the active breadcrumb if not defined before.
*/
public ngOnInit(): void {
if (this.breadcrumbList.length && !this.breadcrumbList.some(breadcrumb => breadcrumb.active)) {
this.breadcrumbList[this.breadcrumbList.length - 1].active = true;
}
}
}

View File

@ -5,16 +5,22 @@
'message action' 'message action'
'bar action'; 'bar action';
grid-template-columns: auto min-content; grid-template-columns: auto min-content;
}
.message { .mat-progress-bar-buffer {
grid-area: message; // TODO theme
} // background-color: mat-color($background, card) !important;
background-color: white !important;
}
.bar { .message {
grid-area: bar; grid-area: message;
} }
.action { .bar {
grid-area: action; grid-area: bar;
}
.action {
grid-area: action;
}
} }

View File

@ -14,6 +14,8 @@ export enum AssignmentPollMethods {
*/ */
export class AssignmentPoll extends BasePoll<AssignmentPoll, AssignmentOption> { export class AssignmentPoll extends BasePoll<AssignmentPoll, AssignmentOption> {
public static COLLECTIONSTRING = 'assignments/assignment-poll'; public static COLLECTIONSTRING = 'assignments/assignment-poll';
public static defaultGroupsConfig = 'assignment_poll_default_groups';
public static defaultPollMethodConfig = 'assignment_poll_method';
public id: number; public id: number;
public assignment_id: number; public assignment_id: number;

View File

@ -8,7 +8,6 @@ export class AssignmentRelatedUser extends BaseModel<AssignmentRelatedUser> {
public id: number; public id: number;
public user_id: number; public user_id: number;
public elected: boolean;
public assignment_id: number; public assignment_id: number;
public weight: number; public weight: number;

View File

@ -12,6 +12,7 @@ export enum MotionPollMethods {
*/ */
export class MotionPoll extends BasePoll<MotionPoll, MotionOption> { export class MotionPoll extends BasePoll<MotionPoll, MotionOption> {
public static COLLECTIONSTRING = 'motions/motion-poll'; public static COLLECTIONSTRING = 'motions/motion-poll';
public static defaultGroupsConfig = 'motion_poll_default_groups';
public id: number; public id: number;
public motion_id: number; public motion_id: number;

View File

@ -2,7 +2,7 @@ import { BaseDecimalModel } from '../base/base-decimal-model';
import { BaseOption } from './base-option'; import { BaseOption } from './base-option';
export enum PollColor { export enum PollColor {
yes = '#9fd773', yes = '#4caf50',
no = '#cc6c5b', no = '#cc6c5b',
abstain = '#a6a6a6', abstain = '#a6a6a6',
votesvalid = '#e2e2e2', votesvalid = '#e2e2e2',
@ -76,6 +76,10 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
return this.isFinished || this.isPublished; return this.isFinished || this.isPublished;
} }
public get nextState(): PollState {
return this.state + 1;
}
protected getDecimalFields(): (keyof BasePoll<T, O>)[] { protected getDecimalFields(): (keyof BasePoll<T, O>)[] {
return ['votesvalid', 'votesinvalid', 'votescast']; return ['votesvalid', 'votesinvalid', 'votescast'];
} }

View File

@ -19,6 +19,6 @@ const PollValues = {
}) })
export class PollKeyVerbosePipe implements PipeTransform { export class PollKeyVerbosePipe implements PipeTransform {
public transform(value: string): string { public transform(value: string): string {
return PollValues[value]; return PollValues[value] || value;
} }
} }

View File

@ -6,7 +6,7 @@ import { AssignmentPollService } from 'app/site/assignments/services/assignment-
import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { PollPercentBasePipe } from './poll-percent-base.pipe'; import { PollPercentBasePipe } from './poll-percent-base.pipe';
fdescribe('PollPercentBasePipe', () => { describe('PollPercentBasePipe', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [E2EImportsModule] imports: [E2EImportsModule]

View File

@ -40,7 +40,7 @@ export class PollPercentBasePipe implements PipeTransform {
const percentNumber = (value / totalByBase) * 100; const percentNumber = (value / totalByBase) * 100;
if (percentNumber > 0) { if (percentNumber > 0) {
const result = percentNumber % 1 === 0 ? percentNumber : percentNumber.toFixed(this.decimalPlaces); const result = percentNumber % 1 === 0 ? percentNumber : percentNumber.toFixed(this.decimalPlaces);
return `(${result}%)`; return `(${result} %)`;
} }
} }
return null; return null;

View File

@ -111,7 +111,6 @@ import { GlobalSpinnerComponent } from 'app/site/common/components/global-spinne
import { HeightResizingDirective } from './directives/height-resizing.directive'; import { HeightResizingDirective } from './directives/height-resizing.directive';
import { TrustPipe } from './pipes/trust.pipe'; import { TrustPipe } from './pipes/trust.pipe';
import { LocalizedDatePipe } from './pipes/localized-date.pipe'; import { LocalizedDatePipe } from './pipes/localized-date.pipe';
import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
import { ChartsComponent } from './components/charts/charts.component'; import { ChartsComponent } from './components/charts/charts.component';
import { CheckInputComponent } from './components/check-input/check-input.component'; import { CheckInputComponent } from './components/check-input/check-input.component';
import { BannerComponent } from './components/banner/banner.component'; import { BannerComponent } from './components/banner/banner.component';
@ -277,7 +276,6 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
ChartsModule, ChartsModule,
TrustPipe, TrustPipe,
LocalizedDatePipe, LocalizedDatePipe,
BreadcrumbComponent,
ChartsComponent, ChartsComponent,
CheckInputComponent, CheckInputComponent,
BannerComponent, BannerComponent,
@ -335,7 +333,6 @@ import { PollPercentBasePipe } from './pipes/poll-percent-base.pipe';
HeightResizingDirective, HeightResizingDirective,
TrustPipe, TrustPipe,
LocalizedDatePipe, LocalizedDatePipe,
BreadcrumbComponent,
ChartsComponent, ChartsComponent,
CheckInputComponent, CheckInputComponent,
BannerComponent, BannerComponent,

View File

@ -67,7 +67,7 @@
<!-- candidates list --> <!-- candidates list -->
<ng-container [ngTemplateOutlet]="candidatesTemplate"></ng-container> <ng-container [ngTemplateOutlet]="candidatesTemplate"></ng-container>
<!-- closed polls --> <!-- closed polls -->
<ng-container *ngIf="assignment"> <ng-container *ngIf="assignment && assignment.polls.length">
<ng-container *ngFor="let poll of assignment.polls | reverse; trackBy: trackByIndex"> <ng-container *ngFor="let poll of assignment.polls | reverse; trackBy: trackByIndex">
<os-assignment-poll [poll]="poll"> </os-assignment-poll> <os-assignment-poll [poll]="poll"> </os-assignment-poll>
</ng-container> </ng-container>
@ -162,7 +162,12 @@
<!-- Search for candidates --> <!-- Search for candidates -->
<div class="search-field" *ngIf="hasPerms('addOthers')"> <div class="search-field" *ngIf="hasPerms('addOthers')">
<form <form
*ngIf="hasPerms('addOthers') && filteredCandidates && filteredCandidates.value.length > 0" *ngIf="
hasPerms('addOthers') &&
filteredCandidates &&
filteredCandidates.value &&
filteredCandidates.value.length
"
[formGroup]="candidatesForm" [formGroup]="candidatesForm"
> >
<mat-form-field> <mat-form-field>
@ -285,7 +290,6 @@
<span translate>Number poll candidates</span> <span translate>Number poll candidates</span>
</mat-checkbox> </mat-checkbox>
</div> </div>
<!-- TODO searchValueSelector: Parent -->
</form> </form>
</mat-card> </mat-card>
</ng-template> </ng-template>

View File

@ -1,9 +1,11 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { AssignmentDetailComponent } from './assignment-detail.component'; import { AssignmentDetailComponent } from './assignment-detail.component';
import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component'; import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component';
import { AssignmentPollComponent } from '../assignment-poll/assignment-poll.component'; import { AssignmentPollComponent } from '../assignment-poll/assignment-poll.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
describe('AssignmentDetailComponent', () => { describe('AssignmentDetailComponent', () => {
let component: AssignmentDetailComponent; let component: AssignmentDetailComponent;
@ -12,7 +14,12 @@ describe('AssignmentDetailComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [E2EImportsModule], imports: [E2EImportsModule],
declarations: [AssignmentDetailComponent, AssignmentPollComponent, AssignmentPollVoteComponent] declarations: [
AssignmentDetailComponent,
AssignmentPollComponent,
AssignmentPollVoteComponent,
PollProgressComponent
]
}).compileComponents(); }).compileComponents();
})); }));

View File

@ -23,6 +23,7 @@ import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service'; import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment'; import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user'; import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user';
@ -176,7 +177,8 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
private promptService: PromptService, private promptService: PromptService,
private pdfService: AssignmentPdfExportService, private pdfService: AssignmentPdfExportService,
private mediafileRepo: MediafileRepositoryService, private mediafileRepo: MediafileRepositoryService,
private pollDialog: AssignmentPollDialogService private pollDialog: AssignmentPollDialogService,
private assignmentPollService: AssignmentPollService
) { ) {
super(title, translate, matSnackBar); super(title, translate, matSnackBar);
this.subscriptions.push( this.subscriptions.push(
@ -306,11 +308,15 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
* Creates a new Poll * Creates a new Poll
*/ */
public openDialog(): void { public openDialog(): void {
this.pollDialog.openDialog({ // TODO: That is not really a ViewObject
const dialogData = {
collectionString: ViewAssignmentPoll.COLLECTIONSTRING, collectionString: ViewAssignmentPoll.COLLECTIONSTRING,
assignment_id: this.assignment.id, assignment_id: this.assignment.id,
assignment: this.assignment assignment: this.assignment,
}); ...this.assignmentPollService.getDefaultPollData()
};
this.pollDialog.openDialog(dialogData);
} }
/** /**

View File

@ -0,0 +1,12 @@
@import '~@angular/material/theming';
@mixin os-assignment-poll-detail-style($theme) {
$background: map-get($theme, background);
.assignment-result-table {
border-collapse: collapse;
tr {
border-bottom: 1px solid mat-color($background, focused-button);
}
}
}

View File

@ -21,46 +21,51 @@
<span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span> <span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span>
<div *ngIf="poll.stateHasVotes"> <div *ngIf="poll.stateHasVotes">
<h2 translate>Result</h2> <div [class]="chartType === 'horizontalBar' ? 'result-wrapper-bar-chart' : 'result-wrapper-pie-chart'">
<div class="result-wrapper">
<!-- Result Table --> <!-- Result Table -->
<mat-table class="result-table" [dataSource]="poll.tableData"> <table class="assignment-result-table">
<ng-container matColumnDef="user" sticky> <tbody>
<mat-header-cell *matHeaderCellDef>{{ 'Candidates' | translate }}</mat-header-cell> <tr>
<mat-cell *matCellDef="let row">{{ row.user }}</mat-cell> <th translate>Candidates</th>
</ng-container> <th translate>Votes</th>
<div *ngIf="!isVotedPoll"> </tr>
<ng-container matColumnDef="yes"> <tr *ngFor="let row of poll.tableData">
<mat-header-cell *matHeaderCellDef>{{ 'Yes' | translate }}</mat-header-cell> <td>
<mat-cell *matCellDef="let row">{{ row.yes }}</mat-cell> <span>
</ng-container> {{ row.votingOption | pollKeyVerbose | translate }}
</span>
<span class="user-subtitle" *ngIf="row.votingOptionSubtitle">
<br />
{{ row.votingOptionSubtitle }}
</span>
</td>
<td>
<div *ngFor="let vote of row.value">
<div class="single-result" *ngIf="voteFitsMethod(vote)">
<os-icon-container *ngIf="vote.icon" [icon]="vote.icon">
{{ vote.vote | pollKeyVerbose | translate }}
</os-icon-container>
<span *ngIf="!vote.icon">
{{ vote.vote | pollKeyVerbose | translate }}
</span>
<ng-container matColumnDef="no"> <span>
<mat-header-cell *matHeaderCellDef>{{ 'No' | translate }}</mat-header-cell> {{ vote.amount | parsePollNumber }}
<mat-cell *matCellDef="let row">{{ row.no }}</mat-cell> <span *ngIf="vote.showPercent">
</ng-container> {{ vote.amount | pollPercentBase: poll }}
</span>
<ng-container matColumnDef="abstain"> </span>
<mat-header-cell *matHeaderCellDef>{{ 'Abstain' | translate }}</mat-header-cell> </div>
<mat-cell *matCellDef="let row">{{ row.abstain }}</mat-cell> </div>
</ng-container> </td>
</div> </tr>
</tbody>
<div *ngIf="isVotedPoll"> </table>
<ng-container matColumnDef="votes">
<mat-header-cell *matHeaderCellDef>{{ 'Votes' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.yes }}</mat-cell>
</ng-container>
</div>
<mat-header-row *matHeaderRowDef="columnDefinitionOverview"></mat-header-row>
<mat-row *matRowDef="let row; columns: columnDefinitionOverview"></mat-row>
</mat-table>
<!-- Result Chart --> <!-- Result Chart -->
<os-charts <os-charts
class="result-chart" class="assignment-result-chart"
[ngClass]="chartType === 'doughnut' ? 'pie-chart' : ''"
*ngIf="chartDataSubject.value" *ngIf="chartDataSubject.value"
[type]="chartType" [type]="chartType"
[labels]="candidatesLabels" [labels]="candidatesLabels"
@ -105,7 +110,8 @@
[ngClass]="voteOptionStyle[vote.votes[option.user_id].value].css" [ngClass]="voteOptionStyle[vote.votes[option.user_id].value].css"
class="vote-field" class="vote-field"
> >
<mat-icon> {{ voteOptionStyle[vote.votes[option.user_id].value].icon }}</mat-icon> {{ vote.votes[option.user_id].valueVerbose | translate }} <mat-icon> {{ voteOptionStyle[vote.votes[option.user_id].value].icon }}</mat-icon>
{{ vote.votes[option.user_id].valueVerbose | translate }}
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
@ -123,17 +129,18 @@
</div> </div>
<!-- Meta Infos --> <!-- Meta Infos -->
<div class="poll-content small"> <div class="assignment-poll-meta">
<div *ngIf="poll.groups && poll.type && poll.type !== 'analog'"> <small *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
{{ 'Groups' | translate }}: {{ 'Groups' | translate }}:
<span *ngFor="let group of poll.groups; let i = index"> <span *ngFor="let group of poll.groups; let i = index">
{{ group.getTitle() | translate }}<span *ngIf="i < poll.groups.length - 1">, </span> {{ group.getTitle() | translate }}<span *ngIf="i < poll.groups.length - 1">, </span>
</span> </span>
</div> </small>
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div> <small *ngIf="poll.onehundred_percent_base">
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div> {{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}
</small>
</div> </div>
</ng-container> </ng-container>
</ng-template> </ng-template>
@ -141,7 +148,7 @@
<!-- 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> <os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="openDialog()"> <button *osPerms="'assignments.can_manage_polls'" mat-menu-item (click)="openDialog(poll)">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
<span translate>Edit</span> <span translate>Edit</span>
</button> </button>

View File

@ -1,9 +1,22 @@
@import '~assets/styles/variables.scss'; @import '~assets/styles/variables.scss';
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
.result-wrapper { %assignment-result-wrapper {
margin-top: 2em;
display: grid; display: grid;
grid-gap: 10px; grid-gap: 10px;
}
.result-wrapper-bar-chart {
@extend %assignment-result-wrapper;
grid-template-areas:
'results'
'chart'
'names';
}
.result-wrapper-pie-chart {
@extend %assignment-result-wrapper;
grid-template-areas: grid-template-areas:
'chart' 'chart'
'results' 'results'
@ -11,7 +24,7 @@
} }
@include desktop { @include desktop {
.result-wrapper { .result-wrapper-pie-chart {
grid-template-areas: grid-template-areas:
'results chart' 'results chart'
'names names'; 'names names';
@ -19,13 +32,34 @@
} }
} }
.result-table { .assignment-result-table {
grid-area: results; grid-area: results;
th {
text-align: left;
font-weight: initial;
}
tr {
height: 48px;
}
tr:last-child {
border-bottom: none;
}
.single-result {
display: flex;
}
} }
.result-chart { .assignment-result-chart {
grid-area: chart; grid-area: chart;
}
.pie-chart {
max-width: 300px; max-width: 300px;
margin-left: auto;
margin-right: auto;
} }
.named-result-table { .named-result-table {
@ -36,27 +70,14 @@
} }
} }
.poll-content { .assignment-poll-meta {
display: grid;
text-align: right;
padding-top: 20px; padding-top: 20px;
} }
.chart-wrapper {
&.flex {
display: flex;
.mat-table {
flex: 2;
.mat-column-votes {
justify-content: center;
}
}
.chart-inner-wrapper {
flex: 3;
}
}
}
.single-votes-table { .single-votes-table {
display: block;
height: 500px; height: 500px;
} }

View File

@ -14,6 +14,7 @@ import { ViewportService } from 'app/core/ui-services/viewport.service';
import { ChartType } from 'app/shared/components/charts/charts.component'; import { ChartType } from 'app/shared/components/charts/charts.component';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component'; import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component';
import { VotingResult } from 'app/site/polls/models/view-base-poll';
import { PollService } from 'app/site/polls/services/poll.service'; import { PollService } from 'app/site/polls/services/poll.service';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
@ -43,13 +44,6 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
return this.poll.pollmethod === AssignmentPollMethods.Votes; return this.poll.pollmethod === AssignmentPollMethods.Votes;
} }
public get columnDefinitionOverview(): string[] {
const columns = this.isVotedPoll ? ['user', 'votes'] : ['user', 'yes', 'no'];
if (this.poll.pollmethod === AssignmentPollMethods.YNA) {
columns.splice(3, 0, 'abstain');
}
return columns;
}
private _chartType: ChartType = 'horizontalBar'; private _chartType: ChartType = 'horizontalBar';
public constructor( public constructor(
@ -130,9 +124,7 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
} }
this.setVotesData(Object.values(votes)); this.setVotesData(Object.values(votes));
this.candidatesLabels = this.pollService.getChartLabels(this.poll); this.candidatesLabels = this.pollService.getChartLabels(this.poll);
this.isReady = true; this.isReady = true;
} }
@ -157,4 +149,17 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
protected hasPerms(): boolean { protected hasPerms(): boolean {
return this.operator.hasPerms('assignments.can_manage'); return this.operator.hasPerms('assignments.can_manage');
} }
public voteFitsMethod(result: VotingResult): boolean {
if (this.poll.pollmethod === AssignmentPollMethods.Votes) {
if (result.vote === 'abstain' || result.vote === 'no') {
return false;
}
} else if (this.poll.pollmethod === AssignmentPollMethods.YN) {
if (result.vote === 'abstain') {
return false;
}
}
return true;
}
} }

View File

@ -1,7 +1,9 @@
<os-poll-form [data]="pollData" [pollMethods]="assignmentPollMethods" #pollForm></os-poll-form> <os-poll-form [data]="pollData" [pollMethods]="assignmentPollMethods" #pollForm></os-poll-form>
<!-- Analog voting -->
<ng-container *ngIf="pollForm.contentForm.get('type').value === 'analog'"> <ng-container *ngIf="pollForm.contentForm.get('type').value === 'analog'">
<!-- Candidate values -->
<form [formGroup]="dialogVoteForm"> <form [formGroup]="dialogVoteForm">
<!-- Candidates -->
<div formGroupName="options"> <div formGroupName="options">
<div *ngFor="let option of options" class="votes-grid"> <div *ngFor="let option of options" class="votes-grid">
<div> <div>
@ -33,36 +35,30 @@
></os-check-input> ></os-check-input>
</div> </div>
</form> </form>
<mat-divider></mat-divider>
<!-- Publish Check -->
<div class="spacer-top-20"> <div class="spacer-top-20">
<mat-checkbox <mat-checkbox [(ngModel)]="publishImmediately" (change)="publishStateChanged($event.checked)">
[(ngModel)]="publishImmediately"
(change)="publishStateChanged($event.checked)"
>
<span translate>Publish immediately</span> <span translate>Publish immediately</span>
</mat-checkbox> </mat-checkbox>
<mat-error *ngIf="!dialogVoteForm.valid" translate> <mat-error *ngIf="!dialogVoteForm.valid" translate>
If you want to publish after creating, you have to fill at least one of the fields. If you want to publish after creating, you have to fill at least one of the fields.
</mat-error> </mat-error>
</div> </div>
<!-- Summary values -->
<!-- <div *ngFor="let sumValue of sumValues" class="sum-value">
<mat-form-field>
<input
type="number"
matInput
[value]="getSumValue(sumValue)"
(change)="setSumValue(sumValue, $event.target.value)"
/>
<mat-label>{{ pollService.getLabel(sumValue) | translate }}</mat-label>
</mat-form-field>
</div> -->
</ng-container> </ng-container>
<mat-divider></mat-divider>
<!-- Actions -->
<div mat-dialog-actions> <div mat-dialog-actions>
<button mat-button (click)="submitPoll()" [disabled]="!pollForm.contentForm || pollForm.contentForm.invalid || dialogVoteForm.invalid"> <!-- Save Button -->
<button
mat-button
(click)="submitPoll()"
[disabled]="!pollForm.contentForm || pollForm.contentForm.invalid || dialogVoteForm.invalid"
>
<span translate>Save</span> <span translate>Save</span>
</button> </button>
<!-- Cancel Button -->
<button mat-button [mat-dialog-close]="false"> <button mat-button [mat-dialog-close]="false">
<span translate>Cancel</span> <span translate>Cancel</span>
</button> </button>

View File

@ -11,9 +11,7 @@ import { GeneralValueVerbose, VoteValue, VoteValueVerbose } from 'app/shared/mod
import { AssignmentPollMethodsVerbose } from 'app/site/assignments/models/view-assignment-poll'; import { AssignmentPollMethodsVerbose } from 'app/site/assignments/models/view-assignment-poll';
import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component'; import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component';
import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component'; import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component';
import { CalculablePollKey, PollVoteValue } from 'app/site/polls/services/poll.service';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { ViewAssignmentOption } from '../../models/view-assignment-option';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
type OptionsObject = { user_id: number; user: ViewUser }[]; type OptionsObject = { user_id: number; user: ViewUser }[];
@ -26,7 +24,7 @@ type OptionsObject = { user_id: number; user: ViewUser }[];
templateUrl: './assignment-poll-dialog.component.html', templateUrl: './assignment-poll-dialog.component.html',
styleUrls: ['./assignment-poll-dialog.component.scss'] styleUrls: ['./assignment-poll-dialog.component.scss']
}) })
export class AssignmentPollDialogComponent extends BasePollDialogComponent implements OnInit { export class AssignmentPollDialogComponent extends BasePollDialogComponent<ViewAssignmentPoll> implements OnInit {
/** /**
* The summary values that will have fields in the dialog * The summary values that will have fields in the dialog
*/ */
@ -41,7 +39,7 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent imple
public specialValues: [number, string][]; public specialValues: [number, string][];
@ViewChild('pollForm', { static: true }) @ViewChild('pollForm', { static: true })
protected pollForm: PollFormComponent; protected pollForm: PollFormComponent<ViewAssignmentPoll>;
/** /**
* vote entries for each option in this component. Is empty if method * vote entries for each option in this component. Is empty if method
@ -65,13 +63,14 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent imple
title: Title, title: Title,
protected translate: TranslateService, protected translate: TranslateService,
matSnackbar: MatSnackBar, matSnackbar: MatSnackBar,
public dialogRef: MatDialogRef<BasePollDialogComponent>, public dialogRef: MatDialogRef<BasePollDialogComponent<ViewAssignmentPoll>>,
@Inject(MAT_DIALOG_DATA) public pollData: Partial<ViewAssignmentPoll> @Inject(MAT_DIALOG_DATA) public pollData: Partial<ViewAssignmentPoll>
) { ) {
super(title, translate, matSnackbar, dialogRef); super(title, translate, matSnackbar, dialogRef);
} }
public ngOnInit(): void { public ngOnInit(): void {
// TODO: not solid.
// on new poll creation, poll.options does not exist, so we have to build a substitute from the assignment candidates // on new poll creation, poll.options does not exist, so we have to build a substitute from the assignment candidates
this.options = this.pollData.options this.options = this.pollData.options
? this.pollData.options ? this.pollData.options
@ -154,85 +153,6 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent imple
} }
} }
/**
* Validates candidates input (every candidate has their options filled in),
* submits and closes the dialog if successful, else displays an error popup.
* TODO better validation
*/
public submit(): void {
/*const error = this.data.options.find(dataoption => {
this.optionPollKeys.some(key => {
const keyValue = dataoption.votes.find(o => o.value === key);
return !keyValue || keyValue.weight === undefined;
});
});
if (error) {
this.matSnackBar.open(
this.translate.instant('Please fill in the values for each candidate'),
this.translate.instant('OK'),
{
duration: 1000
}
);
} else {
this.dialogRef.close(this.data);
}*/
}
/**
* TODO: currently unused
*
* @param key poll option to be labeled
* @returns a label for a poll option
*/
public getLabel(key: CalculablePollKey): string {
// return this.pollService.getLabel(key);
throw new Error('TODO');
}
/**
* Updates a vote value
*
* @param value the value to update
* @param candidate the candidate for whom to update the value
* @param newData the new value
*/
public setValue(value: PollVoteValue, candidate: ViewAssignmentOption, newData: string): void {
/*const vote = candidate.votes.find(v => v.value === value);
if (vote) {
vote.weight = parseFloat(newData);
} else {
candidate.votes.push({
value: value,
weight: parseFloat(newData)
});
}*/
}
/**
* Retrieves the current value for a voting option
*
* @param value the vote value (e.g. 'Abstain')
* @param candidate the pollOption
* @returns the currently entered number or undefined if no number has been set
*/
public getValue(value: PollVoteValue, candidate: ViewAssignmentOption): number | undefined {
/*const val = candidate.votes.find(v => v.value === value);
return val ? val.weight : undefined;*/
throw new Error('TODO');
}
/**
* Retrieves a per-poll value
*
* @param value
* @returns integer or undefined
*/
public getSumValue(value: any /*SummaryPollKey*/): number | undefined {
// return this.data[value] || undefined;
throw new Error('TODO');
}
/** /**
* Sets a per-poll value * Sets a per-poll value
* *

View File

@ -1,8 +1,4 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<!-- Poll progress bar -->
<div *osPerms="'assignments.can_manage_polls'; and: poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<ng-container *ngIf="vmanager.canVote(poll)"> <ng-container *ngIf="vmanager.canVote(poll)">
<!-- TODO: Someone should make this pretty --> <!-- TODO: Someone should make this pretty -->

View File

@ -50,7 +50,10 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
} }
public ngOnInit(): void { public ngOnInit(): void {
this.defineVoteOptions(); if (this.poll) {
this.defineVoteOptions();
}
this.subscriptions.push( this.subscriptions.push(
this.voteRepo.getViewModelListObservable().subscribe(votes => { this.voteRepo.getViewModelListObservable().subscribe(votes => {
this.votes = votes; this.votes = votes;
@ -91,8 +94,6 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
} }
protected updateVotes(): void { protected updateVotes(): void {
console.log('currentVotes: ', this.currentVotes);
if (this.user && this.votes && this.poll) { if (this.user && this.votes && this.poll) {
const filtered = this.votes.filter( const filtered = this.votes.filter(
vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id vote => vote.option.poll_id === this.poll.id && vote.user_id === this.user.id

View File

@ -1,64 +1,75 @@
<mat-card class="os-card" *ngIf="poll && showPoll()"> <mat-card class="os-card" *ngIf="poll && showPoll()">
<div class="assignment-poll-wrapper"> <div class="assignment-poll-wrapper">
<div class="assignment-poll-title-header"> <div>
<!-- Title -->
<mat-card-title> <mat-card-title>
<a routerLink="/assignments/polls/{{ poll.id }}"> <a routerLink="/assignments/polls/{{ poll.id }}">
{{ poll.title }} {{ poll.title }}
</a> </a>
</mat-card-title> </mat-card-title>
<div class="poll-properties">
<mat-chip <!-- Type and State -->
*osPerms="'assignments.can_manage_polls'" <div>
class="poll-state active" <span *ngIf="poll.type !== 'analog'"> {{ poll.typeVerbose | translate }} &middot; </span>
[disableRipple]="true" <span>
[matMenuTriggerFor]="triggerMenu"
[class]="poll.stateVerbose.toLowerCase()"
[ngClass]="{ disabled: !poll.getNextStates() }"
>
{{ poll.stateVerbose | translate }} {{ poll.stateVerbose | translate }}
</mat-chip>
<span *ngIf="poll.type !== 'analog'">
{{ poll.typeVerbose | translate }}
</span> </span>
</div> </div>
<!-- Menu -->
<div class="poll-menu"> <div class="poll-menu">
<!-- Buttons --> <!-- Buttons -->
<button <button
mat-icon-button mat-icon-button
*osPerms="'assignments.motions.can_manage_polls';or: 'core.can_manage_projector'" *osPerms="'assignments.motions.can_manage_polls'; or: 'core.can_manage_projector'"
[matMenuTriggerFor]="pollItemMenu" [matMenuTriggerFor]="pollItemMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<mat-icon>more_horiz</mat-icon> <mat-icon>more_horiz</mat-icon>
</button> </button>
</div> </div>
<!-- Change state button -->
<div *osPerms="'assignments.can_manage_polls'">
<button
mat-stroked-button
*ngIf="!poll.isPublished"
[ngClass]="pollStateActions[poll.state].css"
(click)="changeState(poll.nextState)"
>
<mat-icon> {{ pollStateActions[poll.state].icon }}</mat-icon>
<span class="next-state-label">
{{ poll.nextStateActionVerbose | translate }}
</span>
</button>
</div>
</div> </div>
<div *ngIf="hasVotes"> <div *ngIf="hasVotes">
<os-charts <os-charts
[class]="chartType === 'doughnut' ? 'doughnut-chart' : 'bar-chart'"
[type]="chartType" [type]="chartType"
[labels]="candidatesLabels" [labels]="candidatesLabels"
[data]="chartDataSubject" [data]="chartDataSubject"
[hasPadding]="false" [hasPadding]="false"
></os-charts> ></os-charts>
</div> </div>
<!-- Poll progress bar -->
<div *osPerms="'assignments.can_manage_polls'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<os-assignment-poll-vote *ngIf="poll.canBeVotedFor" [poll]="poll"></os-assignment-poll-vote> <os-assignment-poll-vote *ngIf="poll.canBeVotedFor" [poll]="poll"></os-assignment-poll-vote>
<div class="poll-detail-button-wrapper"> <div class="poll-detail-button-wrapper">
<a mat-button routerLink="/assignments/polls/{{ poll.id }}"> <a mat-icon-button routerLink="/assignments/polls/{{ poll.id }}" matTooltip="{{ 'More' | translate }}">
{{ 'More' | translate }} <mat-icon class="small-icon">
visibility
</mat-icon>
</a> </a>
</div> </div>
</div> </div>
</mat-card> </mat-card>
<mat-menu #triggerMenu="matMenu">
<ng-container *ngIf="poll">
<button mat-menu-item (click)="changeState(state.value)" *ngFor="let state of poll.getNextStates() | keyvalue">
<span translate>{{ state.key }}</span>
</button>
</ng-container>
</mat-menu>
<mat-menu #pollItemMenu="matMenu"> <mat-menu #pollItemMenu="matMenu">
<div *osPerms="'assignments.can_manage'"> <div *osPerms="'assignments.can_manage'">
<button mat-menu-item (click)="openDialog()"> <button mat-menu-item (click)="openDialog()">
@ -75,6 +86,11 @@
<span translate>Ballot paper</span> <span translate>Ballot paper</span>
</button> </button>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<!-- Reset Button -->
<button mat-menu-item (click)="resetState()">
<mat-icon color="warn">replay</mat-icon>
<span translate>Reset state</span>
</button>
<button mat-menu-item class="red-warning-text" (click)="onDeletePoll()"> <button mat-menu-item class="red-warning-text" (click)="onDeletePoll()">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
<span translate>Delete</span> <span translate>Delete</span>

View File

@ -1,5 +1,6 @@
@import '~assets/styles/poll-colors.scss';
.assignment-poll-wrapper { .assignment-poll-wrapper {
@import '~assets/styles/poll-common-styles.scss';
position: relative; position: relative;
margin: 0 15px; margin: 0 15px;
@ -9,37 +10,6 @@
right: 0; right: 0;
} }
.poll-properties {
margin: 4px 0;
.mat-chip {
margin: 0 4px;
&.active {
cursor: pointer;
}
}
.poll-state {
&.created {
background-color: #2196f3;
color: white;
}
&.started {
background-color: #4caf50;
color: white;
}
&.finished {
background-color: #ff5252;
color: white;
}
&.published {
background-color: #ffd800;
color: black;
}
}
}
.poll-detail-button-wrapper { .poll-detail-button-wrapper {
display: flex; display: flex;
margin: auto 0; margin: auto 0;
@ -47,4 +17,23 @@
margin-left: auto; margin-left: auto;
} }
} }
.start-poll-button {
color: green !important;
}
.stop-poll-button {
color: $poll-stop-color;
}
.publish-poll-button {
color: $poll-publish-color;
}
.doughnut-chart {
display: block;
max-width: 300px;
margin-left: auto;
margin-right: auto;
}
} }

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module'; import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component'; import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component';
import { AssignmentPollComponent } from './assignment-poll.component'; import { AssignmentPollComponent } from './assignment-poll.component';
@ -11,8 +12,8 @@ describe('AssignmentPollComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [AssignmentPollComponent, AssignmentPollVoteComponent], imports: [E2EImportsModule],
imports: [E2EImportsModule] declarations: [AssignmentPollComponent, AssignmentPollVoteComponent, PollProgressComponent]
}).compileComponents(); }).compileComponents();
})); }));

View File

@ -15,7 +15,6 @@ import { BasePollComponent } from 'app/site/polls/components/base-poll.component
import { PollService } from 'app/site/polls/services/poll.service'; import { PollService } from 'app/site/polls/services/poll.service';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service'; import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service';
import { ViewAssignmentOption } from '../../models/view-assignment-option';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
/** /**
@ -52,9 +51,6 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
public descriptionForm: FormGroup; public descriptionForm: FormGroup;
/** /**
* permission checks.
* TODO stub
*
* @returns true if the user is permitted to do operations * @returns true if the user is permitted to do operations
*/ */
public get canManage(): boolean { public get canManage(): boolean {
@ -93,9 +89,6 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
} }
public ngOnInit(): void { public ngOnInit(): void {
/*this.majorityChoice =
this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) ||
null;*/
this.descriptionForm = this.formBuilder.group({ this.descriptionForm = this.formBuilder.group({
description: this.poll ? this.poll.description : '' description: this.poll ? this.poll.description : ''
}); });
@ -115,70 +108,4 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
(this.poll.type !== 'analog' && this.poll.isStarted) (this.poll.type !== 'analog' && this.poll.isStarted)
); );
} }
/**
* Determines whether the candidate has reached the majority needed to pass
* the quorum
*
* @param option
* @returns true if the quorum is successfully met
*/
public quorumReached(option: ViewAssignmentOption): boolean {
/*const yesValue = this.poll.pollmethod === 'votes' ? 'Votes' : 'Yes';
const amount = option.votes.find(v => v.value === yesValue).weight;
const yesQuorum = this.pollService.yesQuorum(
this.majorityChoice,
this.pollService.calculationDataFromPoll(this.poll),
option
);
return yesQuorum && amount >= yesQuorum;*/
throw new Error('TODO');
}
/**
* Mark/unmark an option as elected
*
* @param option
*/
public toggleElected(option: ViewAssignmentOption): void {
/*if (!this.operator.hasPerms('assignments.can_manage')) {
return;
}
// TODO additional conditions: assignment not finished?
const viewAssignmentRelatedUser = this.assignment.assignment_related_users.find(
user => user.user_id === option.candidate_id
);
if (viewAssignmentRelatedUser) {
this.assignmentRepo.markElected(viewAssignmentRelatedUser, this.assignment, !option.is_elected);
}*/
}
/**
* Sends the edited poll description to the server
* TODO: Better feedback
*/
public async onEditDescriptionButton(): Promise<void> {
/*const desc: string = this.descriptionForm.get('description').value;
await this.assignmentRepo.updatePoll({ description: desc }, this.poll).catch(this.raiseError);*/
}
/**
* Fetches a tooltip string about the quorum
* @param option
* @returns a translated
*/
public getQuorumReachedString(option: ViewAssignmentOption): string {
/*const name = this.translate.instant(this.majorityChoice.display_name);
const quorum = this.pollService.yesQuorum(
this.majorityChoice,
this.pollService.calculationDataFromPoll(this.poll),
option
);
const isReached = this.quorumReached(option)
? this.translate.instant('reached')
: this.translate.instant('not reached');
return `${name} (${quorum}) ${isReached}`;*/
throw new Error('TODO');
}
} }

View File

@ -2,10 +2,9 @@ import { BehaviorSubject } from 'rxjs';
import { ChartData } from 'app/shared/components/charts/charts.component'; import { ChartData } from 'app/shared/components/charts/charts.component';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { 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 { PollTableData, ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { PollTableData, ViewBasePoll, VotingResult } from 'app/site/polls/models/view-base-poll';
import { ViewAssignment } from './view-assignment'; import { ViewAssignment } from './view-assignment';
import { ViewAssignmentOption } from './view-assignment-option'; import { ViewAssignmentOption } from './view-assignment-option';
@ -35,7 +34,6 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
} }
public getSlide(): ProjectorElementBuildDeskriptor { public getSlide(): ProjectorElementBuildDeskriptor {
// TODO: update to new voting system?
return { return {
getBasicProjectorElement: options => ({ getBasicProjectorElement: options => ({
name: AssignmentPoll.COLLECTIONSTRING, name: AssignmentPoll.COLLECTIONSTRING,
@ -49,27 +47,36 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
} }
public generateTableData(): PollTableData[] { public generateTableData(): PollTableData[] {
const data = this.options const tableData: PollTableData[] = this.options.map(candidate => ({
.map(candidate => ({ votingOption: candidate.user.short_name,
yes: candidate.yes, votingOptionSubtitle: candidate.user.getLevelAndNumber(),
no: candidate.no,
abstain: candidate.abstain, value: this.voteTableKeys.map(
user: candidate.user.full_name, key =>
showPercent: true ({
vote: key.vote,
amount: candidate[key.vote],
icon: key.icon,
hide: key.hide,
showPercent: key.showPercent
} as VotingResult)
)
}));
tableData.push(
...this.sumTableKeys.map(key => ({
votingOption: key.vote,
value: [
{
amount: this[key.vote],
hide: key.hide,
showPercent: key.showPercent
} as VotingResult
]
})) }))
.sort((a, b) => b.yes - a.yes); );
return data; return tableData;
}
/**
* Override from base poll to skip started state in analog poll type
*/
public getNextStates(): { [key: number]: string } {
if (this.poll.type === 'analog' && this.state === PollState.Created) {
return null;
}
return super.getNextStates();
} }
} }

View File

@ -4,6 +4,7 @@ import { TitleInformationWithAgendaItem } from 'app/site/base/base-view-model-wi
import { BaseViewModelWithAgendaItemAndListOfSpeakers } from 'app/site/base/base-view-model-with-agenda-item-and-list-of-speakers'; import { BaseViewModelWithAgendaItemAndListOfSpeakers } from 'app/site/base/base-view-model-with-agenda-item-and-list-of-speakers';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
import { HasViewPolls } from 'app/site/polls/models/has-view-polls';
import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { ViewAssignmentPoll } from './view-assignment-poll'; import { ViewAssignmentPoll } from './view-assignment-poll';
@ -102,9 +103,8 @@ export class ViewAssignment extends BaseViewModelWithAgendaItemAndListOfSpeakers
}; };
} }
} }
interface IAssignmentRelations { interface IAssignmentRelations extends HasViewPolls<ViewAssignmentPoll> {
assignment_related_users: ViewAssignmentRelatedUser[]; assignment_related_users: ViewAssignmentRelatedUser[];
polls: ViewAssignmentPoll[];
tags?: ViewTag[]; tags?: ViewTag[];
attachments?: ViewMediafile[]; attachments?: ViewMediafile[];
} }

View File

@ -185,11 +185,11 @@ export class AssignmentPdfService {
const tableData = poll.generateTableData(); const tableData = poll.generateTableData();
for (const pollResult of tableData) { for (const pollResult of tableData) {
const voteOption = this.translate.instant(this.pollKeyVerbose.transform(pollResult.votingOption));
const resultLine = this.getPollResult(pollResult, poll); const resultLine = this.getPollResult(pollResult, poll);
const tableLine = [ const tableLine = [
{ {
text: pollResult.user text: voteOption
}, },
{ {
text: resultLine text: resultLine
@ -217,11 +217,13 @@ export class AssignmentPdfService {
* Converts pollData to a printable string representation * Converts pollData to a printable string representation
*/ */
private getPollResult(votingResult: PollTableData, poll: ViewAssignmentPoll): string { private getPollResult(votingResult: PollTableData, poll: ViewAssignmentPoll): string {
const resultList = poll.pollmethodFields.map(field => { const resultList = votingResult.value.map(singleResult => {
const votingKey = this.translate.instant(this.pollKeyVerbose.transform(field)); const votingKey = this.translate.instant(this.pollKeyVerbose.transform(singleResult.vote));
const resultValue = this.parsePollNumber.transform(votingResult[field]); const resultValue = this.parsePollNumber.transform(singleResult.amount);
const resultInPercent = this.pollPercentBase.transform(votingResult[field], poll); const resultInPercent = this.pollPercentBase.transform(singleResult.amount, poll);
return `${votingKey}: ${resultValue} ${resultInPercent ? resultInPercent : ''}`; return `${votingKey}${!!votingKey ? ': ' : ''}${resultValue} ${
singleResult.showPercent && resultInPercent ? resultInPercent : ''
}`;
}); });
return resultList.join('\n'); return resultList.join('\n');
} }

View File

@ -4,7 +4,6 @@ import { MatDialog } from '@angular/material';
import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component'; import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component';
import { AssignmentPollService } from './assignment-poll.service';
import { ViewAssignmentPoll } from '../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../models/view-assignment-poll';
/** /**
@ -16,7 +15,7 @@ import { ViewAssignmentPoll } from '../models/view-assignment-poll';
export class AssignmentPollDialogService extends BasePollDialogService<ViewAssignmentPoll> { export class AssignmentPollDialogService extends BasePollDialogService<ViewAssignmentPoll> {
protected dialogComponent = AssignmentPollDialogComponent; protected dialogComponent = AssignmentPollDialogComponent;
public constructor(dialog: MatDialog, mapper: CollectionStringMapperService, service: AssignmentPollService) { public constructor(dialog: MatDialog, mapper: CollectionStringMapperService) {
super(dialog, mapper, service); super(dialog, mapper);
} }
} }

View File

@ -113,7 +113,6 @@ export class AssignmentPollPdfService extends PollPdfService {
* @param title The identifier of the motion * @param title The identifier of the motion
* @param subtitle The actual motion title * @param subtitle The actual motion title
*/ */
// TODO: typing of result
protected createBallot(data: AbstractPollData): object { protected createBallot(data: AbstractPollData): object {
return { return {
columns: [ columns: [
@ -137,7 +136,6 @@ export class AssignmentPollPdfService extends PollPdfService {
}; };
} }
// TODO: typing of result
private createCandidateFields(poll: ViewAssignmentPoll): object { private createCandidateFields(poll: ViewAssignmentPoll): object {
const candidates = poll.options.sort((a, b) => { const candidates = poll.options.sort((a, b) => {
return a.weight - b.weight; return a.weight - b.weight;
@ -147,15 +145,23 @@ export class AssignmentPollPdfService extends PollPdfService {
? this.createBallotOption(cand.user.full_name) ? this.createBallotOption(cand.user.full_name)
: this.createYNBallotEntry(cand.user.full_name, poll.pollmethod); : this.createYNBallotEntry(cand.user.full_name, poll.pollmethod);
}); });
if (poll.pollmethod === 'votes') { if (poll.pollmethod === 'votes') {
const noEntry = this.createBallotOption(this.translate.instant('No')); if (poll.global_no) {
noEntry.margin[1] = 25; const noEntry = this.createBallotOption(this.translate.instant('No'));
resultObject.push(noEntry); noEntry.margin[1] = 25;
resultObject.push(noEntry);
}
if (poll.global_abstain) {
const abstainEntry = this.createBallotOption(this.translate.instant('Abstain'));
abstainEntry.margin[1] = 25;
resultObject.push(abstainEntry);
}
} }
return resultObject; return resultObject;
} }
// TODO: typing of result
private createYNBallotEntry(option: string, method: AssignmentPollMethods): object { private createYNBallotEntry(option: string, method: AssignmentPollMethods): object {
const choices = method === 'YNA' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No']; const choices = method === 'YNA' ? ['Yes', 'No', 'Abstain'] : ['Yes', 'No'];
const columnstack = choices.map(choice => { const columnstack = choices.map(choice => {
@ -182,7 +188,6 @@ export class AssignmentPollPdfService extends PollPdfService {
* @param poll * @param poll
* @returns pdfMake definitions * @returns pdfMake definitions
*/ */
// TODO: typing of result
private createPollHint(poll: ViewAssignmentPoll): object { private createPollHint(poll: ViewAssignmentPoll): object {
return { return {
text: poll.description || '', text: poll.description || '',

View File

@ -5,11 +5,9 @@ import { TranslateService } from '@ngx-translate/core';
import { ConstantsService } from 'app/core/core-services/constants.service'; import { ConstantsService } from 'app/core/core-services/constants.service';
import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPoll, AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { Collection } from 'app/shared/models/base/collection';
import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll'; import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll';
import { PollData, PollService } from 'app/site/polls/services/poll.service'; import { PollData, PollService } from 'app/site/polls/services/poll.service';
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -25,6 +23,10 @@ export class AssignmentPollService extends PollService {
*/ */
public defaultMajorityMethod: MajorityMethod; public defaultMajorityMethod: MajorityMethod;
public defaultGroupIds: number[];
public defaultPollMethod: AssignmentPollMethods;
/** /**
* Constructor. Subscribes to the configuration values needed * Constructor. Subscribes to the configuration values needed
* @param config ConfigService * @param config ConfigService
@ -42,16 +44,21 @@ export class AssignmentPollService extends PollService {
config config
.get<MajorityMethod>('motion_poll_default_majority_method') .get<MajorityMethod>('motion_poll_default_majority_method')
.subscribe(method => (this.defaultMajorityMethod = method)); .subscribe(method => (this.defaultMajorityMethod = method));
config.get<number[]>(AssignmentPoll.defaultGroupsConfig).subscribe(ids => (this.defaultGroupIds = ids));
config
.get<AssignmentPollMethods>(AssignmentPoll.defaultPollMethodConfig)
.subscribe(method => (this.defaultPollMethod = method));
} }
public fillDefaultPollData(poll: Partial<ViewAssignmentPoll> & Collection): void { public getDefaultPollData(): AssignmentPoll {
super.fillDefaultPollData(poll); const poll = new AssignmentPoll(super.getDefaultPollData());
const length = this.pollRepo.getViewModelList().filter(item => item.assignment_id === poll.assignment_id) const length = this.pollRepo.getViewModelList().filter(item => item.assignment_id === poll.assignment_id)
.length; .length;
poll.title = !length ? this.translate.instant('Ballot') : `${this.translate.instant('Ballot')} (${length + 1})`; poll.title = !length ? this.translate.instant('Ballot') : `${this.translate.instant('Ballot')} (${length + 1})`;
poll.pollmethod = AssignmentPollMethods.YN; poll.pollmethod = this.defaultPollMethod;
poll.assignment_id = poll.assignment_id;
return poll;
} }
private sumOptionsYN(poll: PollData): number { private sumOptionsYN(poll: PollData): number {

View File

@ -1,9 +1,8 @@
import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { 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';
import { PollTableData, ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { PollTableData, ViewBasePoll, VotingResult } from 'app/site/polls/models/view-base-poll';
import { ViewMotion } from './view-motion'; import { ViewMotion } from './view-motion';
export interface MotionPollTitleInformation { export interface MotionPollTitleInformation {
@ -15,58 +14,12 @@ 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: TableKey[] = [
{
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 result(): ViewMotionOption { public get result(): ViewMotionOption {
return this.options[0]; return this.options[0];
} }
@ -80,22 +33,30 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
} }
public generateTableData(): PollTableData[] { public generateTableData(): PollTableData[] {
let tableData = this.options.flatMap(vote => let tableData: PollTableData[] = this.options.flatMap(vote =>
this.tableKeys.map(key => ({ this.voteTableKeys.map(key => this.createTableDataEntry(key, vote))
key: key.vote,
value: vote[key.vote],
canHide: key.canHide,
icon: key.icon,
showPercent: key.showPercent
}))
); );
tableData.push( tableData.push(...this.sumTableKeys.map(key => this.createTableDataEntry(key)));
...this.voteKeys.map(key => ({ key: key.vote, value: this[key.vote], showPercent: key.showPercent }))
); tableData = tableData.filter(localeTableData => !localeTableData.value.some(result => result.hide));
tableData = tableData.filter(entry => entry.canHide === false || entry.value || entry.value !== -2);
return tableData; return tableData;
} }
private createTableDataEntry(result: VotingResult, vote?: ViewMotionOption): PollTableData {
return {
votingOption: result.vote,
value: [
{
amount: vote ? vote[result.vote] : this[result.vote],
hide: result.hide,
icon: result.icon,
showPercent: result.showPercent
}
]
};
}
public getSlide(): ProjectorElementBuildDeskriptor { public getSlide(): ProjectorElementBuildDeskriptor {
return { return {
getBasicProjectorElement: options => ({ getBasicProjectorElement: options => ({
@ -116,16 +77,6 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
public anySpecialVotes(): boolean { public anySpecialVotes(): boolean {
return this.result.yes < 0 || this.result.no < 0 || this.result.abstain < 0; return this.result.yes < 0 || this.result.no < 0 || this.result.abstain < 0;
} }
/**
* Override from base poll to skip started state in analog poll type
*/
public getNextStates(): { [key: number]: string } {
if (this.poll.type === 'analog' && this.state === PollState.Created) {
return null;
}
return super.getNextStates();
}
} }
export interface ViewMotionPoll extends MotionPoll { export interface ViewMotionPoll extends MotionPoll {

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module'; import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { ManageSubmittersComponent } from '../manage-submitters/manage-submitters.component'; import { ManageSubmittersComponent } from '../manage-submitters/manage-submitters.component';
import { MotionCommentsComponent } from '../motion-comments/motion-comments.component'; import { MotionCommentsComponent } from '../motion-comments/motion-comments.component';
import { MotionDetailDiffComponent } from '../motion-detail-diff/motion-detail-diff.component'; import { MotionDetailDiffComponent } from '../motion-detail-diff/motion-detail-diff.component';
@ -26,7 +27,8 @@ describe('MotionDetailComponent', () => {
MotionPollComponent, MotionPollComponent,
MotionDetailOriginalChangeRecommendationsComponent, MotionDetailOriginalChangeRecommendationsComponent,
MotionDetailDiffComponent, MotionDetailDiffComponent,
MotionPollVoteComponent MotionPollVoteComponent,
PollProgressComponent
] ]
}).compileComponents(); }).compileComponents();
})); }));

View File

@ -64,6 +64,7 @@ import { LocalPermissionsService } from 'app/site/motions/services/local-permiss
import { MotionFilterListService } from 'app/site/motions/services/motion-filter-list.service'; import { MotionFilterListService } from 'app/site/motions/services/motion-filter-list.service';
import { MotionPdfExportService } from 'app/site/motions/services/motion-pdf-export.service'; import { MotionPdfExportService } from 'app/site/motions/services/motion-pdf-export.service';
import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service'; import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service';
import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { MotionSortListService } from 'app/site/motions/services/motion-sort-list.service'; import { MotionSortListService } from 'app/site/motions/services/motion-sort-list.service';
import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
@ -467,7 +468,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
private motionFilterService: MotionFilterListService, private motionFilterService: MotionFilterListService,
private amendmentFilterService: AmendmentFilterListService, private amendmentFilterService: AmendmentFilterListService,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private pollDialog: MotionPollDialogService private pollDialog: MotionPollDialogService,
private motionPollService: MotionPollService
) { ) {
super(title, translate, matSnackBar); super(title, translate, matSnackBar);
} }
@ -1625,9 +1627,15 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
this.cd.markForCheck(); this.cd.markForCheck();
} }
public openDialog(poll?: ViewMotionPoll): void { public openDialog(): void {
this.pollDialog.openDialog( // TODO: Could be simpler, requires a lot of refactoring
poll ? poll : { collectionString: ViewMotionPoll.COLLECTIONSTRING, motion_id: this.motion.id } const dialogData = {
); collectionString: ViewMotionPoll.COLLECTIONSTRING,
motion_id: this.motion.id,
motion: this.motion,
...this.motionPollService.getDefaultPollData()
};
this.pollDialog.openDialog(dialogData);
} }
} }

View File

@ -18,97 +18,113 @@
<ng-template #viewTemplate> <ng-template #viewTemplate>
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<h1>{{ poll.title }}</h1> <h1>{{ poll.title }}</h1>
<span *ngIf="poll.type !== 'analog'">{{ poll.typeVerbose | translate }}</span>
<div>
<!-- Subtitle -->
<span *ngIf="poll.type !== 'analog'"> {{ poll.typeVerbose | translate }} &middot; </span>
<!-- State chip -->
<span>
{{ poll.stateVerbose }}
</span>
</div>
<div *ngIf="!poll.hasVotes || !poll.stateHasVotes">{{ 'No results to show' | translate }}</div> <div *ngIf="!poll.hasVotes || !poll.stateHasVotes">{{ 'No results to show' | translate }}</div>
<div *ngIf="poll.stateHasVotes"> <div *ngIf="poll.stateHasVotes">
<h2 translate>Result</h2>
<div class="result-wrapper" *ngIf="poll.hasVotes"> <div class="result-wrapper" *ngIf="poll.hasVotes">
<!-- Chart -->
<os-charts
class="result-chart"
*ngIf="chartDataSubject.value"
[type]="chartType"
[data]="chartDataSubject"
></os-charts>
<!-- result table --> <!-- result table -->
<table class="result-table"> <table class="result-table">
<tbody> <tbody>
<tr> <tr>
<th></th> <th></th>
<th translate>Votes</th> <th colspan="2" translate>Votes</th>
</tr> </tr>
<tr *ngFor="let row of poll.tableData"> <tr *ngFor="let row of poll.tableData" [class]="row.votingOption">
<!-- YNA/Valid etc -->
<td> <td>
<os-icon-container *ngIf="row.icon" [icon]="row.icon"> <os-icon-container *ngIf="row.value[0].icon" [icon]="row.value[0].icon">
{{ row.key | pollKeyVerbose | translate }} {{ row.votingOption | pollKeyVerbose | translate }}
</os-icon-container> </os-icon-container>
<span *ngIf="!row.icon"> <span *ngIf="!row.value[0].icon">
{{ row.key | pollKeyVerbose | translate }} {{ row.votingOption | pollKeyVerbose | translate }}
</span> </span>
</td> </td>
<!-- Percent numbers -->
<td class="result-cell-definition"> <td class="result-cell-definition">
{{ row.value | parsePollNumber }} <span *ngIf="row.value[0].showPercent">
<span *ngIf="row.showPercent"> {{ row.value[0].amount | pollPercentBase: poll }}
{{ row.value | pollPercentBase: poll }}
</span> </span>
</td> </td>
<!-- Voices -->
<td class="result-cell-definition">
{{ row.value[0].amount | parsePollNumber }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Named table: only show if votes are present --> <!-- Chart -->
<div class="named-result-table" *ngIf="poll.type === 'named'"> <div class="doughnut-chart">
<h3>{{ 'Single votes' | translate }}</h3> <os-charts
<os-list-view-table *ngIf="chartDataSubject.value"
[listObservable]="votesDataObservable" [type]="chartType"
[columns]="columnDefinition" [data]="chartDataSubject"
[filterProps]="filterProps" [showLegend]="false"
[allowProjector]="false" [hasPadding]="false"
[fullScreen]="false" ></os-charts>
[vScrollFixed]="60"
listStorageKey="motion-poll-vote"
[cssClasses]="{ 'single-votes-table': true }"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<div *ngIf="vote.user">{{ vote.user.getFullName() }}</div>
<div *ngIf="!vote.user">{{ 'Anonymous' | translate }}</div>
</div>
<div *pblNgridCellDef="'vote'; row as vote" class="vote-cell">
<div class="vote-cell-icon-container" [ngClass]="voteOptionStyle[vote.value].css">
<mat-icon>{{ voteOptionStyle[vote.value].icon }}</mat-icon>
</div>
<div>{{ vote.valueVerbose | translate }}</div>
</div>
<!-- No Results -->
<div *pblNgridNoDataRef class="pbl-ngrid-no-data">
{{ 'The individual votes were made anonymous.' | translate }}
</div>
</os-list-view-table>
</div> </div>
</div> </div>
<!-- Named table: only show if votes are present -->
<div class="named-result-table" *ngIf="poll.type === 'named'">
<h2>{{ 'Single votes' | translate }}</h2>
<os-list-view-table
[listObservable]="votesDataObservable"
[columns]="columnDefinition"
[filterProps]="filterProps"
[allowProjector]="false"
[fullScreen]="true"
[vScrollFixed]="60"
listStorageKey="motion-poll-vote"
[cssClasses]="{ 'single-votes-table': true }"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<div *ngIf="vote.user">{{ vote.user.getFullName() }}</div>
<div *ngIf="!vote.user">{{ 'Anonymous' | translate }}</div>
</div>
<div *pblNgridCellDef="'vote'; row as vote" class="vote-cell">
<div class="vote-cell-icon-container" [ngClass]="voteOptionStyle[vote.value].css">
<mat-icon>{{ voteOptionStyle[vote.value].icon }}</mat-icon>
</div>
<div>{{ vote.valueVerbose | translate }}</div>
</div>
<!-- No Results -->
<div *pblNgridNoDataRef class="pbl-ngrid-no-data">
{{ 'The individual votes were made anonymous.' | translate }}
</div>
</os-list-view-table>
</div>
</div> </div>
<div class="poll-content small"> <div class="poll-content">
<div *ngIf="poll.groups && poll.type && poll.type !== 'analog'"> <small *ngIf="poll.groups && poll.type && poll.type !== 'analog'">
{{ 'Groups' | translate }}: {{ 'Groups' | translate }}:
<span *ngFor="let group of poll.groups; let i = index"> <span *ngFor="let group of poll.groups; let i = index">
{{ group.getTitle() | translate }}<span *ngIf="i < poll.groups.length - 1">, </span> {{ group.getTitle() | translate }}<span *ngIf="i < poll.groups.length - 1">, </span>
</span> </span>
</div> </small>
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div> <small>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</small>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
</div> </div>
</ng-container> </ng-container>
</ng-template> </ng-template>
@ -116,7 +132,7 @@
<!-- 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> <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(poll)">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
<span translate>Edit</span> <span translate>Edit</span>
</button> </button>

View File

@ -2,53 +2,51 @@
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
.poll-content { .poll-content {
text-align: right;
display: grid;
padding-top: 20px; padding-top: 20px;
} }
.result-wrapper { .result-wrapper {
display: grid; display: grid;
grid-gap: 10px; grid-gap: 2em;
grid-template-areas: margin: 2em;
'chart' grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
'results'
'names';
}
@include desktop { .result-table {
.result-wrapper { // display: block;
grid-template-areas: th {
'results chart' text-align: right;
'names names'; font-weight: initial;
grid-template-columns: 2fr 1fr;
}
}
.result-table {
grid-area: results;
tr {
height: 48px;
min-height: 48px;
th:first-child,
td:first-child {
padding-left: 24px;
}
th:last-child,
td:last-child {
padding-right: 24px;
} }
.result-cell-definition { tr {
text-align: center; height: 48px;
border-bottom: none !important;
.result-cell-definition {
text-align: right;
}
}
.yes {
color: $votes-yes-color;
}
.no {
color: $votes-no-color;
}
.abstain {
color: $votes-abstain-color;
} }
} }
}
.result-chart { .doughnut-chart {
grid-area: chart; display: block;
max-width: 300px; margin-top: auto;
margin-left: auto; margin-bottom: auto;
margin-right: auto; }
} }
.named-result-table { .named-result-table {

View File

@ -1,12 +1,4 @@
@import '~@angular/material/theming'; @import '~@angular/material/theming';
@mixin os-motion-poll-detail-style($theme) { @mixin os-motion-poll-detail-style($theme) {
$background: map-get($theme, background);
.result-table {
border-collapse: collapse;
tr {
border-bottom: 1px solid mat-color($background, focused-button);
}
}
} }

View File

@ -5,7 +5,7 @@ import { E2EImportsModule } from 'e2e-imports.module';
import { MotionPollDialogComponent } from './motion-poll-dialog.component'; import { MotionPollDialogComponent } from './motion-poll-dialog.component';
fdescribe('MotionPollDialogComponent', () => { describe('MotionPollDialogComponent', () => {
let component: MotionPollDialogComponent; let component: MotionPollDialogComponent;
let fixture: ComponentFixture<MotionPollDialogComponent>; let fixture: ComponentFixture<MotionPollDialogComponent>;

View File

@ -15,18 +15,18 @@ import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form
templateUrl: './motion-poll-dialog.component.html', templateUrl: './motion-poll-dialog.component.html',
styleUrls: ['./motion-poll-dialog.component.scss'] styleUrls: ['./motion-poll-dialog.component.scss']
}) })
export class MotionPollDialogComponent extends BasePollDialogComponent { export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotionPoll> {
public motionPollMethods = { YNA: MotionPollMethodsVerbose.YNA }; public motionPollMethods = { YNA: MotionPollMethodsVerbose.YNA };
@ViewChild('pollForm', { static: false }) @ViewChild('pollForm', { static: false })
protected pollForm: PollFormComponent; protected pollForm: PollFormComponent<ViewMotionPoll>;
public constructor( public constructor(
private fb: FormBuilder, private fb: FormBuilder,
title: Title, title: Title,
protected translate: TranslateService, protected translate: TranslateService,
matSnackbar: MatSnackBar, matSnackbar: MatSnackBar,
public dialogRef: MatDialogRef<BasePollDialogComponent>, public dialogRef: MatDialogRef<BasePollDialogComponent<ViewMotionPoll>>,
@Inject(MAT_DIALOG_DATA) public pollData: Partial<ViewMotionPoll> @Inject(MAT_DIALOG_DATA) public pollData: Partial<ViewMotionPoll>
) { ) {
super(title, translate, matSnackbar, dialogRef); super(title, translate, matSnackbar, dialogRef);

View File

@ -1,10 +1,10 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll">
<div *osPerms="'motions.can_manage_polls';and:poll.isStarted"> <div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress> <os-poll-progress [poll]="poll"></os-poll-progress>
</div> </div>
<ng-container *ngIf="vmanager.canVote(poll)"> <div *ngIf="vmanager.canVote(poll)" class="vote-button-grid">
<!-- Voting --> <!-- Voting -->
<p *ngFor="let option of voteOptions"> <div class="vote-button" *ngFor="let option of voteOptions">
<button <button
mat-raised-button mat-raised-button
(click)="saveVote(option.vote)" (click)="saveVote(option.vote)"
@ -13,11 +13,6 @@
<mat-icon> {{ option.icon }}</mat-icon> <mat-icon> {{ option.icon }}</mat-icon>
</button> </button>
<span class="vote-label"> {{ option.label | translate }} </span> <span class="vote-label"> {{ option.label | translate }} </span>
</p> </div>
</ng-container> </div>
<!-- TODO most of the messages are not making sense -->
<!-- <ng-container *ngIf="!vmanager.canVote(poll)">
<span>{{ vmanager.getVotePermissionErrorVerbose(poll) | translate }}</span>
</ng-container> -->
</ng-container> </ng-container>

View File

@ -1,17 +1,33 @@
@import '~assets/styles/poll-colors.scss'; @import '~assets/styles/poll-colors.scss';
.vote-button-grid {
display: grid;
grid-gap: 20px;
margin-top: 2em;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
}
.vote-button {
display: inline-grid;
grid-gap: 1em;
margin: auto;
.vote-label {
text-align: center;
}
}
.voted-yes { .voted-yes {
background-color: $votes-yes-color; background-color: $votes-yes-color;
color: $vote-active-color;
} }
.voted-no { .voted-no {
background-color: $votes-no-color; background-color: $votes-no-color;
color: $vote-active-color;
} }
.voted-abstain { .voted-abstain {
background-color: $votes-abstain-color; background-color: $votes-abstain-color;
} color: $vote-active-color;
.vote-label {
margin-left: 1em;
} }

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module'; import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { MotionPollVoteComponent } from './motion-poll-vote.component'; import { MotionPollVoteComponent } from './motion-poll-vote.component';
describe('MotionPollVoteComponent', () => { describe('MotionPollVoteComponent', () => {
@ -11,7 +12,7 @@ describe('MotionPollVoteComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [E2EImportsModule], imports: [E2EImportsModule],
declarations: [MotionPollVoteComponent] declarations: [MotionPollVoteComponent, PollProgressComponent]
}).compileComponents(); }).compileComponents();
})); }));

View File

@ -1,44 +1,68 @@
<div class="poll-preview-wrapper" *ngIf="poll && showPoll()"> <mat-card class="motion-poll-wrapper" *ngIf="poll">
<!-- Poll Infos --> <!-- Poll Infos -->
<div class="poll-title-wrapper"> <div class="poll-title-wrapper">
<!-- Title --> <!-- Title Area -->
<a class="poll-title" [routerLink]="pollLink"> <div class="poll-title-area">
{{ poll.title }} <!-- Title -->
</a> <span class="poll-title">
<a [routerLink]="pollLink">
{{ poll.title }}
</a>
</span>
<div>
<!-- Subtitle -->
<span *ngIf="pollService.isElectronicVotingEnabled && poll.type !== 'analog'">
{{ poll.typeVerbose | translate }} &middot;
</span>
<!-- State chip -->
<span>
{{ poll.stateVerbose }}
</span>
</div>
</div>
<!-- Dot Menu --> <!-- Dot Menu -->
<span class="poll-title-actions" *osPerms="'motions.can_manage_polls'"> <span class="poll-actions" *osPerms="'motions.can_manage_polls'">
<button mat-icon-button [matMenuTriggerFor]="pollDetailMenu"> <button mat-icon-button [matMenuTriggerFor]="pollDetailMenu">
<mat-icon class="small-icon">more_horiz</mat-icon> <mat-icon class="small-icon">more_horiz</mat-icon>
</button> </button>
</span> </span>
</div>
<!-- State chip --> <!-- Change state button -->
<div class="poll-properties" *osPerms="'motions.can_manage_polls'"> <div *osPerms="'motions.can_manage_polls'">
<div *ngIf="pollService.isElectronicVotingEnabled && poll.type !== 'analog'"> <button
{{ poll.typeVerbose | translate }} mat-stroked-button
</div> *ngIf="!poll.isPublished"
[ngClass]="pollStateActions[poll.state].css"
<mat-chip (click)="changeState(poll.nextState)"
disableRipple >
[matMenuTriggerFor]="triggerMenu" <mat-icon> {{ pollStateActions[poll.state].icon }}</mat-icon>
class="poll-state active" <span class="next-state-label">
[class]="poll.stateVerbose.toLowerCase()" {{ poll.nextStateActionVerbose | translate }}
[ngClass]="{ 'disabled': !poll.getNextStates() }" </span>
> </button>
{{ poll.stateVerbose }}
</mat-chip>
</div>
</div> </div>
<!-- Results --> <!-- Results -->
<ng-container *ngIf="poll && !poll.stateHasVotes && poll.type !== 'analog'; else votingResult"> <ng-container *ngIf="poll && !poll.stateHasVotes && poll.type !== 'analog'; else votingResult">
<os-motion-poll-vote [poll]="poll"></os-motion-poll-vote> <os-motion-poll-vote [poll]="poll"></os-motion-poll-vote>
</ng-container> </ng-container>
</div>
<!-- Detail link -->
<div class="poll-detail-button-wrapper">
<a mat-icon-button [routerLink]="pollLink" matTooltip="{{ 'More' | translate }}">
<mat-icon class="small-icon">
visibility
</mat-icon>
</a>
</div>
</mat-card>
<ng-template #votingResult> <ng-template #votingResult>
<div [routerLink]="pollLink" class="poll-link-wrapper"> <div class="poll-link-wrapper">
<ng-container <ng-container
[ngTemplateOutlet]="poll.hasVotes && poll.stateHasVotes ? viewTemplate : emptyTemplate" [ngTemplateOutlet]="poll.hasVotes && poll.stateHasVotes ? viewTemplate : emptyTemplate"
></ng-container> ></ng-container>
@ -46,7 +70,28 @@
</ng-template> </ng-template>
<ng-template #viewTemplate> <ng-template #viewTemplate>
<div class="poll-chart-wrapper"> <!-- Result Chart and legend -->
<div class="poll-chart-wrapper" *osPerms="'motions.can_manage_polls'; or: poll.isPublished">
<div class="vote-legend" [routerLink]="pollLink">
<div class="votes-yes">
<os-icon-container icon="thumb_up" size="large">
{{ voteYes | parsePollNumber }}
{{ voteYes | pollPercentBase: poll }}
</os-icon-container>
</div>
<div class="votes-no">
<os-icon-container icon="thumb_down" size="large">
{{ voteNo | parsePollNumber }}
{{ voteNo | pollPercentBase: poll }}
</os-icon-container>
</div>
<div class="votes-abstain">
<os-icon-container icon="trip_origin" size="large">
{{ voteAbstain | parsePollNumber }}
{{ voteAbstain | pollPercentBase: poll }}
</os-icon-container>
</div>
</div>
<div class="doughnut-chart"> <div class="doughnut-chart">
<os-charts <os-charts
*ngIf="showChart" *ngIf="showChart"
@ -57,31 +102,13 @@
> >
</os-charts> </os-charts>
</div> </div>
<div class="vote-legend">
<div class="votes-yes" *ngIf="pollService.isVoteDocumented(voteYes)">
<os-icon-container icon="thumb_up" size="large">
{{ voteYes | parsePollNumber }}
{{ voteYes | pollPercentBase: poll }}
</os-icon-container>
</div>
<div class="votes-no" *ngIf="pollService.isVoteDocumented(voteNo)">
<os-icon-container icon="thumb_down" size="large">
{{ voteNo | parsePollNumber }}
{{ voteNo | pollPercentBase: poll }}
</os-icon-container>
</div>
<div class="votes-abstain" *ngIf="pollService.isVoteDocumented(voteAbstain)">
<os-icon-container icon="trip_origin" size="large">
{{ voteAbstain | parsePollNumber }}
{{ voteAbstain | pollPercentBase: poll }}
</os-icon-container>
</div>
</div>
</div> </div>
<div class="poll-detail-button-wrapper">
<a mat-button [routerLink]="pollLink"> <!-- In Progress hint -->
{{ 'More' | translate }} <div class="motion-couting-in-progress-hint" *osPerms="'motions.can_manage_polls'; complement: true">
</a> <span *ngIf="poll.isFinished">
{{ 'Counting is in progress' | translate }}
</span>
</div> </div>
</ng-template> </ng-template>
@ -104,18 +131,16 @@
</button> </button>
<div *osPerms="'motions.can_manage_polls'"> <div *osPerms="'motions.can_manage_polls'">
<mat-divider></mat-divider> <mat-divider></mat-divider>
<!-- Reset Button -->
<button mat-menu-item (click)="resetState()">
<mat-icon color="warn">replay</mat-icon>
<span translate>Reset state</span>
</button>
<!-- Delete button -->
<button mat-menu-item (click)="deletePoll()"> <button mat-menu-item (click)="deletePoll()">
<mat-icon color="warn">delete</mat-icon> <mat-icon color="warn">delete</mat-icon>
<span translate>Delete</span> <span translate>Delete</span>
</button> </button>
</div> </div>
</mat-menu> </mat-menu>
<!-- Select state menu -->
<mat-menu #triggerMenu="matMenu">
<ng-container *ngIf="poll">
<button mat-menu-item (click)="changeState(state.value)" *ngFor="let state of poll.getNextStates() | keyvalue">
<span translate>{{ state.key }}</span>
</button>
</ng-container>
</mat-menu>

View File

@ -4,70 +4,47 @@
outline: none; outline: none;
} }
.poll-preview-wrapper { .motion-poll-wrapper {
padding: 8px; margin-bottom: 30px;
border: 1px solid rgba(0, 0, 0, 0.12);
.poll-title { .poll-title-wrapper {
text-decoration: none; display: grid;
font-weight: 500; grid-gap: 10px;
} grid-template-areas: 'title actions';
grid-template-columns: auto min-content;
.poll-title-actions { .poll-title-area {
float: right; grid-area: title;
} margin-top: 1em;
.poll-properties { .poll-title {
margin: 4px 0; font-size: 125%;
.mat-chip {
margin: 0 4px;
&.active {
cursor: pointer;
} }
} }
.poll-state { .poll-actions {
&.created { grid-area: actions;
background-color: #2196f3;
color: white;
}
&.started {
background-color: #4caf50;
color: white;
}
&.finished {
background-color: #ff5252;
color: white;
}
&.published {
background-color: #ffd800;
color: black;
}
} }
} }
.poll-chart-wrapper { .poll-chart-wrapper {
cursor: pointer; cursor: pointer;
display: grid; display: grid;
grid-gap: 10px; grid-gap: 20px;
grid-template-areas: 'placeholder chart legend'; margin: 2em;
grid-template-columns: auto minmax(50px, 20%) auto; // try to find max scale
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
.doughnut-chart { .doughnut-chart {
grid-area: chart; display: block;
max-width: 200px;
margin-top: auto; margin-top: auto;
margin-bottom: auto; margin-bottom: auto;
} }
.vote-legend { .vote-legend {
grid-area: legend;
margin-top: auto;
margin-bottom: auto;
div + div { div + div {
margin-top: 10px; margin-top: 20px;
} }
.votes-yes { .votes-yes {
@ -93,16 +70,24 @@
} }
} }
.poll-preview-meta-info { .next-state-label {
span { margin-top: auto;
padding: 0 5px; margin-bottom: auto;
}
} }
.poll-content { .start-poll-button {
padding-bottom: 8px; color: green !important;
} }
.poll-footer { .stop-poll-button {
text-align: end; color: $poll-stop-color;
}
.publish-poll-button {
color: $poll-publish-color;
}
.motion-couting-in-progress-hint {
margin-top: 1em;
font-style: italic;
} }

View File

@ -1,9 +1,4 @@
@import '~@angular/material/theming'; @import '~@angular/material/theming';
@mixin os-motion-poll-style($theme) { @mixin os-motion-poll-style($theme) {
$background: map-get($theme, background);
.poll-preview-wrapper {
background-color: mat-color($background, card);
}
} }

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module'; import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { MotionPollVoteComponent } from '../motion-poll-vote/motion-poll-vote.component'; import { MotionPollVoteComponent } from '../motion-poll-vote/motion-poll-vote.component';
import { MotionPollComponent } from './motion-poll.component'; import { MotionPollComponent } from './motion-poll.component';
@ -11,7 +12,7 @@ describe('MotionPollComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [E2EImportsModule], imports: [E2EImportsModule],
declarations: [MotionPollComponent, MotionPollVoteComponent] declarations: [MotionPollComponent, MotionPollVoteComponent, PollProgressComponent]
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {

View File

@ -4,7 +4,6 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from 'app/core/core-services/operator.service';
import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
@ -114,22 +113,12 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
public pollRepo: MotionPollRepositoryService, public pollRepo: MotionPollRepositoryService,
pollDialog: MotionPollDialogService, pollDialog: MotionPollDialogService,
public pollService: PollService, public pollService: PollService,
private operator: OperatorService,
private pdfService: MotionPollPdfService private pdfService: MotionPollPdfService
) { ) {
super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog); super(titleService, matSnackBar, translate, dialog, promptService, pollRepo, pollDialog);
} }
public showPoll(): boolean {
return (
this.operator.hasPerms('motions.can_manage_polls') ||
this.poll.isPublished ||
(this.poll.type !== 'analog' && this.poll.isStarted)
);
}
public downloadPdf(): void { public downloadPdf(): void {
console.log('picture_as_pdf');
this.pdfService.printBallots(this.poll); this.pdfService.printBallots(this.poll);
} }

View File

@ -371,13 +371,17 @@ export class MotionPdfService {
motion.polls.forEach(poll => { motion.polls.forEach(poll => {
if (poll.hasVotes) { if (poll.hasVotes) {
const tableData = poll.generateTableData(); const tableData = poll.generateTableData();
tableData.forEach(votingResult => { tableData.forEach(votingResult => {
const resultKey = this.translate.instant(this.pollKeyVerbose.transform(votingResult.key)); const votingOption = this.translate.instant(
const resultValue = this.parsePollNumber.transform(votingResult.value); this.pollKeyVerbose.transform(votingResult.votingOption)
column1.push(`${resultKey}:`); );
const value = votingResult.value[0];
const resultValue = this.parsePollNumber.transform(value.amount);
column1.push(`${votingOption}:`);
column2.push(resultValue); column2.push(resultValue);
if (votingResult.showPercent) { if (value.showPercent) {
const resultInPercent = this.pollPercentBase.transform(votingResult.value, poll); const resultInPercent = this.pollPercentBase.transform(value.amount, poll);
column3.push(resultInPercent); column3.push(resultInPercent);
} }
}); });

View File

@ -4,7 +4,6 @@ import { MatDialog } from '@angular/material';
import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component'; import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component';
import { MotionPollService } from './motion-poll.service';
import { ViewMotionPoll } from '../models/view-motion-poll'; import { ViewMotionPoll } from '../models/view-motion-poll';
/** /**
@ -16,7 +15,7 @@ import { ViewMotionPoll } from '../models/view-motion-poll';
export class MotionPollDialogService extends BasePollDialogService<ViewMotionPoll> { export class MotionPollDialogService extends BasePollDialogService<ViewMotionPoll> {
protected dialogComponent = MotionPollDialogComponent; protected dialogComponent = MotionPollDialogComponent;
public constructor(dialog: MatDialog, mapper: CollectionStringMapperService, service: MotionPollService) { public constructor(dialog: MatDialog, mapper: CollectionStringMapperService) {
super(dialog, mapper, service); super(dialog, mapper);
} }
} }

View File

@ -5,10 +5,8 @@ import { TranslateService } from '@ngx-translate/core';
import { ConstantsService } from 'app/core/core-services/constants.service'; import { ConstantsService } from 'app/core/core-services/constants.service';
import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { Collection } from 'app/shared/models/base/collection'; import { MotionPoll, MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll'; import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { PollData, PollService } from 'app/site/polls/services/poll.service'; import { PollData, PollService } from 'app/site/polls/services/poll.service';
interface PollResultData { interface PollResultData {
@ -34,6 +32,8 @@ export class MotionPollService extends PollService {
*/ */
public defaultMajorityMethod: MajorityMethod; public defaultMajorityMethod: MajorityMethod;
public defaultGroupIds: number[];
/** /**
* Constructor. Subscribes to the configuration values needed * Constructor. Subscribes to the configuration values needed
* @param config ConfigService * @param config ConfigService
@ -51,15 +51,18 @@ export class MotionPollService extends PollService {
config config
.get<MajorityMethod>('motion_poll_default_majority_method') .get<MajorityMethod>('motion_poll_default_majority_method')
.subscribe(method => (this.defaultMajorityMethod = method)); .subscribe(method => (this.defaultMajorityMethod = method));
config.get<number[]>(MotionPoll.defaultGroupsConfig).subscribe(ids => (this.defaultGroupIds = ids));
} }
public fillDefaultPollData(poll: Partial<ViewMotionPoll> & Collection): void { public getDefaultPollData(): MotionPoll {
super.fillDefaultPollData(poll); const poll = new MotionPoll(super.getDefaultPollData());
const length = this.pollRepo.getViewModelList().filter(item => item.motion_id === poll.motion_id).length; const length = this.pollRepo.getViewModelList().filter(item => item.motion_id === poll.motion_id).length;
poll.title = !length ? this.translate.instant('Vote') : `${this.translate.instant('Vote')} (${length + 1})`; poll.title = !length ? this.translate.instant('Vote') : `${this.translate.instant('Vote')} (${length + 1})`;
poll.pollmethod = MotionPollMethods.YNA; poll.pollmethod = MotionPollMethods.YNA;
poll.motion_id = poll.motion_id;
return poll;
} }
public getPercentBase(poll: PollData): number { public getPercentBase(poll: PollData): number {

View File

@ -10,9 +10,7 @@ import { BehaviorSubject, from, Observable } from 'rxjs';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { Breadcrumb } from 'app/shared/components/breadcrumb/breadcrumb.component';
import { ChartData, ChartType } from 'app/shared/components/charts/charts.component'; import { ChartData, ChartType } from 'app/shared/components/charts/charts.component';
import { PollState, PollType } from 'app/shared/models/poll/base-poll';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewGroup } from 'app/site/users/models/view-group'; import { ViewGroup } from 'app/site/users/models/view-group';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
@ -58,11 +56,6 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
*/ */
public poll: V = null; public poll: V = null;
/**
* The breadcrumbs for the poll-states.
*/
public breadcrumbs: Breadcrumb[] = [];
/** /**
* Sets the type of the shown chart, if votes are entered. * Sets the type of the shown chart, if votes are entered.
*/ */
@ -143,8 +136,8 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
/** /**
* Opens dialog for editing the poll * Opens dialog for editing the poll
*/ */
public openDialog(): void { public openDialog(viewPoll: V): void {
this.pollDialog.openDialog(this.poll); this.pollDialog.openDialog(viewPoll);
} }
protected onDeleted(): void {} protected onDeleted(): void {}
@ -198,7 +191,6 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
this.repo.getViewModelObservable(params.id).subscribe(poll => { this.repo.getViewModelObservable(params.id).subscribe(poll => {
if (poll) { if (poll) {
this.poll = poll; this.poll = poll;
this.updateBreadcrumbs();
this.onPollLoaded(); this.onPollLoaded();
this.waitForOptions(); this.waitForOptions();
this.checkData(); this.checkData();
@ -218,81 +210,4 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
this.onPollWithOptionsLoaded(); this.onPollWithOptionsLoaded();
} }
} }
/**
* Action for the different breadcrumbs.
*/
private async changeState(): Promise<void> {
this.actionWrapper(this.repo.changePollState(this.poll), this.onStateChanged);
}
/**
* Resets the state of a motion-poll.
*/
private async resetState(): Promise<void> {
this.actionWrapper(this.repo.resetPoll(this.poll), this.onStateChanged);
}
/**
* Used to execute same logic after fullfilling a promise.
*
* @param action Any promise to execute.
*
* @returns Any promise-like.
*/
private actionWrapper(action: Promise<any>, callback?: () => any): any {
action
.then(() => {
this.checkData();
if (callback) {
callback();
}
})
.catch(this.raiseError);
}
/**
* Used to change the breadcrumbs depending on the state of the given motion-poll.
*/
private updateBreadcrumbs(): void {
this.breadcrumbs = Object.values(PollState)
.filter(state => typeof state === 'string')
.map((state: string) => ({
label: state,
action: this.getBreadcrumbAction(PollState[state]),
active: this.poll ? this.poll.state === PollState[state] : false
}));
}
/**
* Depending on the state of the motion-poll, the breadcrumb has another action and state.
*
* @param state The state of the motion-poll as number.
*
* @returns An action, that is executed, if the breadcrumb is clicked, or null.
*/
private getBreadcrumbAction(state: number): () => any | null {
if (!this.poll) {
return null;
}
if (!this.hasPerms()) {
return null;
}
switch (this.poll.state) {
case PollState.Created:
return state === 2 ? () => this.changeState() : null;
case PollState.Started:
return this.poll.type !== PollType.Analog && state === 3 ? () => this.changeState() : null;
case PollState.Finished:
if (state === 1) {
return () => this.resetState();
} else if (state === 4) {
return () => this.changeState();
} else {
return null;
}
case PollState.Published:
return state === 1 ? () => this.resetState() : null;
}
}
} }

View File

@ -8,14 +8,15 @@ import { TranslateService } from '@ngx-translate/core';
import { OneOfValidator } from 'app/shared/validators/one-of-validator'; import { OneOfValidator } from 'app/shared/validators/one-of-validator';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { PollFormComponent } from './poll-form/poll-form.component'; import { PollFormComponent } from './poll-form/poll-form.component';
import { ViewBasePoll } from '../models/view-base-poll';
/** /**
* A dialog for updating the values of a poll. * A dialog for updating the values of a poll.
*/ */
export abstract class BasePollDialogComponent extends BaseViewComponent { export abstract class BasePollDialogComponent<T extends ViewBasePoll> extends BaseViewComponent {
public publishImmediately: boolean; public publishImmediately: boolean;
protected pollForm: PollFormComponent; protected pollForm: PollFormComponent<T>;
public dialogVoteForm: FormGroup; public dialogVoteForm: FormGroup;
@ -23,7 +24,7 @@ export abstract class BasePollDialogComponent extends BaseViewComponent {
title: Title, title: Title,
protected translate: TranslateService, protected translate: TranslateService,
matSnackbar: MatSnackBar, matSnackbar: MatSnackBar,
public dialogRef: MatDialogRef<BasePollDialogComponent> public dialogRef: MatDialogRef<BasePollDialogComponent<T>>
) { ) {
super(title, translate, matSnackbar); super(title, translate, matSnackbar);
} }

View File

@ -18,6 +18,21 @@ export abstract class BasePollComponent<V extends ViewBasePoll> extends BaseView
protected _poll: V; protected _poll: V;
public pollStateActions = {
[PollState.Created]: {
icon: 'play_arrow',
css: 'start-poll-button'
},
[PollState.Started]: {
icon: 'stop',
css: 'stop-poll-button'
},
[PollState.Finished]: {
icon: 'public',
css: 'publish-poll-button'
}
};
public constructor( public constructor(
titleService: Title, titleService: Title,
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
@ -41,6 +56,10 @@ export abstract class BasePollComponent<V extends ViewBasePoll> extends BaseView
} }
} }
public resetState(): void {
this.changeState(PollState.Created);
}
/** /**
* Handler for the 'delete poll' button * Handler for the 'delete poll' button
*/ */

View File

@ -7,6 +7,8 @@
</h2> </h2>
</mat-form-field> </mat-form-field>
</form> </form>
<!-- TODO: rather disable forms than duplicate them -->
<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>

View File

@ -5,8 +5,8 @@ import { E2EImportsModule } from 'e2e-imports.module';
import { PollFormComponent } from './poll-form.component'; import { PollFormComponent } from './poll-form.component';
describe('PollFormComponent', () => { describe('PollFormComponent', () => {
let component: PollFormComponent; let component: PollFormComponent<any>;
let fixture: ComponentFixture<PollFormComponent>; let fixture: ComponentFixture<PollFormComponent<any>>;
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({

View File

@ -12,6 +12,7 @@ import { PercentBase } from 'app/shared/models/poll/base-poll';
import { PollType } from 'app/shared/models/poll/base-poll'; import { PollType } from 'app/shared/models/poll/base-poll';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { import {
MajorityMethodVerbose, MajorityMethodVerbose,
PercentBaseVerbose, PercentBaseVerbose,
@ -28,7 +29,7 @@ import { PollService } from '../../services/poll.service';
templateUrl: './poll-form.component.html', templateUrl: './poll-form.component.html',
styleUrls: ['./poll-form.component.scss'] styleUrls: ['./poll-form.component.scss']
}) })
export class PollFormComponent extends BaseViewComponent implements OnInit { export class PollFormComponent<T extends ViewBasePoll> extends BaseViewComponent implements OnInit {
/** /**
* The form-group for the meta-info. * The form-group for the meta-info.
*/ */
@ -44,7 +45,7 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
public pollMethods: { [key: string]: string }; public pollMethods: { [key: string]: string };
@Input() @Input()
public data: Partial<ViewBasePoll>; public data: Partial<T>;
/** /**
* The different types the poll can accept. * The different types the poll can accept.
@ -103,18 +104,15 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
public ngOnInit(): void { public ngOnInit(): void {
this.groupObservable = this.groupRepo.getViewModelListObservable(); this.groupObservable = this.groupRepo.getViewModelListObservable();
const cast = <ViewAssignmentPoll>this.data;
if (cast.assignment && !cast.votes_amount) {
cast.votes_amount = cast.assignment.open_posts;
}
if (this.data) { if (this.data) {
if (!this.data.groups_id) { if (this.data instanceof ViewAssignmentPoll) {
if (this.data.collectionString === ViewAssignmentPoll.COLLECTIONSTRING) { if (this.data.assignment && !this.data.votes_amount) {
this.data.groups_id = this.configService.instant('assignment_poll_default_groups'); this.data.votes_amount = this.data.assignment.open_posts;
} else {
this.data.groups_id = this.configService.instant('motion_poll_default_groups');
} }
if (!this.data.pollmethod) {
this.data.pollmethod = this.configService.instant('assignment_poll_method');
}
} else if (this.data instanceof ViewMotionPoll) {
} }
Object.keys(this.contentForm.controls).forEach(key => { Object.keys(this.contentForm.controls).forEach(key => {
@ -193,26 +191,31 @@ export class PollFormComponent extends BaseViewComponent implements OnInit {
* @param data Passing the properties of the poll. * @param data Passing the properties of the poll.
*/ */
private updatePollValues(data: { [key: string]: any }): void { private updatePollValues(data: { [key: string]: any }): void {
this.pollValues = [ if (this.data) {
[this.pollService.getVerboseNameForKey('type'), this.pollService.getVerboseNameForValue('type', data.type)] this.pollValues = [
]; [
// show pollmethod only for assignment polls this.pollService.getVerboseNameForKey('type'),
if (this.data.pollClassType === PollClassType.Assignment) { this.pollService.getVerboseNameForValue('type', data.type)
this.pollValues.push([ ]
this.pollService.getVerboseNameForKey('pollmethod'), ];
this.pollService.getVerboseNameForValue('pollmethod', data.pollmethod) // show pollmethod only for assignment polls
]); if (this.data.pollClassType === PollClassType.Assignment) {
} this.pollValues.push([
if (data.type !== 'analog') { this.pollService.getVerboseNameForKey('pollmethod'),
this.pollValues.push([ this.pollService.getVerboseNameForValue('pollmethod', data.pollmethod)
this.pollService.getVerboseNameForKey('groups'), ]);
this.groupRepo.getNameForIds(...([] || (data && data.groups_id))) }
]); if (data.type !== 'analog') {
} this.pollValues.push([
if (data.pollmethod === 'votes') { this.pollService.getVerboseNameForKey('groups'),
this.pollValues.push([this.pollService.getVerboseNameForKey('votes_amount'), data.votes_amount]); this.groupRepo.getNameForIds(...([] || (data && data.groups_id)))
this.pollValues.push([this.pollService.getVerboseNameForKey('global_no'), data.global_no]); ]);
this.pollValues.push([this.pollService.getVerboseNameForKey('global_abstain'), data.global_abstain]); }
if (data.pollmethod === 'votes') {
this.pollValues.push([this.pollService.getVerboseNameForKey('votes_amount'), data.votes_amount]);
this.pollValues.push([this.pollService.getVerboseNameForKey('global_no'), data.global_no]);
this.pollValues.push([this.pollService.getVerboseNameForKey('global_abstain'), data.global_abstain]);
}
} }
} }

View File

@ -1,7 +1,12 @@
<span> <div *ngIf="poll" class="poll-progress-wrapper">
{{ 'Casted votes' | translate }}: {{ poll.votescast }} / {{ max }}, <div class="motion-vote-number" *ngIf="poll.pollClassType === 'motion'">
{{ 'valid votes' | translate}}: {{ poll.votesvalid }} / {{ max }}, <span>{{ poll.votescast }} / {{ max }}</span>
{{ 'invalid votes' | translate}}: {{ poll.votesinvalid }} / {{ max }} </div>
</span>
<mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar> <div *ngIf="poll.pollClassType === 'assignment'">
<div>{{ 'Total' | translate }}: {{ poll.votescast }} / {{ max }},</div>
<div>{{ 'Valid' | translate }}: {{ poll.votesvalid }} / {{ max }},</div>
<div>{{ 'Invalid votes' | translate }}: {{ poll.votesinvalid }} / {{ max }}</div>
</div>
<mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar>
</div>

View File

@ -0,0 +1,10 @@
.poll-progress-wrapper {
margin: 1em 0 2em 0;
.voting-progress-bar {
margin-top: 1em;
}
.motion-vote-number {
text-align: center;
}
}

View File

@ -10,7 +10,8 @@ describe('PollProgressComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [E2EImportsModule] imports: [E2EImportsModule],
declarations: [PollProgressComponent]
}).compileComponents(); }).compileComponents();
})); }));

View File

@ -30,7 +30,11 @@ export class PollProgressComponent extends BaseViewComponent implements OnInit {
} }
public get valueInPercent(): number { public get valueInPercent(): number {
return (this.poll.votesvalid / this.max) * 100; if (this.poll) {
return (this.poll.votesvalid / this.max) * 100;
} else {
return 0;
}
} }
/** /**
@ -38,15 +42,17 @@ export class PollProgressComponent extends BaseViewComponent implements OnInit {
* Sets the observable for groups. * Sets the observable for groups.
*/ */
public ngOnInit(): void { public ngOnInit(): void {
this.userRepo if (this.poll) {
.getViewModelListObservable() this.userRepo
.pipe( .getViewModelListObservable()
map(users => .pipe(
users.filter(user => user.is_present && this.poll.groups_id.intersect(user.groups_id).length) 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

@ -0,0 +1,5 @@
import { ViewBasePoll } from './view-base-poll';
export interface HasViewPolls<T extends ViewBasePoll> {
polls: T[];
}

View File

@ -1,4 +1,4 @@
import { BasePoll, PollState } from 'app/shared/models/poll/base-poll'; import { BasePoll, PercentBase, PollType } from 'app/shared/models/poll/base-poll';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
@ -16,14 +16,16 @@ export enum PollClassType {
* Interface describes the possible data for the result-table. * Interface describes the possible data for the result-table.
*/ */
export interface PollTableData { export interface PollTableData {
key?: string; votingOption: string;
value?: number; votingOptionSubtitle?: string;
yes?: number; value: VotingResult[];
no?: number; }
abstain?: number;
user?: string; export interface VotingResult {
canHide?: boolean; vote?: 'yes' | 'no' | 'abstain' | 'votesvalid' | 'votesinvalid' | 'votescast';
amount?: number;
icon?: string; icon?: string;
hide?: boolean;
showPercent?: boolean; showPercent?: boolean;
} }
@ -35,7 +37,7 @@ export const PollClassTypeVerbose = {
export const PollStateVerbose = { export const PollStateVerbose = {
1: 'Created', 1: 'Created',
2: 'Started', 2: 'Started',
3: 'Finished', 3: 'Finished (unpublished)',
4: 'Published' 4: 'Published'
}; };
@ -85,6 +87,41 @@ export const PercentBaseVerbose = {
export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends BaseProjectableViewModel<M> { export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends BaseProjectableViewModel<M> {
private _tableData: PollTableData[] = []; private _tableData: PollTableData[] = [];
protected voteTableKeys: VotingResult[] = [
{
vote: 'yes',
icon: 'thumb_up',
showPercent: true
},
{
vote: 'no',
icon: 'thumb_down',
showPercent: true
},
{
vote: 'abstain',
icon: 'trip_origin',
showPercent: this.showAbstainPercent
}
];
protected sumTableKeys: VotingResult[] = [
{
vote: 'votesvalid',
showPercent: this.poll.isPercentBaseValidOrCast
},
{
vote: 'votesinvalid',
hide: this.poll.type !== PollType.Analog,
showPercent: this.poll.isPercentBaseValidOrCast
},
{
vote: 'votescast',
hide: this.poll.type !== PollType.Analog,
showPercent: this.poll.isPercentBaseValidOrCast
}
];
public get tableData(): PollTableData[] { public get tableData(): PollTableData[] {
if (!this._tableData.length) { if (!this._tableData.length) {
this._tableData = this.generateTableData(); this._tableData = this.generateTableData();
@ -108,6 +145,10 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
return PollStateVerbose[this.state]; return PollStateVerbose[this.state];
} }
public get nextStateActionVerbose(): string {
return PollStateChangeActionVerbose[this.nextState];
}
public get typeVerbose(): string { public get typeVerbose(): string {
return PollTypeVerbose[this.type]; return PollTypeVerbose[this.type];
} }
@ -120,23 +161,14 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
return PercentBaseVerbose[this.onehundred_percent_base]; return PercentBaseVerbose[this.onehundred_percent_base];
} }
public get showAbstainPercent(): boolean {
return this.onehundred_percent_base === PercentBase.YNA;
}
public abstract readonly pollClassType: 'motion' | 'assignment'; public abstract readonly pollClassType: 'motion' | 'assignment';
public canBeVotedFor: () => boolean; public canBeVotedFor: () => boolean;
/**
* returns a mapping "verbose_state" -> "state_id" for all valid next states
*/
public getNextStates(): { [key: number]: string } {
const next_state = (this.state % Object.keys(PollStateVerbose).length) + 1;
const states = {};
states[PollStateChangeActionVerbose[next_state]] = next_state;
if (this.state === PollState.Finished) {
states[PollStateChangeActionVerbose[PollState.Created]] = PollState.Created;
}
return states;
}
public get user_has_voted_invalid(): boolean { public get user_has_voted_invalid(): boolean {
return this.options.some(option => option.user_has_voted) && !this.user_has_voted_valid; return this.options.some(option => option.user_has_voted) && !this.user_has_voted_valid;
} }

View File

@ -3,16 +3,14 @@ import { Injectable } from '@angular/core';
import { _ } from 'app/core/translate/translation-marker'; import { _ } from 'app/core/translate/translation-marker';
import { ChartData, ChartType } from 'app/shared/components/charts/charts.component'; import { ChartData, ChartType } from 'app/shared/components/charts/charts.component';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { Collection } from 'app/shared/models/base/collection';
import { MotionPollMethods } from 'app/shared/models/motions/motion-poll'; import { MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MajorityMethod, PercentBase, PollColor, PollType } from 'app/shared/models/poll/base-poll'; import { BasePoll, MajorityMethod, PercentBase, PollColor, PollType } from 'app/shared/models/poll/base-poll';
import { AssignmentPollMethodsVerbose } from 'app/site/assignments/models/view-assignment-poll'; import { AssignmentPollMethodsVerbose } from 'app/site/assignments/models/view-assignment-poll';
import { import {
MajorityMethodVerbose, MajorityMethodVerbose,
PercentBaseVerbose, PercentBaseVerbose,
PollPropertyVerbose, PollPropertyVerbose,
PollTypeVerbose, PollTypeVerbose
ViewBasePoll
} from 'app/site/polls/models/view-base-poll'; } from 'app/site/polls/models/view-base-poll';
import { ConstantsService } from '../../../core/core-services/constants.service'; import { ConstantsService } from '../../../core/core-services/constants.service';
@ -129,6 +127,11 @@ export abstract class PollService {
*/ */
public abstract defaultMajorityMethod: MajorityMethod; public abstract defaultMajorityMethod: MajorityMethod;
/**
* Per default entitled to vote
*/
public abstract defaultGroupIds: number[];
/** /**
* The majority method currently in use * The majority method currently in use
*/ */
@ -151,10 +154,13 @@ export abstract class PollService {
* Assigns the default poll data to the object. To be extended in subclasses * Assigns the default poll data to the object. To be extended in subclasses
* @param poll the poll/object to fill * @param poll the poll/object to fill
*/ */
public fillDefaultPollData(poll: Partial<ViewBasePoll> & Collection): void { public getDefaultPollData(): Partial<BasePoll> {
poll.onehundred_percent_base = this.defaultPercentBase; return {
poll.majority_method = this.defaultMajorityMethod; onehundred_percent_base: this.defaultPercentBase,
poll.type = PollType.Analog; majority_method: this.defaultMajorityMethod,
groups_id: this.defaultGroupIds,
type: PollType.Analog
};
} }
public getVerboseNameForValue(key: string, value: string): string { public getVerboseNameForValue(key: string, value: string): string {

View File

@ -48,6 +48,7 @@ export class ViewUser extends BaseProjectableViewModel<User> implements UserTitl
// Will be set by the repository // Will be set by the repository
public getFullName: () => string; public getFullName: () => string;
public getShortName: () => string; public getShortName: () => string;
public getLevelAndNumber: () => string;
/** /**
* Formats the category for search * Formats the category for search

View File

@ -5,7 +5,6 @@ export interface AssignmentSlideData {
open_posts: number; open_posts: number;
assignment_related_users: { assignment_related_users: {
user: string; user: string;
elected: boolean;
}[]; }[];
number_poll_candidates: boolean; number_poll_candidates: boolean;
} }

View File

@ -11,13 +11,11 @@
<ol *ngIf="data.data.number_poll_candidates"> <ol *ngIf="data.data.number_poll_candidates">
<li *ngFor="let candidate of data.data.assignment_related_users"> <li *ngFor="let candidate of data.data.assignment_related_users">
{{ candidate.user }} {{ candidate.user }}
<mat-icon *ngIf="candidate.elected">star</mat-icon>
</li> </li>
</ol> </ol>
<ul *ngIf="!data.data.number_poll_candidates"> <ul *ngIf="!data.data.number_poll_candidates">
<li *ngFor="let candidate of data.data.assignment_related_users"> <li *ngFor="let candidate of data.data.assignment_related_users">
{{ candidate.user }} {{ candidate.user }}
<mat-icon *ngIf="candidate.elected">star</mat-icon>
</li> </li>
</ul> </ul>
</ng-container> </ng-container>

View File

@ -99,6 +99,10 @@
font-weight: 400; font-weight: 400;
} }
.user-subtitle {
color: mat-color($foreground, secondary-text);
}
mat-card-header { mat-card-header {
background-color: mat-color($background, app-bar); background-color: mat-color($background, app-bar);
} }
@ -162,10 +166,6 @@
align-items: stretch; align-items: stretch;
} }
.mat-progress-bar-buffer {
background-color: mat-color($background, card) !important;
}
.primary-foreground { .primary-foreground {
color: mat-color($primary); color: mat-color($primary);
} }

View File

@ -1,6 +1,10 @@
/** /**
* Define the colors used for yes, no and abstain * Define the colors used for yes, no and abstain
*/ */
$votes-yes-color: #9fd773; $votes-yes-color: #4caf50;
$votes-no-color: #cc6c5b; $votes-no-color: #cc6c5b;
$votes-abstain-color: #a6a6a6; $votes-abstain-color: #a6a6a6;
$vote-active-color: white;
$poll-create-color: #4caf50;
$poll-stop-color: #ff5252;
$poll-publish-color: #e6b100;

View File

@ -1,63 +0,0 @@
.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%;
}
}
}
.poll-progress-bar {
mat-progress-bar {
&.progress-green {
.mat-progress-bar-fill::after {
background-color: #4caf50;
}
.mat-progress-bar-buffer {
background-color: #d5ecd5;
}
}
&.progress-red {
.mat-progress-bar-fill::after {
background-color: #f44336;
}
.mat-progress-bar-buffer {
background-color: #fcd2cf;
}
}
&.progress-yellow {
.mat-progress-bar-fill::after {
background-color: #ffc107;
}
.mat-progress-bar-buffer {
background-color: #fff0c4;
}
}
}
}
.poll-quorum-line {
display: flex;
vertical-align: bottom;
.mat-button {
padding: 1px;
}
}
.main-nav-color {
color: rgba(0, 0, 0, 0.54);
}

View File

@ -30,6 +30,7 @@
@import './app/shared/components/banner/banner.component.scss-theme.scss'; @import './app/shared/components/banner/banner.component.scss-theme.scss';
@import './app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss-theme.scss'; @import './app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss-theme.scss';
@import './app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss'; @import './app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss';
@import './app/site/assignments/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss';
/** fonts */ /** fonts */
@import './assets/styles/fonts.scss'; @import './assets/styles/fonts.scss';
@ -60,6 +61,7 @@ $narrow-spacing: (
@include os-banner-style($theme); @include os-banner-style($theme);
@include os-motion-poll-style($theme); @include os-motion-poll-style($theme);
@include os-motion-poll-detail-style($theme); @include os-motion-poll-detail-style($theme);
@include os-assignment-poll-detail-style($theme);
} }
/** Load projector specific SCSS values */ /** Load projector specific SCSS values */

View File

@ -51,12 +51,26 @@ def get_config_variables():
subgroup="Voting", subgroup="Voting",
) )
yield ConfigVariable(
name="assignment_poll_method",
default_value="votes",
input_type="choice",
label="Preselected poll method",
choices=tuple(
{"value": method[0], "display_name": method[1]}
for method in AssignmentPoll.POLLMETHODS
),
weight=415,
group="Elections",
subgroup="Voting",
)
yield ConfigVariable( yield ConfigVariable(
name="assignment_poll_add_candidates_to_list_of_speakers", name="assignment_poll_add_candidates_to_list_of_speakers",
default_value=True, default_value=True,
input_type="boolean", input_type="boolean",
label="Put all candidates on the list of speakers", label="Put all candidates on the list of speakers",
weight=415, weight=420,
group="Elections", group="Elections",
subgroup="Voting", subgroup="Voting",
) )

View File

@ -175,7 +175,12 @@ class Migration(migrations.Migration):
model_name="assignmentpoll", model_name="assignmentpoll",
name="pollmethod", name="pollmethod",
field=models.CharField( field=models.CharField(
choices=[("YN", "YN"), ("YNA", "YNA"), ("votes", "votes")], max_length=5 choices=[
("votes", "Yes per candidate"),
("YN", "Yes/No per candidate"),
("YNA", "Yes/No/Abstain per candidate"),
],
max_length=5,
), ),
), ),
migrations.AlterField( migrations.AlterField(

View File

@ -20,4 +20,5 @@ class Migration(migrations.Migration):
migrations.RemoveField(model_name="assignmentpoll", name="votesabstain"), migrations.RemoveField(model_name="assignmentpoll", name="votesabstain"),
migrations.RemoveField(model_name="assignmentpoll", name="votesno"), migrations.RemoveField(model_name="assignmentpoll", name="votesno"),
migrations.RemoveField(model_name="assignmentpoll", name="published"), migrations.RemoveField(model_name="assignmentpoll", name="published"),
migrations.RemoveField(model_name="assignmentrelateduser", name="elected",),
] ]

View File

@ -41,11 +41,6 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model):
ForeinKey to the user who is related to the assignment. ForeinKey to the user who is related to the assignment.
""" """
elected = models.BooleanField(default=False)
"""
Saves the election state of each user
"""
weight = models.IntegerField(default=0) weight = models.IntegerField(default=0)
""" """
The sort order of the candidates. The sort order of the candidates.
@ -141,7 +136,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
settings.AUTH_USER_MODEL, through="AssignmentRelatedUser" settings.AUTH_USER_MODEL, through="AssignmentRelatedUser"
) )
""" """
Users that are candidates or elected. Users that are candidates.
See AssignmentRelatedUser for more information. See AssignmentRelatedUser for more information.
""" """
@ -180,14 +175,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
""" """
Queryset that represents the candidates for the assignment. Queryset that represents the candidates for the assignment.
""" """
return self.related_users.filter(assignmentrelateduser__elected=False) return self.related_users.all()
@property
def elected(self):
"""
Queryset that represents all elected users for the assignment.
"""
return self.related_users.filter(assignmentrelateduser__elected=True)
def is_candidate(self, user): def is_candidate(self, user):
""" """
@ -197,14 +185,6 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
""" """
return self.candidates.filter(pk=user.pk).exists() return self.candidates.filter(pk=user.pk).exists()
def is_elected(self, user):
"""
Returns True if the user is elected for this assignment.
Costs one database query.
"""
return self.elected.filter(pk=user.pk).exists()
def add_candidate(self, user): def add_candidate(self, user):
""" """
Adds the user as candidate. Adds the user as candidate.
@ -213,17 +193,9 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
self.assignment_related_users.aggregate(models.Max("weight"))["weight__max"] self.assignment_related_users.aggregate(models.Max("weight"))["weight__max"]
or 0 or 0
) )
defaults = {"elected": False, "weight": weight + 1} defaults = {"weight": weight + 1}
self.assignment_related_users.update_or_create(user=user, defaults=defaults) self.assignment_related_users.update_or_create(user=user, defaults=defaults)
def set_elected(self, user):
"""
Makes user an elected user for this assignment.
"""
self.assignment_related_users.update_or_create(
user=user, defaults={"elected": True}
)
def remove_candidate(self, user): def remove_candidate(self, user):
""" """
Delete the connection from the assignment to the user. Delete the connection from the assignment to the user.
@ -348,7 +320,11 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
POLLMETHOD_YN = "YN" POLLMETHOD_YN = "YN"
POLLMETHOD_YNA = "YNA" POLLMETHOD_YNA = "YNA"
POLLMETHOD_VOTES = "votes" POLLMETHOD_VOTES = "votes"
POLLMETHODS = (("YN", "YN"), ("YNA", "YNA"), ("votes", "votes")) POLLMETHODS = (
(POLLMETHOD_VOTES, "Yes per candidate"),
(POLLMETHOD_YN, "Yes/No per candidate"),
(POLLMETHOD_YNA, "Yes/No/Abstain per candidate"),
)
pollmethod = models.CharField(max_length=5, choices=POLLMETHODS) pollmethod = models.CharField(max_length=5, choices=POLLMETHODS)
PERCENT_BASE_YN = "YN" PERCENT_BASE_YN = "YN"
@ -404,7 +380,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
def create_options(self, skip_autoupdate=False): def create_options(self, skip_autoupdate=False):
related_users = AssignmentRelatedUser.objects.filter( related_users = AssignmentRelatedUser.objects.filter(
assignment__id=self.assignment.id assignment__id=self.assignment.id
).exclude(elected=True) )
for related_user in related_users: for related_user in related_users:
option = AssignmentOption( option = AssignmentOption(

View File

@ -19,10 +19,7 @@ async def assignment_slide(
assignment = get_model(all_data, "assignments/assignment", element.get("id")) assignment = get_model(all_data, "assignments/assignment", element.get("id"))
assignment_related_users: List[Dict[str, Any]] = [ assignment_related_users: List[Dict[str, Any]] = [
{ {"user": await get_user_name(all_data, aru["user_id"])}
"user": await get_user_name(all_data, aru["user_id"]),
"elected": aru["elected"],
}
for aru in sorted( for aru in sorted(
assignment["assignment_related_users"], key=lambda aru: aru["weight"] assignment["assignment_related_users"], key=lambda aru: aru["weight"]
) )

View File

@ -45,7 +45,7 @@ class AssignmentRelatedUserSerializer(ModelSerializer):
class Meta: class Meta:
model = AssignmentRelatedUser model = AssignmentRelatedUser
fields = ("id", "user", "elected", "weight") fields = ("id", "user", "weight")
class AssignmentVoteSerializer(BaseVoteSerializer): class AssignmentVoteSerializer(BaseVoteSerializer):

View File

@ -32,8 +32,7 @@ class AssignmentViewSet(ModelViewSet):
API endpoint for assignments. API endpoint for assignments.
There are the following views: metadata, list, retrieve, create, There are the following views: metadata, list, retrieve, create,
partial_update, update, destroy, candidature_self, candidature_other, partial_update, update, destroy, candidature_self, candidature_other and create_poll.
mark_elected and create_poll.
""" """
access_permissions = AssignmentAccessPermissions() access_permissions = AssignmentAccessPermissions()
@ -53,7 +52,6 @@ class AssignmentViewSet(ModelViewSet):
"partial_update", "partial_update",
"update", "update",
"destroy", "destroy",
"mark_elected",
"sort_related_users", "sort_related_users",
): ):
result = has_perm(self.request.user, "assignments.can_see") and has_perm( result = has_perm(self.request.user, "assignments.can_see") and has_perm(
@ -81,8 +79,6 @@ class AssignmentViewSet(ModelViewSet):
candidature (DELETE). candidature (DELETE).
""" """
assignment = self.get_object() assignment = self.get_object()
if assignment.is_elected(request.user):
raise ValidationError({"detail": "You are already elected."})
if request.method == "POST": if request.method == "POST":
message = self.nominate_self(request, assignment) message = self.nominate_self(request, assignment)
else: else:
@ -132,8 +128,7 @@ class AssignmentViewSet(ModelViewSet):
def get_user_from_request_data(self, request): def get_user_from_request_data(self, request):
""" """
Helper method to get a specific user from request data (not the Helper method to get a specific user from request data (not the
request.user) so that the views self.candidature_other or request.user) so that the view self.candidature_other can play with it.
self.mark_elected can play with it.
""" """
if not isinstance(request.data, dict): if not isinstance(request.data, dict):
raise ValidationError( raise ValidationError(
@ -172,10 +167,6 @@ class AssignmentViewSet(ModelViewSet):
return self.delete_other(request, user, assignment) return self.delete_other(request, user, assignment)
def nominate_other(self, request, user, assignment): def nominate_other(self, request, user, assignment):
if assignment.is_elected(user):
raise ValidationError(
{"detail": "User {0} is already elected.", "args": [str(user)]}
)
if assignment.phase == assignment.PHASE_FINISHED: if assignment.phase == assignment.PHASE_FINISHED:
raise ValidationError( raise ValidationError(
{ {
@ -209,7 +200,7 @@ class AssignmentViewSet(ModelViewSet):
"detail": "You can not delete someone's candidature to this election because it is finished." "detail": "You can not delete someone's candidature to this election because it is finished."
} }
) )
if not assignment.is_candidate(user) and not assignment.is_elected(user): if not assignment.is_candidate(user):
raise ValidationError( raise ValidationError(
{ {
"detail": "User {0} has no status in this election.", "detail": "User {0} has no status in this election.",
@ -221,37 +212,6 @@ class AssignmentViewSet(ModelViewSet):
{"detail": "Candidate {0} was withdrawn successfully.", "args": [str(user)]} {"detail": "Candidate {0} was withdrawn successfully.", "args": [str(user)]}
) )
@detail_route(methods=["post", "delete"])
def mark_elected(self, request, pk=None):
"""
View to mark other users as elected (POST) or undo this (DELETE).
The client has to send {'user': <id>}.
"""
user = self.get_user_from_request_data(request)
assignment = self.get_object()
if request.method == "POST":
if not assignment.is_candidate(user):
raise ValidationError(
{
"detail": "User {0} is not a candidate of this election.",
"args": [str(user)],
}
)
assignment.set_elected(user)
message = "User {0} was successfully elected."
else:
# request.method == 'DELETE'
if not assignment.is_elected(user):
raise ValidationError(
{
"detail": "User {0} is not an elected candidate of this election.",
"args": [str(user)],
}
)
assignment.add_candidate(user)
message = "User {0} was successfully unelected."
return Response({"detail": message, "args": [str(user)]})
@detail_route(methods=["post"]) @detail_route(methods=["post"])
def sort_related_users(self, request, pk=None): def sort_related_users(self, request, pk=None):
""" """

View File

@ -401,52 +401,3 @@ class CandidatureOther(TestCase):
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
class MarkElectedOtherUser(TestCase):
"""
Tests marking an elected user. We use an extra user here to show that
admin can not only mark himself but also other users.
"""
def setUp(self):
self.client = APIClient()
self.client.login(username="admin", password="admin")
self.assignment = Assignment.objects.create(
title="test_assignment_Ierohsh8rahshofiejai", open_posts=1
)
self.user = get_user_model().objects.create_user(
username="test_user_Oonei3rahji5jugh1eev",
password="test_password_aiphahb5Nah0cie4iP7o",
)
def test_mark_elected(self):
self.assignment.add_candidate(
get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev")
)
response = self.client.post(
reverse("assignment-mark-elected", args=[self.assignment.pk]),
{"user": self.user.pk},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(
Assignment.objects.get(pk=self.assignment.pk)
.elected.filter(username="test_user_Oonei3rahji5jugh1eev")
.exists()
)
def test_mark_unelected(self):
user = get_user_model().objects.get(username="test_user_Oonei3rahji5jugh1eev")
self.assignment.set_elected(user)
response = self.client.delete(
reverse("assignment-mark-elected", args=[self.assignment.pk]),
{"user": self.user.pk},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(
Assignment.objects.get(pk=self.assignment.pk)
.elected.filter(username="test_user_Oonei3rahji5jugh1eev")
.exists()
)