Merge pull request #4537 from MaximilianKrambach/assignments
assignment detail and list views
This commit is contained in:
commit
6f62e5c7e4
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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) {
|
||||||
|
@ -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[];
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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));
|
||||||
|
@ -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
|
||||||
|
@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listview for the assignments
|
* List view for the assignments
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'os-assignment-list',
|
selector: 'os-assignment-list',
|
||||||
@ -25,34 +25,40 @@ 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 listview
|
// activate multiSelect mode for this list view
|
||||||
this.canMultiSelect = true;
|
this.canMultiSelect = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
||||||
|
@ -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)],
|
||||||
|
@ -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 {}
|
||||||
|
@ -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> </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; "flex-spaced"">
|
||||||
|
<div>
|
||||||
|
<span translate>Number of persons to be elected</span>:
|
||||||
|
<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>
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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})`;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
<h2 translate>Voting result</h2>
|
||||||
|
<div class="meta-text">
|
||||||
|
<span translate>Special values</span>:<br />
|
||||||
|
<mat-chip>-1</mat-chip> = <span translate>majority</span><br />
|
||||||
|
<mat-chip color="accent">-2</mat-chip> =
|
||||||
|
<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>
|
@ -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;
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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%;
|
||||||
|
@ -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][];
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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">
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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';
|
||||||
|
@ -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) {
|
||||||
|
@ -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">
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user