Merge pull request #4537 from MaximilianKrambach/assignments

assignment detail and list views
This commit is contained in:
Maximilian Krambach 2019-04-08 10:25:56 +02:00 committed by GitHub
commit 6f62e5c7e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 2091 additions and 183 deletions

View File

@ -64,4 +64,13 @@ export abstract class BaseComponent {
const translatedPrefix = this.translate.instant(prefix); const translatedPrefix = this.translate.instant(prefix);
this.titleService.setTitle(translatedPrefix + this.titleSuffix); this.titleService.setTitle(translatedPrefix + this.titleSuffix);
} }
/**
* Helper for indexed *ngFor components
*
* @param index
*/
public trackByIndex(index: number): number {
return index;
}
} }

View File

@ -3,11 +3,14 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Assignment } from 'app/shared/models/assignments/assignment'; import { Assignment } from 'app/shared/models/assignments/assignment';
import { AssignmentUser } from 'app/shared/models/assignments/assignment-user';
import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository'; import { BaseAgendaContentObjectRepository } from '../base-agenda-content-object-repository';
import { CollectionStringMapperService } from '../../core-services/collectionStringMapper.service'; import { CollectionStringMapperService } from '../../core-services/collectionStringMapper.service';
import { DataSendService } from 'app/core/core-services/data-send.service'; import { DataSendService } from 'app/core/core-services/data-send.service';
import { DataStoreService } from '../../core-services/data-store.service'; import { DataStoreService } from '../../core-services/data-store.service';
import { HttpService } from 'app/core/core-services/http.service';
import { Item } from 'app/shared/models/agenda/item'; import { Item } from 'app/shared/models/agenda/item';
import { Poll } from 'app/shared/models/assignments/poll';
import { Tag } from 'app/shared/models/core/tag'; import { Tag } from 'app/shared/models/core/tag';
import { User } from 'app/shared/models/users/user'; import { User } from 'app/shared/models/users/user';
import { ViewAssignment } from 'app/site/assignments/models/view-assignment'; import { ViewAssignment } from 'app/site/assignments/models/view-assignment';
@ -25,18 +28,30 @@ import { ViewUser } from 'app/site/users/models/view-user';
providedIn: 'root' providedIn: 'root'
}) })
export class AssignmentRepositoryService extends BaseAgendaContentObjectRepository<ViewAssignment, Assignment> { export class AssignmentRepositoryService extends BaseAgendaContentObjectRepository<ViewAssignment, Assignment> {
private readonly restPath = '/rest/assignments/assignment/';
private readonly restPollPath = '/rest/assignments/poll/';
private readonly candidatureOtherPath = '/candidature_other/';
private readonly candidatureSelfPath = '/candidature_self/';
private readonly createPollPath = '/create_poll/';
private readonly markElectedPath = '/mark_elected/';
/** /**
* Constructor for the Assignment Repository. * Constructor for the Assignment Repository.
* *
* @param DS The DataStore * @param DS DataStore access
* @param mapperService Maps collection strings to classes * @param dataSend Sending data
* @param mapperService Map models to object
* @param viewModelStoreService Access view models
* @param translate Translate string
* @param httpService make HTTP Requests
*/ */
public constructor( public constructor(
DS: DataStoreService, DS: DataStoreService,
dataSend: DataSendService, dataSend: DataSendService,
mapperService: CollectionStringMapperService, mapperService: CollectionStringMapperService,
viewModelStoreService: ViewModelStoreService, viewModelStoreService: ViewModelStoreService,
translate: TranslateService protected translate: TranslateService,
private httpService: HttpService
) { ) {
super(DS, dataSend, mapperService, viewModelStoreService, translate, Assignment, [User, Item, Tag]); super(DS, dataSend, mapperService, viewModelStoreService, translate, Assignment, [User, Item, Tag]);
} }
@ -64,4 +79,136 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito
viewAssignment.getAgendaTitleWithType = () => this.getAgendaTitleWithType(viewAssignment); viewAssignment.getAgendaTitleWithType = () => this.getAgendaTitleWithType(viewAssignment);
return viewAssignment; return viewAssignment;
} }
/**
* Adds another user as a candidate
*
* @param userId User id of a candidate
* @param assignment The assignment to add the candidate to
*/
public async changeCandidate(userId: number, assignment: ViewAssignment): Promise<void> {
const data = { user: userId };
if (assignment.candidates.some(candidate => candidate.id === userId)) {
await this.httpService.delete(this.restPath + assignment.id + this.candidatureOtherPath, data);
} else {
await this.httpService.post(this.restPath + assignment.id + this.candidatureOtherPath, data);
}
}
/**
* Add the operator as candidate to the assignment
*
* @param assignment The assignment to add the candidate to
*/
public async addSelf(assignment: ViewAssignment): Promise<void> {
await this.httpService.post(this.restPath + assignment.id + this.candidatureSelfPath);
}
/**
* Removes the current user (operator) from the list of candidates for an assignment
*
* @param assignment The assignment to remove ourself from
*/
public async deleteSelf(assignment: ViewAssignment): Promise<void> {
await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath);
}
/**
* Creates a new Poll to a given assignment
*
* @param assignment The assignment to add the poll to
*/
public async addPoll(assignment: ViewAssignment): Promise<void> {
await this.httpService.post(this.restPath + assignment.id + this.createPollPath);
}
/**
* Deletes a poll
*
* @param id id of the poll to delete
*/
public async deletePoll(poll: Poll): Promise<void> {
await this.httpService.delete(`${this.restPollPath}${poll.id}/`);
}
/**
* update data (metadata etc) for a poll
*
* @param poll the (partial) data to update
* @param originalPoll the poll to update
*
* TODO: check if votes is untouched
*/
public async updatePoll(poll: Partial<Poll>, originalPoll: Poll): Promise<void> {
const data: Poll = Object.assign(originalPoll, poll);
await this.httpService.patch(`${this.restPollPath}${originalPoll.id}/`, data);
}
/**
* TODO: temporary (?) update votes method. Needed because server needs
* different input than it's output in case of votes ?
*
* @param poll the updated Poll
* @param originalPoll the original poll
*/
public async updateVotes(poll: Partial<Poll>, originalPoll: Poll): Promise<void> {
poll.options.sort((a, b) => a.weight - b.weight);
const votes = poll.options.map(option => {
switch (poll.pollmethod) {
case 'votes':
return { Votes: option.votes.find(v => v.value === 'Yes').weight };
case 'yn':
return {
Yes: option.votes.find(v => v.value === 'Yes').weight,
No: option.votes.find(v => v.value === 'No').weight
};
case 'yna':
return {
Yes: option.votes.find(v => v.value === 'Yes').weight,
No: option.votes.find(v => v.value === 'No').weight,
Abstain: option.votes.find(v => v.value === 'Abstain').weight
};
}
});
const data = {
assignment_id: originalPoll.assignment_id,
votes: votes,
votesabstain: null,
votescast: poll.votescast || null,
votesinvalid: poll.votesinvalid || null,
votesno: null,
votesvalid: poll.votesvalid || null
};
await this.httpService.put(`${this.restPollPath}${originalPoll.id}/`, data);
}
/**
* change the 'elected' state of an election candidate
*
* @param user
* @param assignment
* @param elected true if the candidate is to be elected, false if unelected
*/
public async markElected(user: AssignmentUser, assignment: ViewAssignment, elected: boolean): Promise<void> {
const data = { user: user.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);
}
}
/**
* Sorting the candidates
* TODO untested stub
*
* @param sortedCandidates
* @param assignment
*/
public async sortCandidates(sortedCandidates: any[], assignment: ViewAssignment): Promise<void> {
throw Error('TODO');
// const restPath = `/rest/assignments/assignment/${assignment.id}/sort_related_users`;
// const data = { related_users: sortedCandidates };
// await this.httpService.post(restPath, data);
}
} }

View File

@ -6,37 +6,85 @@ import { _ } from 'app/core/translate/translation-marker';
* The possible keys of a poll object that represent numbers. * The possible keys of a poll object that represent numbers.
* TODO Should be 'key of MotionPoll if type of key is number' * TODO Should be 'key of MotionPoll if type of key is number'
* TODO: normalize MotionPoll model and other poll models * TODO: normalize MotionPoll model and other poll models
* TODO: reuse more motion-poll-service stuff
*/ */
export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain'; export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain';
/** /**
* Shared service class for polls. * TODO: may be obsolete if the server switches to lower case only
* TODO: For now, motionPolls only. TODO See if reusable for assignment polls etc. * (lower case variants are already in CalculablePollKey)
*/
export type PollVoteValue = 'Yes' | 'No' | 'Abstain';
/**
* Interface representing possible majority calculation methods. The implementing
* calc function should return an integer number that must be reached for the
* option to reach the quorum, or null if disabled
*/
export interface MajorityMethod {
value: string;
display_name: string;
calc: (base: number) => number | null;
}
/**
* List of available majority methods, used in motion and assignment polls
*/
export const PollMajorityMethod: MajorityMethod[] = [
{
value: 'simple_majority',
display_name: 'Simple majority',
calc: base => Math.ceil(base * 0.5)
},
{
value: 'two-thirds_majority',
display_name: 'Two-thirds majority',
calc: base => Math.ceil((base / 3) * 2)
},
{
value: 'three-quarters_majority',
display_name: 'Three-quarters majority',
calc: base => Math.ceil((base / 4) * 3)
},
{
value: 'disabled',
display_name: 'Disabled',
calc: a => null
}
];
/**
* Shared service class for polls. Used by child classes {@link MotionPollService}
* and {@link AssignmentPollService}
*/ */
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class PollService { export abstract class PollService {
/** /**
* The chosen and currently used base for percentage calculations. Is set by * The chosen and currently used base for percentage calculations. Is
* the config service * supposed to be set by a config service
*/ */
public percentBase: string; public percentBase: string;
/** /**
* The default majority method (as set per config). * The default majority method (to be set set per config).
*/ */
public defaultMajorityMethod: string; public defaultMajorityMethod: string;
/**
* The majority method currently in use
*/
public majorityMethod: MajorityMethod;
/** /**
* An array of value - label pairs for special value signifiers. * An array of value - label pairs for special value signifiers.
* TODO: Should be given by the server, and editable. For now: hard coded * TODO: Should be given by the server, and editable. For now they are hard
* coded
*/ */
private _specialPollVotes: [number, string][] = [[-1, 'majority'], [-2, 'undocumented']]; private _specialPollVotes: [number, string][] = [[-1, 'majority'], [-2, 'undocumented']];
/** /**
* getter for the special votes * getter for the special vote values
* *
* @returns an array of special (non-positive) numbers used in polls and * @returns an array of special (non-positive) numbers used in polls and
* their descriptive strings * their descriptive strings
@ -53,9 +101,9 @@ export class PollService {
/** /**
* Gets an icon for a Poll Key * Gets an icon for a Poll Key
* *
* @param key * @param key yes, no, abstain or something like that
* @returns a string for material-icons to represent the icon for * @returns a string for material-icons to represent the icon for
* this key(e.g. yes: positiv sign, no: negative sign) * this key(e.g. yes: positive sign, no: negative sign)
*/ */
public getIcon(key: CalculablePollKey): string { public getIcon(key: CalculablePollKey): string {
switch (key) { switch (key) {
@ -65,7 +113,7 @@ export class PollService {
return 'thumb_down'; return 'thumb_down';
case 'abstain': case 'abstain':
return 'not_interested'; return 'not_interested';
// case 'votescast': // TODO case 'votescast':
// sum // sum
case 'votesvalid': case 'votesvalid':
return 'check'; return 'check';
@ -79,10 +127,11 @@ export class PollService {
/** /**
* Gets a label for a poll Key * Gets a label for a poll Key
* *
* @param key yes, no, abstain or something like that
* @returns A short descriptive name for the poll keys * @returns A short descriptive name for the poll keys
*/ */
public getLabel(key: CalculablePollKey): string { public getLabel(key: CalculablePollKey | PollVoteValue): string {
switch (key) { switch (key.toLowerCase()) {
case 'yes': case 'yes':
return 'Yes'; return 'Yes';
case 'no': case 'no':
@ -102,11 +151,11 @@ export class PollService {
/** /**
* retrieve special labels for a poll value * retrieve special labels for a poll value
*
* @param value
* @returns the label for a non-positive value, according to
* {@link specialPollVotes}. Positive values will return as string * {@link specialPollVotes}. Positive values will return as string
* representation of themselves * representation of themselves
*
* @param value check value for special numbers
* @returns the label for a non-positive value, according to
*/ */
public getSpecialLabel(value: number): string { public getSpecialLabel(value: number): string {
if (value >= 0) { if (value >= 0) {
@ -115,4 +164,25 @@ export class PollService {
const vote = this.specialPollVotes.find(special => special[0] === value); const vote = this.specialPollVotes.find(special => special[0] === value);
return vote ? vote[1] : 'Undocumented special (negative) value'; return vote ? vote[1] : 'Undocumented special (negative) value';
} }
/**
* Get the progress bar class for a decision key
*
* @param key a calculable poll key (like yes or no)
* @returns a css class designing a progress bar in a color, or an empty string
*/
public getProgressBarColor(key: CalculablePollKey | PollVoteValue): string {
switch (key.toLowerCase()) {
case 'yes':
return 'progress-green';
case 'no':
return 'progress-red';
case 'abstain':
return 'progress-yellow';
case 'votes':
return 'progress-green';
default:
return '';
}
}
} }

View File

@ -9,11 +9,6 @@ span.right-with-margin {
margin-right: 25px; margin-right: 25px;
} }
.flex-spaced {
display: flex;
justify-content: space-between;
}
.filter-count { .filter-count {
font-style: italic; font-style: italic;
margin-right: 10px; margin-right: 10px;

View File

@ -1,18 +1,38 @@
import { Deserializer } from '../base/deserializer'; import { Deserializer } from '../base/deserializer';
/** /**
* Content of the 'assignment_related_users' property * Content of the 'assignment_related_users' property.
* Note that this differs from a ViewUser (e.g. different id)
* @ignore * @ignore
*/ */
export class AssignmentUser extends Deserializer { export class AssignmentUser extends Deserializer {
public id: number; public id: number;
/**
* id of the user this assignment user relates to
*/
public user_id: number; public user_id: number;
/**
* The current 'elected' state
*/
public elected: boolean; public elected: boolean;
/**
* id of the related assignment
*/
public assignment_id: number; public assignment_id: number;
/**
* A weight to determine the position in the list of candidates
* (determined by the server)
*/
public weight: number; public weight: number;
/** /**
* Needs to be completely optional because assignment has (yet) the optional parameter 'assignment_related_users' * Constructor. Needs to be completely optional because assignment has
* (yet) the optional parameter 'assignment_related_users'
*
* @param input * @param input
*/ */
public constructor(input?: any) { public constructor(input?: any) {

View File

@ -2,12 +2,6 @@ import { AssignmentUser } from './assignment-user';
import { Poll } from './poll'; import { Poll } from './poll';
import { BaseModel } from '../base/base-model'; import { BaseModel } from '../base/base-model';
export const assignmentPhase = [
{ key: 0, name: 'Searching for candidates' },
{ key: 1, name: 'Voting' },
{ key: 2, name: 'Finished' }
];
/** /**
* Representation of an assignment. * Representation of an assignment.
* @ignore * @ignore
@ -18,7 +12,7 @@ export class Assignment extends BaseModel<Assignment> {
public title: string; public title: string;
public description: string; public description: string;
public open_posts: number; public open_posts: number;
public phase: number; public phase: number; // see Openslides constants
public assignment_related_users: AssignmentUser[]; public assignment_related_users: AssignmentUser[];
public poll_description_default: number; public poll_description_default: number;
public polls: Poll[]; public polls: Poll[];

View File

@ -1,4 +1,5 @@
import { Deserializer } from '../base/deserializer'; import { Deserializer } from '../base/deserializer';
import { PollVoteValue } from 'app/core/ui-services/poll.service';
/** /**
* Representation of a poll option * Representation of a poll option
@ -7,18 +8,38 @@ import { Deserializer } from '../base/deserializer';
* @ignore * @ignore
*/ */
export class PollOption extends Deserializer { export class PollOption extends Deserializer {
public id: number; public id: number; // The AssignmentUser id of the candidate
public candidate_id: number; public candidate_id: number; // the User id of the candidate
public is_elected: boolean; public is_elected: boolean;
public votes: number[]; public votes: {
weight: number; // TODO arrives as string?
value: PollVoteValue;
}[];
public poll_id: number; public poll_id: number;
public weight: number; public weight: number; // weight to order the display
/** /**
* Needs to be completely optional because poll has (yet) the optional parameter 'poll-options' * Needs to be completely optional because poll has (yet) the optional parameter 'poll-options'
*
* @param input * @param input
*/ */
public constructor(input?: any) { public constructor(input?: any) {
// cast stringify numbers
if (typeof input === 'object') {
Object.keys(input).forEach(key => {
if (typeof input[key] === 'string') {
input[key] = parseInt(input[key], 10);
}
});
if (input.votes) {
input.votes = input.votes.map(vote => {
return {
value: vote.value,
weight: parseInt(vote.weight, 10)
};
});
}
}
super(input); super(input);
} }
} }

View File

@ -1,5 +1,6 @@
import { PollOption } from './poll-option'; import { AssignmentPollMethod } from 'app/site/assignments/services/assignment-poll.service';
import { Deserializer } from '../base/deserializer'; import { Deserializer } from '../base/deserializer';
import { PollOption } from './poll-option';
/** /**
* Content of the 'polls' property of assignments * Content of the 'polls' property of assignments
@ -7,7 +8,7 @@ import { Deserializer } from '../base/deserializer';
*/ */
export class Poll extends Deserializer { export class Poll extends Deserializer {
public id: number; public id: number;
public pollmethod: string; public pollmethod: AssignmentPollMethod;
public description: string; public description: string;
public published: boolean; public published: boolean;
public options: PollOption[]; public options: PollOption[];
@ -17,17 +18,33 @@ export class Poll extends Deserializer {
public has_votes: boolean; public has_votes: boolean;
public assignment_id: number; public assignment_id: number;
/**
* (temporary?) storing the base values for percentage calculations,
* to avoid recalculating pollBases too often
* (the calculation iterates through all pollOptions in some use cases)
*/
public pollBase: number;
/** /**
* Needs to be completely optional because assignment has (yet) the optional parameter 'polls' * Needs to be completely optional because assignment has (yet) the optional parameter 'polls'
* @param input * @param input
*/ */
public constructor(input?: any) { public constructor(input?: any) {
// cast stringify numbers
if (typeof input === 'object') {
const numberifyKeys = ['id', 'votesvalid', 'votesinvalid', 'votescast', 'assignment_id'];
for (const key of Object.keys(input)) {
if (numberifyKeys.includes(key) && typeof input[key] === 'string') {
input[key] = parseInt(input[key], 10);
}
}
}
super(input); super(input);
} }
public deserialize(input: any): void { public deserialize(input: any): void {
Object.assign(this, input); Object.assign(this, input);
this.options = []; this.options = [];
if (input.options instanceof Array) { if (input.options instanceof Array) {
this.options = input.options.map(pollOptionData => new PollOption(pollOptionData)); this.options = input.options.map(pollOptionData => new PollOption(pollOptionData));

View File

@ -1,4 +1,8 @@
<os-head-bar plusButton="true" (plusButtonClicked)="onPlusButton()" [multiSelectMode]="isMultiSelect"> <os-head-bar
[mainButton]="operator.hasPerms('assignments.can_manage')"
(mainEvent)="onPlusButton()"
[multiSelectMode]="isMultiSelect"
>
<!-- Title --> <!-- Title -->
<div class="title-slot"><h2 translate>Elections</h2></div> <div class="title-slot"><h2 translate>Elections</h2></div>
<!-- Menu --> <!-- Menu -->
@ -91,6 +95,10 @@
<mat-icon>done_all</mat-icon> <mat-icon>done_all</mat-icon>
<span translate>Select all</span> <span translate>Select all</span>
</button> </button>
<button mat-menu-item (click)="deselectAll()">
<mat-icon>clear</mat-icon>
<span translate>Deselect all</span>
</button>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<button <button
mat-menu-item mat-menu-item

View File

@ -1,5 +1,6 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material'; import { MatSnackBar } from '@angular/material';
import { Router, ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -8,14 +9,13 @@ import { AssignmentFilterListService } from '../services/assignment-filter.servi
import { AssignmentSortListService } from '../services/assignment-sort-list.service'; import { AssignmentSortListService } from '../services/assignment-sort-list.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { ListViewBaseComponent } from '../../base/list-view-base'; import { ListViewBaseComponent } from '../../base/list-view-base';
import { OperatorService } from 'app/core/core-services/operator.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewAssignment } from '../models/view-assignment';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { ActivatedRoute } from '@angular/router'; import { ViewAssignment } from '../models/view-assignment';
/** /**
* List view for the assignments * List view for the assignments
*
*/ */
@Component({ @Component({
selector: 'os-assignment-list', selector: 'os-assignment-list',
@ -25,24 +25,31 @@ import { ActivatedRoute } from '@angular/router';
export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignment, Assignment> implements OnInit { export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignment, Assignment> implements OnInit {
/** /**
* Constructor. * Constructor.
*
* @param titleService * @param titleService
* @param storage
* @param translate * @param translate
* @param matSnackBar * @param matSnackBar
* @param repo the repository * @param repo the repository
* @param promptService * @param promptService
* @param filterService: A service to supply the filtered datasource * @param filterService: A service to supply the filtered datasource
* @param sortService: Service to sort the filtered dataSource * @param sortService: Service to sort the filtered dataSource
* @param route
* @param router
* @param operator
*/ */
public constructor( public constructor(
titleService: Title, titleService: Title,
storage: StorageService, storage: StorageService,
route: ActivatedRoute,
protected translate: TranslateService, // protected required for ng-translate-extract protected translate: TranslateService, // protected required for ng-translate-extract
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
public repo: AssignmentRepositoryService, public repo: AssignmentRepositoryService,
private promptService: PromptService, private promptService: PromptService,
public filterService: AssignmentFilterListService, public filterService: AssignmentFilterListService,
public sortService: AssignmentSortListService public sortService: AssignmentSortListService,
protected route: ActivatedRoute,
private router: Router,
public operator: OperatorService
) { ) {
super(titleService, translate, matSnackBar, route, storage, filterService, sortService); super(titleService, translate, matSnackBar, route, storage, filterService, sortService);
// activate multiSelect mode for this list view // activate multiSelect mode for this list view
@ -51,8 +58,7 @@ export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignmen
/** /**
* Init function. * Init function.
* Sets the title, inits the table, sets sorting and filter definitions, subscribes to filtered * Sets the title, inits the table
* data and sorting service
*/ */
public ngOnInit(): void { public ngOnInit(): void {
super.setTitle(this.translate.instant('Elections')); super.setTitle(this.translate.instant('Elections'));
@ -60,18 +66,21 @@ export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignmen
} }
/** /**
* Click on the plus button delegated from head-bar * Handles a click on the plus button delegated from head-bar.
* Creates a new assignment
*/ */
public onPlusButton(): void { public onPlusButton(): void {
console.log('create new assignments'); this.router.navigate(['./new'], { relativeTo: this.route });
} }
/** /**
* Action to be performed after a click on a row in the table, if in single select mode * Action to be performed after a click on a row in the table, if in single select mode.
* Navigates to the corresponding assignment
*
* @param assignment The entry of row clicked * @param assignment The entry of row clicked
*/ */
public singleSelectAction(assignment: ViewAssignment): void { public singleSelectAction(assignment: ViewAssignment): void {
console.log('select assignment list: ', assignment); this.router.navigate([assignment.getDetailStateURL()], { relativeTo: this.route });
} }
/** /**
@ -79,7 +88,7 @@ export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignmen
* TODO: Not yet implemented * TODO: Not yet implemented
*/ */
public downloadAssignmentButton(): void { public downloadAssignmentButton(): void {
console.log('Hello World'); this.raiseError('TODO: assignment download not yet implemented');
} }
/** /**
@ -95,6 +104,11 @@ export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignmen
} }
} }
/**
* Fetch the column definitions for the data table
*
* @returns a list of string matching the columns
*/
public getColumnDefintion(): string[] { public getColumnDefintion(): string[] {
const list = ['title', 'phase', 'candidates']; const list = ['title', 'phase', 'candidates'];
if (this.isMultiSelect) { if (this.isMultiSelect) {

View File

@ -1,8 +1,14 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component';
import { AssignmentListComponent } from './assignment-list/assignment-list.component'; import { AssignmentListComponent } from './assignment-list/assignment-list.component';
const routes: Routes = [{ path: '', component: AssignmentListComponent, pathMatch: 'full' }]; const routes: Routes = [
{ path: '', component: AssignmentListComponent, pathMatch: 'full' },
{ path: 'new', component: AssignmentDetailComponent },
{ path: ':id', component: AssignmentDetailComponent }
];
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],

View File

@ -1,12 +1,21 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component';
import { AssignmentListComponent } from './assignment-list/assignment-list.component';
import { AssignmentPollComponent } from './components/assignment-poll/assignment-poll.component';
import { AssignmentPollDialogComponent } from './components/assignment-poll/assignment-poll-dialog.component';
import { AssignmentsRoutingModule } from './assignments-routing.module'; import { AssignmentsRoutingModule } from './assignments-routing.module';
import { SharedModule } from '../../shared/shared.module'; import { SharedModule } from '../../shared/shared.module';
import { AssignmentListComponent } from './assignment-list/assignment-list.component';
@NgModule({ @NgModule({
imports: [CommonModule, AssignmentsRoutingModule, SharedModule], imports: [CommonModule, AssignmentsRoutingModule, SharedModule],
declarations: [AssignmentListComponent] declarations: [
AssignmentListComponent,
AssignmentDetailComponent,
AssignmentPollComponent,
AssignmentPollDialogComponent
],
entryComponents: [AssignmentPollDialogComponent]
}) })
export class AssignmentsModule {} export class AssignmentsModule {}

View File

@ -0,0 +1,259 @@
<os-head-bar
[mainButton]="hasPerms('manage')"
mainButtonIcon="edit"
[nav]="false"
[editMode]="editAssignment"
(mainEvent)="setEditMode(!editAssignment)"
(saveEvent)="saveAssignment()"
>
<!-- Title -->
<div class="title-slot">
<h2 *ngIf="assignment && !newAssignment">
<span translate>Election</span>
<span>&nbsp;</span> <span *ngIf="!editAssignment">{{ assignment.title }}</span>
<span *ngIf="editAssignment">{{ assignmentForm.get('title').value }}</span>
</h2>
<h2 *ngIf="newAssignment" translate>New election</h2>
</div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="assignmentDetailMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
<mat-menu #assignmentDetailMenu="matMenu">
<!-- delete -->
<!-- print, edit, delete -->
<div *ngIf="assignment">
<!-- PDF -->
<button mat-menu-item (click)="onDownloadPdf()">
<!-- TODO: results or descritipon. Results if published -->
<mat-icon>picture_as_pdf</mat-icon>
<span translate>PDF</span>
</button>
<mat-divider></mat-divider>
<!-- Delete -->
<button mat-menu-item class="red-warning-text" (click)="onDeleteAssignmentButton()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</div>
</mat-menu>
</os-head-bar>
<div class="content-container">
<!-- Title -->
<div class="title on-transition-fade" *ngIf="assignment && !editAssignment">
<div class="title-line">
<h1>{{ assignment.title }}</h1>
</div>
</div>
<ng-container *ngIf="vp.isMobile; then mobileView; else desktopView"></ng-container>
</div>
<ng-template #mobileView>
<!-- TODO -->
</ng-template>
<ng-template #desktopView>
<mat-card class="os-card">
<div *ngIf="editAssignment">
<ng-container [ngTemplateOutlet]="assignmentFormTemplate"></ng-container>
</div>
<div *ngIf="!editAssignment">
<mat-card class="os-card">
<ng-container [ngTemplateOutlet]="metaInfoTemplate"></ng-container>
</mat-card>
<mat-card class="os-card">
<ng-container [ngTemplateOutlet]="contentTemplate"></ng-container>
</mat-card>
</div>
</mat-card>
</ng-template>
<ng-template #metaInfoTemplate>
<span translate>Meta information</span>
<div *ngIf="assignment; &quot;flex-spaced&quot;">
<div>
<span translate>Number of persons to be elected</span>:&nbsp;
<span>{{ assignment.assignment.open_posts }}</span>
</div>
<div>
<span> {{ phaseString | translate }}</span>
<mat-form-field>
<mat-label translate>Phase</mat-label>
<mat-select
class="selection"
[disabled]="!hasPerms('manage')"
(selectionChange)="setPhase($event)"
[value]="assignment.phase"
>
<mat-option *ngFor="let option of phaseOptions" [value]="option.value">
{{ option.display_name | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</ng-template>
<ng-template #contentTemplate>
<ng-container *ngIf="assignment && assignment.phase !== 2" [ngTemplateOutlet]="candidatesTemplate">
</ng-container>
<!-- TODO related agenda item to create/updade: internal status; parent_id ? -->
<ng-container [ngTemplateOutlet]="pollTemplate"></ng-container>
<!-- TODO different status/display if finished -->
</ng-template>
<!-- poll template -->
<ng-template #pollTemplate>
<div>
<!-- TODO: header. New poll button to the right, and with icon -->
<!-- new poll button -->
<button type="button" mat-button *ngIf="hasPerms('createPoll')" (click)="createPoll()">
<span translate>Create poll</span>
</button>
</div>
<mat-tab-group (selectedTabChange)="onTabChange()" *ngIf="assignment && assignment.polls">
<!-- TODO avoid animation/switching on update -->
<mat-tab *ngFor="let poll of assignment.polls; let i = index" [label]="getPollLabel(poll, i)">
<os-assignment-poll [assignment]="assignment" [poll]="poll"> </os-assignment-poll>
</mat-tab>
</mat-tab-group>
</ng-template>
<ng-template #candidatesTemplate>
<!-- TODO two columns here: Left candidates; right add/remove -->
<div>
<div>
<div *ngIf="assignment && assignment.candidates">
<!-- TODO: Sorting -->
<div *ngFor="let candidate of assignment.candidates">
<span>{{ candidate.username }}</span>
<button mat-button *ngIf="hasPerms('addOthers')" (click)="removeUser(candidate)">Remove</button>
</div>
</div>
</div>
<div>
<div *ngIf="hasPerms('addOthers')">
<!-- candidates: if open: Select list, sortable (like list of speakers) -->
<os-search-value-selector
*ngIf="hasPerms('addOthers')"
ngDefaultControl
placeholder="Add candidate"
[formControl]="candidatesForm.get('candidate')"
[InputListValues]="availableCandidates"
[form]="candidatesForm"
>
</os-search-value-selector>
<button mat-button [disabled]="!candidatesForm.get('candidate').value" (click)="addUser()">
<span translate>add</span>
</button>
<!-- TODO disable if no user set; filter out users already in list of candidates -->
</div>
</div>
<!-- class="spacer-top-10" -->
<button
type="button"
mat-button
color="primary"
*ngIf="hasPerms('addSelf') && !isSelfCandidate"
(click)="addSelf()"
>
<span class="upper" translate>Add me</span>
</button>
<br />
<button type="button" mat-button *ngIf="hasPerms('addSelf') && isSelfCandidate" (click)="removeSelf()">
<span translate>Remove me</span>
</button>
<br />
</div>
</ng-template>
<ng-template #assignmentFormTemplate>
<div>
<form
class="content"
[formGroup]="assignmentForm"
(keydown)="onKeyDown($event)"
*ngIf="assignment && editAssignment"
>
<!-- title -->
<mat-form-field>
<input
matInput
placeholder="{{ 'Title' | translate }}"
formControlName="title"
[value]="assignmentCopy.title || ''"
/>
</mat-form-field>
<!-- description: HTML Editor -->
<editor
formControlName="description"
[init]="tinyMceSettings"
*ngIf="assignment && editAssignment"
required
></editor>
<div
*ngIf="
assignmentForm.get('description').invalid &&
(assignmentForm.get('description').dirty || assignmentForm.get('description').touched)
"
class="red-warning-text"
translate
>
This field is required.
</div>
<!-- searchValueSelector: tags -->
<div class="content-field">
<os-search-value-selector
ngDefaultControl
[form]="assignmentForm"
[formControl]="assignmentForm.get('tags_id')"
[multiple]="true"
[includeNone]="true"
listname="{{ 'Tags' | translate }}"
[InputListValues]="tagsObserver"
></os-search-value-selector>
</div>
<!-- searchValueSelector:agendaItem -->
<div class="content-field">
<os-search-value-selector
ngDefaultControl
[form]="assignmentForm"
[formControl]="assignmentForm.get('agenda_item_id')"
[multiple]="false"
[includeNone]="false"
listname="{{ 'Topic' | translate }}"
[InputListValues]="agendaObserver"
></os-search-value-selector>
</div>
<!-- poll_description_default -->
<mat-form-field>
<input
matInput
placeholder="{{ 'Default comment on the ballot paper' | translate }}"
formControlName="poll_description_default"
[value]="assignmentCopy.assignment.poll_description_default || ''"
/>
</mat-form-field>
<!-- open posts: number -->
<mat-form-field>
<input
matInput
placeholder="{{ 'Number of persons to be elected' | translate }}"
formControlName="open_posts"
[value]="assignmentCopy.assignment.open_posts || null"
/>
</mat-form-field>
<!-- TODO searchValueSelector: Parent -->
</form>
</div>
</ng-template>

View File

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

View File

@ -0,0 +1,426 @@
import { BehaviorSubject } from 'rxjs';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MatSnackBar, MatSelectChange } from '@angular/material';
import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { Assignment } from 'app/shared/models/assignments/assignment';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ConstantsService } from 'app/core/ui-services/constants.service';
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { Poll } from 'app/shared/models/assignments/poll';
import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ViewAssignment, AssignmentPhase } from '../../models/view-assignment';
import { ViewItem } from 'app/site/agenda/models/view-item';
import { ViewportService } from 'app/core/ui-services/viewport.service';
import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user';
/**
* Component for the assignment detail view
*/
@Component({
selector: 'os-assignment-detail',
templateUrl: './assignment-detail.component.html',
styleUrls: ['./assignment-detail.component.scss']
})
export class AssignmentDetailComponent extends BaseViewComponent implements OnInit {
/**
* Determines if the assignment is new
*/
public newAssignment = false;
/**
* If true, the page is supposed to be in 'edit' mode (i.e. the assignment itself can be edited)
*/
public editAssignment = false;
/**
* The different phases of an assignment. Info is fetched from server
*/
public phaseOptions: AssignmentPhase[] = [];
/**
* List of users (used in searchValueSelector for candidates)
* TODO Candidates already in the list should be filtered out
*/
public availableCandidates = new BehaviorSubject<ViewUser[]>([]);
/**
* TODO a filtered list (excluding users already in this.assignment.candidates)
*/
public filteredCandidates = new BehaviorSubject<ViewUser[]>([]);
/**
* Form for adding/removing candidates.
*/
public candidatesForm: FormGroup;
/**
* Form for editing the assignment itself (TODO mergeable with candidates?)
*/
public assignmentForm: FormGroup;
/**
* Used in the search Value selector to assign tags
*/
public tagsObserver: BehaviorSubject<ViewTag[]>;
/**
* Used in the search Value selector to assign an agenda item
*/
public agendaObserver: BehaviorSubject<ViewItem[]>;
/**
* Sets the assignment, e.g. via an auto update. Reload important things here:
* - Poll base values are be recalculated
*
* @param assignment the assignment to set
*/
public set assignment(assignment: ViewAssignment) {
this._assignment = assignment;
if (this.assignment.polls && this.assignment.polls.length) {
this.assignment.polls.forEach(poll => {
poll.pollBase = this.pollService.getBaseAmount(poll);
});
}
}
/**
* Returns the target assignment.
*/
public get assignment(): ViewAssignment {
return this._assignment;
}
/**
* Current instance of ViewAssignment. Accessed via getter and setter.
*/
private _assignment: ViewAssignment;
/**
* Copy instance of the assignment that the user might edit
*/
public assignmentCopy: ViewAssignment;
/**
* Check if the operator is a candidate
*
* @returns true if they are in the list of candidates
*/
public get isSelfCandidate(): boolean {
return this.assignment.candidates.find(user => user.id === this.operator.user.id) ? true : false;
}
/**
* gets the current assignment phase as string
*
* @returns a matching string (untranslated)
*/
public get phaseString(): string {
const mapping = this.phaseOptions.find(ph => ph.value === this.assignment.phase);
return mapping ? mapping.display_name : '';
}
/**
* Constructor. Build forms and subscribe to needed configs, constants and updates
*
* @param title
* @param translate
* @param matSnackBar
* @param vp
* @param operator
* @param perms
* @param router
* @param route
* @param formBuilder
* @param repo
* @param userRepo
* @param constants
* @param pollService
* @param agendaRepo
* @param tagRepo
*/
public constructor(
title: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
public vp: ViewportService,
private operator: OperatorService,
public perms: LocalPermissionsService,
private router: Router,
private route: ActivatedRoute,
formBuilder: FormBuilder,
public repo: AssignmentRepositoryService,
private userRepo: UserRepositoryService,
private constants: ConstantsService,
public pollService: AssignmentPollService,
private agendaRepo: ItemRepositoryService,
private tagRepo: TagRepositoryService
) {
super(title, translate, matSnackBar);
/* Server side constants for phases */
this.constants.get<AssignmentPhase[]>('AssignmentPhases').subscribe(phases => (this.phaseOptions = phases));
/* List of eligible users */
this.userRepo.getViewModelListObservable().subscribe(users => this.availableCandidates.next(users));
this.assignmentForm = formBuilder.group({
phase: null,
tags_id: [],
title: '',
description: '',
poll_description_default: '',
open_posts: 0,
agenda_item_id: '' // create agenda item
});
this.candidatesForm = formBuilder.group({
candidate: null
});
}
/**
* Init data
*/
public ngOnInit(): void {
this.getAssignmentByUrl();
this.agendaObserver = this.agendaRepo.getViewModelListBehaviorSubject();
this.tagsObserver = this.tagRepo.getViewModelListBehaviorSubject();
}
/**
* Permission check for interactions.
*
* Current operations supported:
* - addSelf: the user can add themself to the list of candidates
* - addOthers: the user can add other candidates
* - createPoll: the user can add/edit election poll (requires candidates to be present)
* - manage: the user has general manage permissions (i.e. editing the assignment metaInfo)
*
* @param operation the action requested
* @returns true if the user is able to perform the action
*/
public hasPerms(operation: string): boolean {
const isManager = this.operator.hasPerms('assignments.can_manage');
switch (operation) {
case 'addSelf':
if (isManager && this.assignment.phase !== 2) {
return true;
} else {
return this.assignment.phase === 0 && this.operator.hasPerms('assignments.can_nominate_self');
}
case 'addOthers':
if (isManager && this.assignment.phase !== 2) {
return true;
} else {
return this.assignment.phase === 0 && this.operator.hasPerms('assignments.can_nominate_others');
}
case 'createPoll':
return (
isManager && this.assignment && this.assignment.phase !== 2 && this.assignment.candidateAmount > 0
);
case 'manage':
return isManager;
default:
return false;
}
}
/**
* Sets/unsets the 'edit assignment' mode
*
* @param newMode
*/
public setEditMode(newMode: boolean): void {
if (newMode && this.hasPerms('manage')) {
this.patchForm(this.assignment);
this.editAssignment = true;
}
if (!newMode && this.newAssignment) {
this.router.navigate(['./assignments/']);
}
if (!newMode) {
this.editAssignment = false;
}
}
/**
* Changes/updates the assignment form values
*
* @param assignment
*/
private patchForm(assignment: ViewAssignment): void {
this.assignmentCopy = assignment;
this.assignmentForm.patchValue({
tags_id: assignment.assignment.tags_id || [],
agendaItem: assignment.assignment.agenda_item_id || null,
phase: assignment.phase, // todo default: 0?
description: assignment.assignment.description || '',
poll_description_default: assignment.assignment.poll_description_default,
open_posts: assignment.assignment.open_posts || 1
});
}
/**
* Save the current state of the assignment
*/
public saveAssignment(): void {
if (this.newAssignment) {
this.createAssignment();
} else {
this.updateAssignmentFromForm();
}
}
/**
* Creates a new Poll
* TODO: directly open poll dialog?
*/
public async createPoll(): Promise<void> {
await this.repo.addPoll(this.assignment).then(null, this.raiseError);
}
/**
* Adds the operator to list of candidates
*/
public async addSelf(): Promise<void> {
await this.repo.addSelf(this.assignment).then(null, this.raiseError);
}
/**
* Removes the operator from list of candidates
*/
public async removeSelf(): Promise<void> {
await this.repo.deleteSelf(this.assignment).then(null, this.raiseError);
}
/**
* Adds a user to the list of candidates
*/
public async addUser(): Promise<void> {
const candId = this.candidatesForm.get('candidate').value;
this.candidatesForm.setValue({ candidate: null });
if (candId) {
await this.repo.changeCandidate(candId, this.assignment).then(null, this.raiseError);
}
}
/**
* Removes a user from the list of candidates
*
* @param user Assignment User
*/
public async removeUser(user: ViewUser): Promise<void> {
await this.repo.changeCandidate(user.id, this.assignment).then(null, this.raiseError);
}
/**
* Determine the assignment to display using the URL
*/
public getAssignmentByUrl(): void {
const params = this.route.snapshot.params;
if (params && params.id) {
// existing assignment
const assignmentId: number = +params.id;
// the following subscriptions need to be cleared when the route changes
this.subscriptions.push(
this.repo.getViewModelObservable(assignmentId).subscribe(assignment => {
if (assignment) {
this.assignment = assignment;
this.patchForm(this.assignment);
}
})
);
} else {
this.newAssignment = true;
// TODO set defaults?
this.assignment = new ViewAssignment(new Assignment());
this.patchForm(this.assignment);
this.setEditMode(true);
}
}
/**
* Handler for deleting the assignment
* TODO: navigating to assignment overview on delete
*/
public onDeleteAssignmentButton(): void {}
/**
* Handler for actions to be done on change of displayed poll
* TODO: needed?
*/
public onTabChange(): void {}
/**
* Handler for changing the phase of an assignment
*
* TODO: only with existing assignments, else it should fail
* TODO check permissions and conditions
*
* @param event
*/
public async setPhase(event: MatSelectChange): Promise<void> {
if (!this.newAssignment && this.phaseOptions.find(option => option.value === event.value)) {
this.repo.update({ phase: event.value }, this.assignment).then(null, this.raiseError);
}
}
public onDownloadPdf(): void {
// TODO: Download summary pdf
}
/**
* Creates an assignment. Calls the "patchValues" function
*/
public async createAssignment(): Promise<void> {
const newAssignmentValues = { ...this.assignmentForm.value };
if (!newAssignmentValues.agenda_parent_id) {
delete newAssignmentValues.agenda_parent_id;
}
try {
const response = await this.repo.create(newAssignmentValues);
this.router.navigate(['./assignments/' + response.id]);
} catch (e) {
this.raiseError(this.translate.instant(e));
}
}
public updateAssignmentFromForm(): void {
this.repo.patch({ ...this.assignmentForm.value }, this.assignmentCopy).then(() => {
this.editAssignment = false;
}, this.raiseError);
}
/**
* clicking Shift and Enter will save automatically
* Hitting escape while in the edit form should cancel editing
*
* @param event has the code
*/
public onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && event.shiftKey) {
this.saveAssignment();
}
if (event.key === 'Escape') {
this.setEditMode(false);
}
}
/**
* Assemble a meaningful label for the poll
* TODO (currently e.g. 'Ballot 10 (unublished)')
*/
public getPollLabel(poll: Poll, index: number): string {
const pubState = poll.published ? this.translate.instant('published') : this.translate.instant('unpublished');
const title = this.translate.instant('Ballot');
return `${title} ${index + 1} (${pubState})`;
}
}

View File

@ -0,0 +1,75 @@
<h2 translate>Voting result</h2>
<div class="meta-text">
<span translate>Special values</span>:<br />
<mat-chip>-1</mat-chip>&nbsp;=&nbsp; <span translate>majority</span><br />
<mat-chip color="accent">-2</mat-chip>&nbsp;=&nbsp;
<span translate>undocumented</span>
</div>
<div>
<div class="flex-spaced" *ngFor="let candidate of data.poll.options">
<div>
{{ getName(candidate.candidate_id) }}
</div>
<div class="votes">
<mat-form-field>
<input
type="number"
matInput
[value]="getValue('Yes', candidate)"
(change)="setValue('Yes', candidate, $event.target.value)"
/>
<mat-label *ngIf="data.poll.pollmethod !== 'votes'" translate>Yes</mat-label>
</mat-form-field>
<mat-form-field *ngIf="data.poll.pollmethod !== 'votes'">
<input
type="number"
matInput
[value]="getValue('No', candidate)"
(change)="setValue('No', candidate, $event.target.value)"
/>
<mat-label translate>No</mat-label>
</mat-form-field>
<mat-form-field *ngIf="data.poll.pollmethod === 'yna'">
<input
type="number"
matInput
[value]="getValue('Abstain', candidate)"
(change)="setValue('Abstain', candidate, $event.target.value)"
/>
<mat-label translate>Abstain</mat-label>
</mat-form-field>
</div>
<mat-divider *ngIf="data.poll.pollmethod !== 'votes'"></mat-divider>
</div>
<mat-form-field>
<input
type="number"
matInput
[value]="getSumValue('votesvalid')"
(change)="setSumValue('votesvalid', $event.target.value)"
/>
<mat-label translate>Valid votes</mat-label>
</mat-form-field>
<mat-form-field>
<input
type="number"
matInput
[value]="getSumValue('votesinvalid')"
(change)="setSumValue('votesinvalid', $event.target.value)"
/>
<mat-label translate>Invalid votes</mat-label>
</mat-form-field>
<mat-form-field>
<input
type="number"
matInput
[value]="getSumValue('votescast')"
(change)="setSumValue('votescast', $event.target.value)"
/>
<mat-label translate>Total votes</mat-label>
</mat-form-field>
</div>
<div class="submit-buttons">
<button mat-button (click)="submit()">{{ 'Save' | translate }}</button>
<button mat-button (click)="cancel()">{{ 'Cancel' | translate }}</button>
</div>

View File

@ -0,0 +1,19 @@
.submit-buttons {
display: flex;
justify-content: flex-end;
}
.meta-text {
font-style: italic;
margin-left: 10px;
margin-right: 10px;
mat-chip {
margin-left: 5px;
margin-right: 2px;
}
}
.votes {
display: flex;
justify-content: space-between;
}

View File

@ -0,0 +1,169 @@
import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { CalculablePollKey, PollVoteValue } from 'app/core/ui-services/poll.service';
import { Poll } from 'app/shared/models/assignments/poll';
import { PollOption } from 'app/shared/models/assignments/poll-option';
import { ViewUser } from 'app/site/users/models/view-user';
/**
* Vote entries included once for summary (e.g. total votes cast)
*/
type summaryPollKeys = 'votescast' | 'votesvalid' | 'votesinvalid';
/**
* A dialog for updating the values of an assignment-related poll.
*/
@Component({
selector: 'os-assignment-poll-dialog',
templateUrl: './assignment-poll-dialog.component.html',
styleUrls: ['./assignment-poll-dialog.component.scss']
})
export class AssignmentPollDialogComponent {
/**
* List of accepted special non-numerical values.
* See {@link PollService.specialPollVotes}
*/
public specialValues: [number, string][];
/**
* vote entries for each option in this component. Is empty if method
* requires one vote per candidate
*/
public optionPollKeys: PollVoteValue[];
/**
* Constructor. Retrieves necessary metadata from the pollService,
* injects the poll itself
*/
public constructor(
public dialogRef: MatDialogRef<AssignmentPollDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: { poll: Poll; users: ViewUser[] },
private matSnackBar: MatSnackBar,
private translate: TranslateService,
private pollService: AssignmentPollService
) {
this.specialValues = this.pollService.specialPollVotes;
switch (this.data.poll.pollmethod) {
case 'votes':
this.optionPollKeys = ['Yes'];
break;
case 'yn':
this.optionPollKeys = ['Yes', 'No'];
break;
case 'yna':
this.optionPollKeys = ['Yes', 'No', 'Abstain'];
break;
}
}
/**
* Close the dialog, submitting nothing. Triggered by the cancel button and
* default angular cancelling behavior
*/
public cancel(): void {
this.dialogRef.close();
}
/**
* 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.poll.options.find(dataoption => {
for (const key of this.optionPollKeys) {
const keyValue = dataoption.votes.find(o => o.value === key);
if (!keyValue || keyValue.weight === undefined) {
return true;
}
}
});
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.poll);
}
}
/**
* 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);
}
/**
* Get the (full) name of a pollOption candidate
*
* @param the id of the candidate
* @returns the full_name property
*/
public getName(candidateId: number): string {
const user = this.data.users.find(c => c.id === candidateId);
return user ? user.full_name : 'unknown user';
// TODO error handling
}
/**
* 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: PollOption, newData: string): void {
const vote = candidate.votes.find(v => v.value === value);
if (vote) {
vote.weight = parseInt(newData, 10);
} else {
candidate.votes.push({
value: value,
weight: parseInt(newData, 10)
});
}
}
/**
* 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: PollOption): number | undefined {
const val = candidate.votes.find(v => v.value === value);
return val ? val.weight : undefined;
}
/**
* Retrieves a per-poll value
*
* @param value
* @returns integer or undefined
*/
public getSumValue(value: summaryPollKeys): number | undefined {
return this.data.poll[value] || undefined;
}
/**
* Sets a per-poll value
*
* @param value
* @param weight
*/
public setSumValue(value: summaryPollKeys, weight: string): void {
this.data.poll[value] = parseInt(weight, 10);
}
}

View File

@ -0,0 +1,119 @@
<!-- label: 'Ballot 1 ...' -->
<h3 translate>Ballot</h3>
<mat-card class="os-card">
<div class="flex-spaced">
<div>
<!-- *ngIf="poll.description"> -->
Description
<!-- {{ poll.description}} -->
</div>
<!-- Buttons -->
<div *osPerms="'assignments.can_manage'">
<button mat-button (click)="printBallot()">
<span translate>Print ballot paper</span>
</button>
<button mat-button [disabled]="assignment.phase == 2" (click)="enterVotes()">
<span translate>Enter votes</span>
</button>
<button mat-button (click)="togglePublished()">
<span *ngIf="!poll.published" translate>Publish</span>
<span *ngIf="poll.published" translate>Unpublish</span>
</button>
</div>
<div *osPerms="'core.can_manage_projector'">
<button mat-button>
<span translate>Project</span>
<!-- os-projector-button ?-->
</button>
</div>
<div *osPerms="'assignments.can_manage'">
<button mat-button class="red-warning-text" (click)="onDeletePoll(poll)">
<span translate>Delete</span>
</button>
</div>
</div>
<div>
<!-- text input 'set hint for pballot paper' -->
<!-- submit: setBallotHint(poll) -->
</div>
<div *ngIf="pollService.majorityMethods && majorityChoice">
<span translate>Majority method</span>
<mat-basic-chip *ngIf="canManage" [matMenuTriggerFor]="majorityMenu" class="grey" disableRipple>
{{ majorityChoice.display_name | translate }}
</mat-basic-chip>
<mat-basic-chip *ngIf="!canManage" class="grey" disableRipple>
{{ majorityChoice.display_name | translate }}
</mat-basic-chip>
<mat-menu #majorityMenu="matMenu">
<button mat-menu-item *ngFor="let method of pollService.majorityMethods" (click)="setMajority(method)">
<mat-icon *ngIf="method.value === majorityChoice.value">
check
</mat-icon>
{{ method.display_name | translate }}
</button>
</mat-menu>
</div>
<div class="on-transition-fade" *ngIf="poll && poll.options">
<div *ngFor="let option of poll.options" class="flex-spaced">
<div *ngIf="poll.published">
<mat-checkbox [checked]="option.is_elected" (change)="toggleElected(option)">
<!-- TODO mark/unkmark elected osPerms -->
</mat-checkbox>
</div>
<!-- candidate Name -->
<div>
{{ getCandidateName(option) }}
</div>
<!-- Votes -->
<div *ngIf="poll.published && poll.has_votes">
<div *ngFor="let vote of option.votes">
<div class="poll-progress on-transition-fade">
<span>{{ pollService.getLabel(vote.value) | translate }}:</span>
{{ pollService.getSpecialLabel(vote.weight) }}
<span *ngIf="!pollService.isAbstractOption(poll, option)"
>({{ pollService.getPercent(poll, option, vote.value) }}%)</span
>
</div>
<div *ngIf="!pollService.isAbstractOption(poll, option)" class="poll-progress-bar">
<mat-progress-bar
mode="determinate"
[value]="pollService.getPercent(poll, option, vote.value)"
[ngClass]="pollService.getProgressBarColor(vote.value)"
>
</mat-progress-bar>
</div>
</div>
</div>
<div
*ngIf="
poll.has_votes &&
poll.published &&
majorityChoice &&
majorityChoice.value !== 'disabled' &&
!pollService.isAbstractOption(poll, option)
"
>
<span>{{ pollService.yesQuorum(majorityChoice, poll, option) }}</span>
<span *ngIf="quorumReached(option)" class="green-text">
<mat-icon>done</mat-icon>
</span>
<span *ngIf="!quorumReached(option)" class="red-warning-text">
<mat-icon>cancel</mat-icon>
</span>
</div>
</div>
<div>
<!-- summary -->
<div *ngFor="let key of pollValues">
<div>
<span>{{ key | translate }}</span>:
</div>
<div>
{{ pollService.getSpecialLabel(poll[key]) }}
</div>
</div>
</div>
</div>
</mat-card>

View File

@ -0,0 +1,61 @@
::ng-deep .progress-green {
.mat-progress-bar-fill::after {
background-color: #4caf50;
}
.mat-progress-bar-buffer {
background-color: #d5ecd5;
}
}
::ng-deep .progress-red {
.mat-progress-bar-fill::after {
background-color: #f44336;
}
.mat-progress-bar-buffer {
background-color: #fcd2cf;
}
}
::ng-deep .progress-yellow {
.mat-progress-bar-fill::after {
background-color: #ffc107;
}
.mat-progress-bar-buffer {
background-color: #fff0c4;
}
}
.poll-result {
.poll-progress-bar {
height: 5px;
width: 100%;
.mat-progress-bar {
height: 100%;
width: 100%;
}
}
.poll-progress {
display: flex;
margin-bottom: 15px;
margin-top: 15px;
mat-icon {
min-width: 40px;
margin-right: 5px;
}
.progress-container {
width: 85%;
}
}
}
.main-nav-color {
color: rgba(0, 0, 0, 0.54);
}
.poll-quorum-line {
display: flex;
vertical-align: bottom;
.mat-button {
padding: 1px;
}
}

View File

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

View File

@ -0,0 +1,230 @@
import { Component, OnInit, Input } from '@angular/core';
import { MatDialog, MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { AssignmentPollDialogComponent } from './assignment-poll-dialog.component';
import { AssignmentPollService } from '../../services/assignment-poll.service';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { MajorityMethod, CalculablePollKey } from 'app/core/ui-services/poll.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { Poll } from 'app/shared/models/assignments/poll';
import { PollOption } from 'app/shared/models/assignments/poll-option';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewAssignment } from '../../models/view-assignment';
import { BaseViewComponent } from 'app/site/base/base-view';
import { Title } from '@angular/platform-browser';
/**
* Component for a single assignment poll. Used in assignment detail view
*/
@Component({
selector: 'os-assignment-poll',
templateUrl: './assignment-poll.component.html',
styleUrls: ['./assignment-poll.component.scss']
})
export class AssignmentPollComponent extends BaseViewComponent implements OnInit {
/**
* The related assignment (used for metainfos, e.g. related user names)
*/
@Input()
public assignment: ViewAssignment;
/**
* The poll represented in this component
*/
@Input()
public poll: Poll;
/**
* The selected Majority method to display quorum calculations. Will be
* set/changed by the user
*/
public majorityChoice: MajorityMethod | null;
/**
* permission checks.
* TODO stub
*
* @returns true if the user is permitted to do operations
*/
public get canManage(): boolean {
return this.operator.hasPerms('assignments.can_manage');
}
/**
* Gets the voting options
*
* @returns all used (not undefined) option-independent values that are
* used in this poll (e.g.)
*/
public get pollValues(): CalculablePollKey[] {
return this.pollService.pollValues.filter(name => this.poll[name] !== undefined);
}
/**
* Gets the translated poll method name
*
* TODO: check/improve text here
*
* @returns a name for the poll method this poll is set to (which is determined
* by the number of candidates and config settings).
*/
public get pollMethodName(): string {
if (!this.poll) {
return '';
}
switch (this.poll.pollmethod) {
case 'votes':
return this.translate.instant('Vote per Candidate');
case 'yna':
return this.translate.instant('Yes/No/Abstain per Candidate');
case 'yn':
return this.translate.instant('Yes/No per Candidate');
default:
return '';
}
}
/**
* constructor. Does nothing
*
* @param pollService poll related calculations
* @param operator permission checks
* @param assignmentRepo The repository to the assignments
* @param translate Translation service
* @param dialog MatDialog for the vote entering dialog
* @param promptService Prompts for confirmation dialogs
*/
public constructor(
titleService: Title,
matSnackBar: MatSnackBar,
public pollService: AssignmentPollService,
private operator: OperatorService,
private assignmentRepo: AssignmentRepositoryService,
public translate: TranslateService,
public dialog: MatDialog,
private promptService: PromptService
) {
super(titleService, translate, matSnackBar);
}
/**
* Gets the currently selected majority choice option from the repo
*/
public ngOnInit(): void {
this.majorityChoice =
this.pollService.majorityMethods.find(method => method.value === this.pollService.defaultMajorityMethod) ||
null;
}
/**
* Handler for the 'delete poll' button
*
* TODO: Some confirmation (advanced logic (e.g. not deleting published?))
*/
public async onDeletePoll(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete this poll?');
if (await this.promptService.open(title, null)) {
await this.assignmentRepo.deletePoll(this.poll).then(null, this.raiseError);
}
}
/**
* Print the PDF of this poll with the corresponding options and numbers
*
* TODO Print the ballots for this poll.
*/
public printBallot(poll: Poll): void {
this.raiseError('Not yet implemented');
}
/**
* Fetches the name for a candidate from the assignment
*
* @param option Any poll option
* @returns the full_name for the candidate
*/
public getCandidateName(option: PollOption): string {
const user = this.assignment.candidates.find(candidate => candidate.id === option.candidate_id);
return user ? user.full_name : '';
// TODO this.assignment.candidates may not contain every candidates' name (if deleted later)
// so we should rather use this.userRepo.getViewModel(option.id).full_name
// TODO is this name always available?
// TODO error handling
}
/**
* 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: PollOption): 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.poll, option);
return yesQuorum && amount >= yesQuorum;
}
/**
* Opens the {@link AssignmentPollDialogComponent} dialog and then updates the votes, if the dialog
* closes successfully (validation is done there)
*/
public enterVotes(): void {
// TODO deep copy of this.poll (JSON parse is ugly workaround)
// or sending just copy of the options
const data = {
poll: JSON.parse(JSON.stringify(this.poll)),
users: this.assignment.candidates // used to get the names of the users
};
const dialogRef = this.dialog.open(AssignmentPollDialogComponent, {
data: data,
maxHeight: '90vh',
minWidth: '300px',
maxWidth: '80vw',
disableClose: true
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.assignmentRepo.updateVotes(result, this.poll).then(null, this.raiseError);
}
});
}
/**
* Updates the majority method for this poll
*
* @param method the selected majority method
*/
public setMajority(method: MajorityMethod): void {
this.majorityChoice = method;
}
/**
* Toggles the 'published' state
*/
public togglePublished(): void {
this.assignmentRepo.updatePoll({ published: !this.poll.published }, this.poll);
}
/**
* Mark/unmark an option as elected
*
* @param option
*/
public toggleElected(option: PollOption): void {
if (!this.operator.hasPerms('assignments.can_manage')) {
return;
}
// TODO additional conditions: assignment not finished?
const candidate = this.assignment.assignment.assignment_related_users.find(
user => user.user_id === option.candidate_id
);
if (candidate) {
this.assignmentRepo.markElected(candidate, this.assignment, !option.is_elected);
}
}
}

View File

@ -6,6 +6,12 @@ import { ViewUser } from 'app/site/users/models/view-user';
import { ViewItem } from 'app/site/agenda/models/view-item'; import { ViewItem } from 'app/site/agenda/models/view-item';
import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewTag } from 'app/site/tags/models/view-tag';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
import { Poll } from 'app/shared/models/assignments/poll';
export interface AssignmentPhase {
value: number;
display_name: string;
}
export class ViewAssignment extends BaseAgendaViewModel { export class ViewAssignment extends BaseAgendaViewModel {
public static COLLECTIONSTRING = Assignment.COLLECTIONSTRING; public static COLLECTIONSTRING = Assignment.COLLECTIONSTRING;
@ -50,6 +56,10 @@ export class ViewAssignment extends BaseAgendaViewModel {
return this.candidates ? this.candidates.length : 0; return this.candidates ? this.candidates.length : 0;
} }
public get polls(): Poll[] {
return this.assignment ? this.assignment.polls : []; // TODO check
}
/** /**
* This is set by the repository * This is set by the repository
*/ */
@ -59,6 +69,9 @@ export class ViewAssignment extends BaseAgendaViewModel {
public constructor(assignment: Assignment, relatedUser?: ViewUser[], agendaItem?: ViewItem, tags?: ViewTag[]) { public constructor(assignment: Assignment, relatedUser?: ViewUser[], agendaItem?: ViewItem, tags?: ViewTag[]) {
super(Assignment.COLLECTIONSTRING); super(Assignment.COLLECTIONSTRING);
console.log('related user: ', relatedUser);
this._assignment = assignment; this._assignment = assignment;
this._relatedUser = relatedUser; this._relatedUser = relatedUser;
this._agendaItem = agendaItem; this._agendaItem = agendaItem;
@ -84,7 +97,7 @@ export class ViewAssignment extends BaseAgendaViewModel {
} }
public getDetailStateURL(): string { public getDetailStateURL(): string {
return 'TODO'; return `/assignments/${this.id}`;
} }
public getSlide(): ProjectorElementBuildDeskriptor { public getSlide(): ProjectorElementBuildDeskriptor {

View File

@ -1,10 +1,11 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service';
import { Assignment, assignmentPhase } from 'app/shared/models/assignments/assignment'; import { Assignment } from 'app/shared/models/assignments/assignment';
import { BaseFilterListService, OsFilter, OsFilterOption } from 'app/core/ui-services/base-filter-list.service'; import { BaseFilterListService, OsFilter } from 'app/core/ui-services/base-filter-list.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { ViewAssignment } from '../models/view-assignment'; import { ViewAssignment, AssignmentPhase } from '../models/view-assignment';
import { ConstantsService } from 'app/core/ui-services/constants.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -12,32 +13,53 @@ import { ViewAssignment } from '../models/view-assignment';
export class AssignmentFilterListService extends BaseFilterListService<Assignment, ViewAssignment> { export class AssignmentFilterListService extends BaseFilterListService<Assignment, ViewAssignment> {
protected name = 'Assignment'; protected name = 'Assignment';
public filterOptions: OsFilter[]; /**
* Getter for the current filter options
*
* @returns filter definitions to use
*/
public get filterOptions(): OsFilter[] {
return [this.phaseFilter];
}
public constructor(store: StorageService, assignmentRepo: AssignmentRepositoryService) { /**
super(store, assignmentRepo); * Filter for assignment phases. Defined in the servers' constants
this.filterOptions = [ */
{ public phaseFilter: OsFilter = {
property: 'phase', property: 'phase',
options: this.createPhaseOptions() options: []
} };
];
/**
* Constructor. Activates the phase options subscription
*
* @param store StorageService
* @param assignmentRepo Repository
* @param constants the openslides constant service to get the assignment options
*/
public constructor(
store: StorageService,
assignmentRepo: AssignmentRepositoryService,
private constants: ConstantsService
) {
super(store, assignmentRepo);
this.createPhaseOptions();
} }
private createPhaseOptions(): OsFilterOption[] { /**
const options = []; * Subscribes to the phases of an assignment that are defined in the server's
assignmentPhase.forEach(phase => { * constants
options.push({ */
label: phase.name, private createPhaseOptions(): void {
condition: phase.key, this.constants.get<AssignmentPhase[]>('AssignmentPhases').subscribe(phases => {
this.phaseFilter.options = phases.map(ph => {
return {
label: ph.display_name,
condition: ph.value,
isActive: false isActive: false
};
}); });
}); });
options.push('-'); this.updateFilterDefinitions(this.filterOptions);
options.push({
label: 'Other',
condition: null
});
return options;
} }
} }

View File

@ -0,0 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { AssignmentPollService } from './assignment-poll.service';
describe('PollService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: AssignmentPollService = TestBed.get(AssignmentPollService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,200 @@
import { Injectable } from '@angular/core';
import { ConfigService } from 'app/core/ui-services/config.service';
import {
PollService,
PollMajorityMethod,
MajorityMethod,
CalculablePollKey,
PollVoteValue
} from 'app/core/ui-services/poll.service';
import { Poll } from 'app/shared/models/assignments/poll';
import { PollOption } from 'app/shared/models/assignments/poll-option';
type AssignmentPollValues = 'auto' | 'votes' | 'yesnoabstain' | 'yesno';
export type AssignmentPollMethod = 'yn' | 'yna' | 'votes';
type AssignmentPercentBase = 'YES_NO_ABSTAIN' | 'YES_NO' | 'VALID' | 'CAST' | 'DISABLED';
/**
* Service class for assignment polls.
*/
@Injectable({
providedIn: 'root'
})
export class AssignmentPollService extends PollService {
/**
* list of poll keys that are numbers and can be part of a quorum calculation
*/
public pollValues: CalculablePollKey[] = ['votesvalid', 'votesinvalid', 'votescast'];
/**
* the method used for polls (as per config)
*/
public pollMethod: AssignmentPollValues;
/**
* the method used to determine the '100%' base (set in config)
*/
public percentBase: AssignmentPercentBase;
/**
* convenience function for displaying the available majorities
*/
public get majorityMethods(): MajorityMethod[] {
return PollMajorityMethod;
}
/**
* Constructor. Subscribes to the configuration values needed
*
* @param config ConfigService
*/
public constructor(config: ConfigService) {
super();
config
.get<string>('assignments_poll_default_majority_method')
.subscribe(method => (this.defaultMajorityMethod = method));
config
.get<AssignmentPollValues>('assignments_poll_vote_values')
.subscribe(method => (this.pollMethod = method));
config
.get<AssignmentPercentBase>('assignments_poll_100_percent_base')
.subscribe(base => (this.percentBase = base));
}
/**
* Get the base amount for the 100% calculations. Note that some poll methods
* (e.g. yes/no/abstain may have a different percentage base and will return null here)
*
* @param poll
* @returns The amount of votes indicating the 100% base
*/
public getBaseAmount(poll: Poll): number | null {
switch (this.percentBase) {
case 'DISABLED':
return null;
case 'YES_NO':
case 'YES_NO_ABSTAIN':
if (poll.pollmethod === 'votes') {
const yes = poll.options.map(cand => {
const yesValue = cand.votes.find(v => v.value === 'Yes');
return yesValue ? yesValue.weight : -99;
});
if (Math.min(...yes) < 0) {
return null;
} else {
return yes.reduce((a, b) => a + b);
}
} else {
return null;
}
case 'CAST':
return poll.votescast > 0 && poll.votesinvalid >= 0 ? poll.votescast : null;
case 'VALID':
return poll.votesvalid > 0 ? poll.votesvalid : null;
default:
return null;
}
}
/**
* Get the percentage for an option
*
* @param poll
* @param option
* @param value
* @returns a percentage number with two digits, null if the value cannot be calculated
*/
public getPercent(poll: Poll, option: PollOption, value: PollVoteValue): number | null {
const base = poll.pollmethod === 'votes' ? poll.pollBase : this.getOptionBaseAmount(poll, option);
if (!base) {
return null;
}
const vote = option.votes.find(v => v.value === value);
if (!vote) {
return null;
}
return Math.round(((vote.weight * 100) / base) * 100) / 100;
}
/**
* Check if the option in a poll is abstract (percentages should not be calculated)
*
* @param poll
* @param option
* @returns true if the poll has no percentages, the poll option is a special value,
* or if the calculations are disabled in the config
*/
public isAbstractOption(poll: Poll, option: PollOption): boolean {
if (!option.votes || !option.votes.length) {
return true;
}
if (poll.pollmethod === 'votes') {
return poll.pollBase ? false : true;
} else {
return option.votes.some(v => v.weight < 0);
}
}
/**
* Check for abstract (not usable as percentage) options in non-option
* 'meta' values
*
* @param poll
* @param value
* @returns true if percentages cannot be calculated
* TODO: Yes, No, etc. in an option will always return true.
* Use {@link isAbstractOption} for these
*/
public isAbstractValue(poll: Poll, value: CalculablePollKey): boolean {
if (!poll.pollBase || !this.pollValues.includes(value)) {
return true;
}
if (this.percentBase === 'CAST' && poll[value] >= 0) {
return false;
} else if (this.percentBase === 'VALID' && value === 'votesvalid' && poll[value] > 0) {
return false;
}
return true;
}
/**
* Calculate the base amount inside an option. Only useful if poll method is not 'votes'
*
* @returns an positive integer to be used as percentage base, or null
*/
private getOptionBaseAmount(poll: Poll, option: PollOption): number | null {
if (poll.pollmethod === 'votes') {
return null;
}
const yes = option.votes.find(v => v.value === 'Yes');
const no = option.votes.find(v => v.value === 'No');
if (poll.pollmethod === 'yn') {
if (!yes || yes.weight === undefined || !no || no.weight === undefined) {
return null;
}
return yes.weight >= 0 && no.weight >= 0 ? yes.weight + no.weight : null;
} else {
const abstain = option.votes.find(v => v.value === 'Abstain');
if (!abstain || abstain.weight === undefined) {
return null;
}
return yes.weight >= 0 && no.weight >= 0 && abstain.weight >= 0
? yes.weight + no.weight + abstain.weight
: null;
}
}
/**
* Get the minimum amount of votes needed for an option to pass the quorum
*
* @param method
* @param poll
* @param option
* @returns a positive integer number; may return null if quorum is not calculable
*/
public yesQuorum(method: MajorityMethod, poll: Poll, option: PollOption): number | null {
const baseAmount = poll.pollmethod === 'votes' ? poll.pollBase : this.getOptionBaseAmount(poll, option);
return method.calc(baseAmount);
}
}

View File

@ -25,11 +25,6 @@ mat-expansion-panel {
margin: auto; margin: auto;
} }
.flex-spaced {
display: flex;
justify-content: space-between;
}
.full-width-form { .full-width-form {
display: flex; display: flex;
width: 100%; width: 100%;

View File

@ -3,7 +3,8 @@ import { MatDialogRef, MAT_DIALOG_DATA, MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { MotionPollService, CalculablePollKey } from 'app/site/motions/services/motion-poll.service'; import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { CalculablePollKey } from 'app/core/ui-services/poll.service';
/** /**
* A dialog for updating the values of a poll. * A dialog for updating the values of a poll.
@ -15,7 +16,8 @@ import { MotionPollService, CalculablePollKey } from 'app/site/motions/services/
}) })
export class MotionPollDialogComponent { export class MotionPollDialogComponent {
/** /**
* List of accepted special non-numerical values from {@link PollService} * List of accepted special non-numerical values.
* See {@link PollService.specialPollVotes}
*/ */
public specialValues: [number, string][]; public specialValues: [number, string][];

View File

@ -20,7 +20,7 @@
<mat-progress-bar <mat-progress-bar
mode="determinate" mode="determinate"
[value]="getPercent(key)" [value]="getPercent(key)"
[ngClass]="getProgressBarColor(key)" [ngClass]="pollService.getProgressBarColor(key)"
> >
</mat-progress-bar> </mat-progress-bar>
</div> </div>

View File

@ -86,7 +86,7 @@ export class MotionPollComponent implements OnInit {
*/ */
public constructor( public constructor(
public dialog: MatDialog, public dialog: MatDialog,
private pollService: MotionPollService, public pollService: MotionPollService,
private motionRepo: MotionRepositoryService, private motionRepo: MotionRepositoryService,
private constants: ConstantsService, private constants: ConstantsService,
private translate: TranslateService, private translate: TranslateService,
@ -138,26 +138,6 @@ export class MotionPollComponent implements OnInit {
return this.pollService.getIcon(key); return this.pollService.getIcon(key);
} }
/**
* Get the progressbar class for a decision key
*
* @param key
*
* @returns a css class designing a progress bar in a color, or an empty string
*/
public getProgressBarColor(key: CalculablePollKey): string {
switch (key) {
case 'yes':
return 'progress-green';
case 'no':
return 'progress-red';
case 'abstain':
return 'progress-yellow';
default:
return '';
}
}
/** /**
* Transform special case numbers into their strings * Transform special case numbers into their strings
* @param key * @param key

View File

@ -34,7 +34,7 @@
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<div *ngFor="let state of workflow.states; trackBy: trackElement"> <div *ngFor="let state of workflow.states; trackBy: trackByIndex">
<ng-container [matColumnDef]="getColumnDef(state)"> <ng-container [matColumnDef]="getColumnDef(state)">
<mat-header-cell *matHeaderCellDef (click)="onClickStateName(state)"> <mat-header-cell *matHeaderCellDef (click)="onClickStateName(state)">
<div class="clickable-cell"> <div class="clickable-cell">

View File

@ -228,7 +228,7 @@ export class MotionImportService extends BaseImportService<ViewMotion> {
* @param categoryString * @param categoryString
* @returns categories mapped to existing categories * @returns categories mapped to existing categories
*/ */
public getCategory(categoryString: string): CsvMapping { public getCategory(categoryString: string): CsvMapping | null {
if (!categoryString) { if (!categoryString) {
return null; return null;
} }
@ -266,7 +266,7 @@ export class MotionImportService extends BaseImportService<ViewMotion> {
* @param blockString * @param blockString
* @returns a CSVMap with the MotionBlock and an id (if the motionBlock is already in the dataStore) * @returns a CSVMap with the MotionBlock and an id (if the motionBlock is already in the dataStore)
*/ */
public getMotionBlock(blockString: string): CsvMapping { public getMotionBlock(blockString: string): CsvMapping | null {
if (!blockString) { if (!blockString) {
return null; return null;
} }

View File

@ -2,10 +2,11 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { CalculablePollKey } from 'app/core/ui-services/poll.service';
import { ChangeRecommendationRepositoryService } from 'app/core/repositories/motions/change-recommendation-repository.service'; import { ChangeRecommendationRepositoryService } from 'app/core/repositories/motions/change-recommendation-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { HtmlToPdfService } from 'app/core/ui-services/html-to-pdf.service'; import { HtmlToPdfService } from 'app/core/ui-services/html-to-pdf.service';
import { MotionPollService, CalculablePollKey } from './motion-poll.service'; import { MotionPollService } from './motion-poll.service';
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
import { StatuteParagraphRepositoryService } from 'app/core/repositories/motions/statute-paragraph-repository.service'; import { StatuteParagraphRepositoryService } from 'app/core/repositories/motions/statute-paragraph-repository.service';
import { ViewMotion, LineNumberingMode, ChangeRecoMode } from '../models/view-motion'; import { ViewMotion, LineNumberingMode, ChangeRecoMode } from '../models/view-motion';

View File

@ -2,9 +2,7 @@ import { Injectable } from '@angular/core';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { PollService } from 'app/core/ui-services/poll.service'; import { PollService, PollMajorityMethod, CalculablePollKey } from 'app/core/ui-services/poll.service';
export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain';
/** /**
* Service class for motion polls. * Service class for motion polls.
@ -37,7 +35,7 @@ export class MotionPollService extends PollService {
* @param key * @param key
* @returns a percentage number with two digits, null if the value cannot be calculated (consider 0 !== null) * @returns a percentage number with two digits, null if the value cannot be calculated (consider 0 !== null)
*/ */
public calculatePercentage(poll: MotionPoll, key: CalculablePollKey): number { public calculatePercentage(poll: MotionPoll, key: CalculablePollKey): number | null {
const baseNumber = this.getBaseAmount(poll); const baseNumber = this.getBaseAmount(poll);
if (!baseNumber) { if (!baseNumber) {
return null; return null;
@ -126,18 +124,11 @@ export class MotionPollService extends PollService {
return undefined; return undefined;
} }
let result: number; let result: number;
switch (method) { const calc = PollMajorityMethod.find(m => m.value === method);
case 'simple_majority': if (calc && calc.calc) {
result = baseNumber * 0.5; result = calc.calc(baseNumber);
break; } else {
case 'two-thirds_majority': result = null;
result = (baseNumber / 3) * 2;
break;
case 'three-quarters_majority':
result = (baseNumber / 4) * 3;
break;
default:
return undefined;
} }
// rounding up, or if a integer was hit, adding one. // rounding up, or if a integer was hit, adding one.
if (result % 1 !== 0) { if (result % 1 !== 0) {

View File

@ -44,7 +44,7 @@
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<div *ngFor=" let group of groups; trackBy: trackGroupArray"> <div *ngFor="let group of groups; trackBy: trackByIndex">
<ng-container [matColumnDef]="group.name"> <ng-container [matColumnDef]="group.name">
<mat-header-cell class="group-head-table-cell" *matHeaderCellDef (click)="selectGroup(group)"> <mat-header-cell class="group-head-table-cell" *matHeaderCellDef (click)="selectGroup(group)">
<div class="inner-table"> <div class="inner-table">

View File

@ -305,6 +305,12 @@ mat-paginator {
margin-top: 50px; margin-top: 50px;
} }
/**even distribution of elements in a row*/
.flex-spaced {
display: flex;
justify-content: space-between;
}
/**use to push content to the right side*/ /**use to push content to the right side*/
.spacer { .spacer {
flex: 1 1 auto; flex: 1 1 auto;

View File

@ -6,12 +6,12 @@ from openslides.utils.rest_api import (
DictField, DictField,
IntegerField, IntegerField,
ListField, ListField,
ListSerializer,
ModelSerializer, ModelSerializer,
SerializerMethodField, SerializerMethodField,
ValidationError, ValidationError,
) )
from ..utils.autoupdate import inform_changed_data
from ..utils.validate import validate_html from ..utils.validate import validate_html
from .models import ( from .models import (
Assignment, Assignment,
@ -19,7 +19,6 @@ from .models import (
AssignmentPoll, AssignmentPoll,
AssignmentRelatedUser, AssignmentRelatedUser,
AssignmentVote, AssignmentVote,
models,
) )
@ -79,25 +78,6 @@ class AssignmentOptionSerializer(ModelSerializer):
return obj.poll.assignment.is_elected(obj.candidate) return obj.poll.assignment.is_elected(obj.candidate)
class FilterPollListSerializer(ListSerializer):
"""
Customized serializer to filter polls (exclude unpublished).
"""
def to_representation(self, data):
"""
List of object instances -> List of dicts of primitive datatypes.
This method is adapted to filter the data and exclude unpublished polls.
"""
# Dealing with nested relationships, data can be a Manager,
# so, first get a queryset from the Manager if needed
iterable = (
data.filter(published=True) if isinstance(data, models.Manager) else data
)
return [self.child.to_representation(item) for item in iterable]
class AssignmentAllPollSerializer(ModelSerializer): class AssignmentAllPollSerializer(ModelSerializer):
""" """
Serializer for assignment.models.AssignmentPoll objects. Serializer for assignment.models.AssignmentPoll objects.
@ -197,31 +177,6 @@ class AssignmentAllPollSerializer(ModelSerializer):
return instance return instance
class AssignmentShortPollSerializer(AssignmentAllPollSerializer):
"""
Serializer for assignment.models.AssignmentPoll objects.
Serializes only short polls (excluded unpublished polls).
"""
class Meta:
list_serializer_class = FilterPollListSerializer
model = AssignmentPoll
fields = (
"id",
"pollmethod",
"description",
"published",
"options",
"votesabstain",
"votesno",
"votesvalid",
"votesinvalid",
"votescast",
"has_votes",
)
class AssignmentFullSerializer(ModelSerializer): class AssignmentFullSerializer(ModelSerializer):
""" """
Serializer for assignment.models.Assignment objects. With all polls. Serializer for assignment.models.Assignment objects. With all polls.
@ -266,8 +221,11 @@ class AssignmentFullSerializer(ModelSerializer):
""" """
agenda_type = validated_data.pop("agenda_type", None) agenda_type = validated_data.pop("agenda_type", None)
agenda_parent_id = validated_data.pop("agenda_parent_id", None) agenda_parent_id = validated_data.pop("agenda_parent_id", None)
tags = validated_data.pop("tags", [])
assignment = Assignment(**validated_data) assignment = Assignment(**validated_data)
assignment.agenda_item_update_information["type"] = agenda_type assignment.agenda_item_update_information["type"] = agenda_type
assignment.agenda_item_update_information["parent_id"] = agenda_parent_id assignment.agenda_item_update_information["parent_id"] = agenda_parent_id
assignment.save() assignment.save()
assignment.tags.add(*tags)
inform_changed_data(assignment)
return assignment return assignment

View File

@ -204,6 +204,13 @@ class ModelSerializerRegisterer(SerializerMetaclass):
except AttributeError: except AttributeError:
pass pass
else: else:
if model_serializer_classes.get(model) is not None:
error = (
f"Model {model} is already used for the serializer class "
f"{model_serializer_classes[model]} and cannot be registered "
f"for serializer class {serializer_class}."
)
raise RuntimeError(error)
model_serializer_classes[model] = serializer_class model_serializer_classes[model] = serializer_class
return serializer_class return serializer_class