diff --git a/client/src/app/base.component.ts b/client/src/app/base.component.ts index df2a8b7a4..61cb9c853 100644 --- a/client/src/app/base.component.ts +++ b/client/src/app/base.component.ts @@ -64,4 +64,13 @@ export abstract class BaseComponent { const translatedPrefix = this.translate.instant(prefix); this.titleService.setTitle(translatedPrefix + this.titleSuffix); } + + /** + * Helper for indexed *ngFor components + * + * @param index + */ + public trackByIndex(index: number): number { + return index; + } } diff --git a/client/src/app/core/repositories/assignments/assignment-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-repository.service.ts index a33cf4df6..d055eb8ab 100644 --- a/client/src/app/core/repositories/assignments/assignment-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-repository.service.ts @@ -3,11 +3,14 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; 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 { CollectionStringMapperService } from '../../core-services/collectionStringMapper.service'; import { DataSendService } from 'app/core/core-services/data-send.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 { Poll } from 'app/shared/models/assignments/poll'; import { Tag } from 'app/shared/models/core/tag'; import { User } from 'app/shared/models/users/user'; import { ViewAssignment } from 'app/site/assignments/models/view-assignment'; @@ -25,18 +28,30 @@ import { ViewUser } from 'app/site/users/models/view-user'; providedIn: 'root' }) export class AssignmentRepositoryService extends BaseAgendaContentObjectRepository { + 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. * - * @param DS The DataStore - * @param mapperService Maps collection strings to classes + * @param DS DataStore access + * @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( DS: DataStoreService, dataSend: DataSendService, mapperService: CollectionStringMapperService, viewModelStoreService: ViewModelStoreService, - translate: TranslateService + protected translate: TranslateService, + private httpService: HttpService ) { super(DS, dataSend, mapperService, viewModelStoreService, translate, Assignment, [User, Item, Tag]); } @@ -64,4 +79,136 @@ export class AssignmentRepositoryService extends BaseAgendaContentObjectReposito viewAssignment.getAgendaTitleWithType = () => this.getAgendaTitleWithType(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 { + 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 { + 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 { + 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 { + 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 { + 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, originalPoll: Poll): Promise { + 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, originalPoll: Poll): Promise { + 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 { + 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 { + throw Error('TODO'); + // const restPath = `/rest/assignments/assignment/${assignment.id}/sort_related_users`; + // const data = { related_users: sortedCandidates }; + // await this.httpService.post(restPath, data); + } } diff --git a/client/src/app/core/ui-services/poll.service.ts b/client/src/app/core/ui-services/poll.service.ts index 3c768b646..185e81e72 100644 --- a/client/src/app/core/ui-services/poll.service.ts +++ b/client/src/app/core/ui-services/poll.service.ts @@ -6,37 +6,85 @@ import { _ } from 'app/core/translate/translation-marker'; * The possible keys of a poll object that represent numbers. * TODO Should be 'key of MotionPoll if type of key is number' * TODO: normalize MotionPoll model and other poll models - * TODO: reuse more motion-poll-service stuff */ export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain'; /** - * Shared service class for polls. - * TODO: For now, motionPolls only. TODO See if reusable for assignment polls etc. + * TODO: may be obsolete if the server switches to lower case only + * (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({ providedIn: 'root' }) -export class PollService { +export abstract class PollService { /** - * The chosen and currently used base for percentage calculations. Is set by - * the config service + * The chosen and currently used base for percentage calculations. Is + * supposed to be set by a config service */ public percentBase: string; /** - * The default majority method (as set per config). + * The default majority method (to be set set per config). */ public defaultMajorityMethod: string; + /** + * The majority method currently in use + */ + public majorityMethod: MajorityMethod; + /** * 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']]; /** - * getter for the special votes + * getter for the special vote values * * @returns an array of special (non-positive) numbers used in polls and * their descriptive strings @@ -53,9 +101,9 @@ export class PollService { /** * 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 - * 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 { switch (key) { @@ -65,7 +113,7 @@ export class PollService { return 'thumb_down'; case 'abstain': return 'not_interested'; - // case 'votescast': + // TODO case 'votescast': // sum case 'votesvalid': return 'check'; @@ -79,10 +127,11 @@ export class PollService { /** * 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 */ - public getLabel(key: CalculablePollKey): string { - switch (key) { + public getLabel(key: CalculablePollKey | PollVoteValue): string { + switch (key.toLowerCase()) { case 'yes': return 'Yes'; case 'no': @@ -102,11 +151,11 @@ export class PollService { /** * retrieve special labels for a poll value - * - * @param value - * @returns the label for a non-positive value, according to * {@link specialPollVotes}. Positive values will return as string * representation of themselves + * + * @param value check value for special numbers + * @returns the label for a non-positive value, according to */ public getSpecialLabel(value: number): string { if (value >= 0) { @@ -115,4 +164,25 @@ export class PollService { const vote = this.specialPollVotes.find(special => special[0] === 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 ''; + } + } } diff --git a/client/src/app/shared/components/os-sort-filter-bar/os-sort-filter-bar.component.scss b/client/src/app/shared/components/os-sort-filter-bar/os-sort-filter-bar.component.scss index 58492d1cb..a19ecd38b 100644 --- a/client/src/app/shared/components/os-sort-filter-bar/os-sort-filter-bar.component.scss +++ b/client/src/app/shared/components/os-sort-filter-bar/os-sort-filter-bar.component.scss @@ -9,11 +9,6 @@ span.right-with-margin { margin-right: 25px; } -.flex-spaced { - display: flex; - justify-content: space-between; -} - .filter-count { font-style: italic; margin-right: 10px; diff --git a/client/src/app/shared/models/assignments/assignment-user.ts b/client/src/app/shared/models/assignments/assignment-user.ts index f2904552a..2c075c015 100644 --- a/client/src/app/shared/models/assignments/assignment-user.ts +++ b/client/src/app/shared/models/assignments/assignment-user.ts @@ -1,18 +1,38 @@ 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 */ export class AssignmentUser extends Deserializer { public id: number; + + /** + * id of the user this assignment user relates to + */ public user_id: number; + + /** + * The current 'elected' state + */ public elected: boolean; + + /** + * id of the related assignment + */ public assignment_id: number; + + /** + * A weight to determine the position in the list of candidates + * (determined by the server) + */ 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 */ public constructor(input?: any) { diff --git a/client/src/app/shared/models/assignments/assignment.ts b/client/src/app/shared/models/assignments/assignment.ts index b63473f96..eff41fc98 100644 --- a/client/src/app/shared/models/assignments/assignment.ts +++ b/client/src/app/shared/models/assignments/assignment.ts @@ -2,12 +2,6 @@ import { AssignmentUser } from './assignment-user'; import { Poll } from './poll'; 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. * @ignore @@ -18,7 +12,7 @@ export class Assignment extends BaseModel { public title: string; public description: string; public open_posts: number; - public phase: number; + public phase: number; // see Openslides constants public assignment_related_users: AssignmentUser[]; public poll_description_default: number; public polls: Poll[]; diff --git a/client/src/app/shared/models/assignments/poll-option.ts b/client/src/app/shared/models/assignments/poll-option.ts index ddbc21cf7..2b2aca993 100644 --- a/client/src/app/shared/models/assignments/poll-option.ts +++ b/client/src/app/shared/models/assignments/poll-option.ts @@ -1,4 +1,5 @@ import { Deserializer } from '../base/deserializer'; +import { PollVoteValue } from 'app/core/ui-services/poll.service'; /** * Representation of a poll option @@ -7,18 +8,38 @@ import { Deserializer } from '../base/deserializer'; * @ignore */ export class PollOption extends Deserializer { - public id: number; - public candidate_id: number; + public id: number; // The AssignmentUser id of the candidate + public candidate_id: number; // the User id of the candidate public is_elected: boolean; - public votes: number[]; + public votes: { + weight: number; // TODO arrives as string? + value: PollVoteValue; + }[]; 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' + * * @param input */ 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); } } diff --git a/client/src/app/shared/models/assignments/poll.ts b/client/src/app/shared/models/assignments/poll.ts index 268c377c9..a27aacc32 100644 --- a/client/src/app/shared/models/assignments/poll.ts +++ b/client/src/app/shared/models/assignments/poll.ts @@ -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 { PollOption } from './poll-option'; /** * Content of the 'polls' property of assignments @@ -7,7 +8,7 @@ import { Deserializer } from '../base/deserializer'; */ export class Poll extends Deserializer { public id: number; - public pollmethod: string; + public pollmethod: AssignmentPollMethod; public description: string; public published: boolean; public options: PollOption[]; @@ -17,17 +18,33 @@ export class Poll extends Deserializer { public has_votes: boolean; 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' * @param input */ 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); } public deserialize(input: any): void { Object.assign(this, input); - this.options = []; if (input.options instanceof Array) { this.options = input.options.map(pollOptionData => new PollOption(pollOptionData)); diff --git a/client/src/app/site/assignments/assignment-list/assignment-list.component.html b/client/src/app/site/assignments/assignment-list/assignment-list.component.html index b58304df5..c704a36ff 100644 --- a/client/src/app/site/assignments/assignment-list/assignment-list.component.html +++ b/client/src/app/site/assignments/assignment-list/assignment-list.component.html @@ -1,4 +1,8 @@ - +

Elections

@@ -91,6 +95,10 @@ done_all Select all + + + + + + +
+ + + + + +
+
+
+ +
+ +
+
+

{{ assignment.title }}

+
+
+ +
+ + + + + + + +
+ +
+
+ + + + + + +
+
+
+ + + Meta information +
+
+ Number of persons to be elected:  + {{ assignment.assignment.open_posts }} +
+
+ {{ phaseString | translate }} + + Phase + + + {{ option.display_name | translate }} + + + +
+
+
+ + + + + + + + + + + +
+ + + +
+ + + + + + +
+ + + +
+
+
+ +
+ {{ candidate.username }} + +
+
+
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+ + +
+
+ + + + + + + +
+ This field is required. +
+ + +
+ +
+ + +
+ +
+ + + + + + + + + + +
+
+
diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts new file mode 100644 index 000000000..fde354de9 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts new file mode 100644 index 000000000..e0c84a4e6 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts @@ -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([]); + + /** + * TODO a filtered list (excluding users already in this.assignment.candidates) + */ + public filteredCandidates = new BehaviorSubject([]); + + /** + * 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; + + /** + * Used in the search Value selector to assign an agenda item + */ + public agendaObserver: BehaviorSubject; + + /** + * 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('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 { + await this.repo.addPoll(this.assignment).then(null, this.raiseError); + } + + /** + * Adds the operator to list of candidates + */ + public async addSelf(): Promise { + await this.repo.addSelf(this.assignment).then(null, this.raiseError); + } + + /** + * Removes the operator from list of candidates + */ + public async removeSelf(): Promise { + await this.repo.deleteSelf(this.assignment).then(null, this.raiseError); + } + + /** + * Adds a user to the list of candidates + */ + public async addUser(): Promise { + 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 { + 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 { + 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 { + 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})`; + } +} diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll-dialog.component.html b/client/src/app/site/assignments/components/assignment-poll/assignment-poll-dialog.component.html new file mode 100644 index 000000000..e7d069e4d --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll-dialog.component.html @@ -0,0 +1,75 @@ +

Voting result

+
+ Special values:
+ -1 =  majority
+ -2 =  + undocumented +
+
+
+
+ {{ getName(candidate.candidate_id) }} +
+
+ + + Yes + + + + No + + + + Abstain + +
+ +
+ + + Valid votes + + + + Invalid votes + + + + Total votes + +
+
+ + +
diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll-dialog.component.scss b/client/src/app/site/assignments/components/assignment-poll/assignment-poll-dialog.component.scss new file mode 100644 index 000000000..c57c41aa0 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll-dialog.component.scss @@ -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; +} diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll-dialog.component.ts b/client/src/app/site/assignments/components/assignment-poll/assignment-poll-dialog.component.ts new file mode 100644 index 000000000..21829f280 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll-dialog.component.ts @@ -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, + @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); + } +} diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html new file mode 100644 index 000000000..d2cb6d8a9 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html @@ -0,0 +1,119 @@ + +

Ballot

+ +
+
+ + Description + +
+ +
+ + + +
+
+ +
+
+ +
+
+
+ + +
+ +
+ Majority method + + + {{ majorityChoice.display_name | translate }} + + + {{ majorityChoice.display_name | translate }} + + + + +
+
+
+
+ + + +
+ +
+ {{ getCandidateName(option) }} +
+ +
+
+
+ {{ pollService.getLabel(vote.value) | translate }}: + {{ pollService.getSpecialLabel(vote.weight) }} + ({{ pollService.getPercent(poll, option, vote.value) }}%) +
+
+ + +
+
+
+
+ {{ pollService.yesQuorum(majorityChoice, poll, option) }} + + done + + + cancel + +
+
+
+ +
+
+ {{ key | translate }}: +
+
+ {{ pollService.getSpecialLabel(poll[key]) }} +
+
+
+
+
diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss new file mode 100644 index 000000000..b24fe7fa4 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss @@ -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; + } +} diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.spec.ts b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.spec.ts new file mode 100644 index 000000000..708ee596b --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts new file mode 100644 index 000000000..978edcaa0 --- /dev/null +++ b/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts @@ -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 { + 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); + } + } +} diff --git a/client/src/app/site/assignments/models/view-assignment.ts b/client/src/app/site/assignments/models/view-assignment.ts index 0eb5455a0..6ba86104d 100644 --- a/client/src/app/site/assignments/models/view-assignment.ts +++ b/client/src/app/site/assignments/models/view-assignment.ts @@ -6,6 +6,12 @@ import { ViewUser } from 'app/site/users/models/view-user'; import { ViewItem } from 'app/site/agenda/models/view-item'; import { ViewTag } from 'app/site/tags/models/view-tag'; 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 { public static COLLECTIONSTRING = Assignment.COLLECTIONSTRING; @@ -50,6 +56,10 @@ export class ViewAssignment extends BaseAgendaViewModel { 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 */ @@ -59,6 +69,9 @@ export class ViewAssignment extends BaseAgendaViewModel { public constructor(assignment: Assignment, relatedUser?: ViewUser[], agendaItem?: ViewItem, tags?: ViewTag[]) { super(Assignment.COLLECTIONSTRING); + + console.log('related user: ', relatedUser); + this._assignment = assignment; this._relatedUser = relatedUser; this._agendaItem = agendaItem; @@ -84,7 +97,7 @@ export class ViewAssignment extends BaseAgendaViewModel { } public getDetailStateURL(): string { - return 'TODO'; + return `/assignments/${this.id}`; } public getSlide(): ProjectorElementBuildDeskriptor { diff --git a/client/src/app/site/assignments/services/assignment-filter.service.ts b/client/src/app/site/assignments/services/assignment-filter.service.ts index 289c9df57..78e0d212d 100644 --- a/client/src/app/site/assignments/services/assignment-filter.service.ts +++ b/client/src/app/site/assignments/services/assignment-filter.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@angular/core'; import { AssignmentRepositoryService } from 'app/core/repositories/assignments/assignment-repository.service'; -import { Assignment, assignmentPhase } from 'app/shared/models/assignments/assignment'; -import { BaseFilterListService, OsFilter, OsFilterOption } from 'app/core/ui-services/base-filter-list.service'; +import { Assignment } from 'app/shared/models/assignments/assignment'; +import { BaseFilterListService, OsFilter } from 'app/core/ui-services/base-filter-list.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({ providedIn: 'root' @@ -12,32 +13,53 @@ import { ViewAssignment } from '../models/view-assignment'; export class AssignmentFilterListService extends BaseFilterListService { protected name = 'Assignment'; - public filterOptions: OsFilter[]; - - public constructor(store: StorageService, assignmentRepo: AssignmentRepositoryService) { - super(store, assignmentRepo); - this.filterOptions = [ - { - property: 'phase', - options: this.createPhaseOptions() - } - ]; + /** + * Getter for the current filter options + * + * @returns filter definitions to use + */ + public get filterOptions(): OsFilter[] { + return [this.phaseFilter]; } - private createPhaseOptions(): OsFilterOption[] { - const options = []; - assignmentPhase.forEach(phase => { - options.push({ - label: phase.name, - condition: phase.key, - isActive: false + /** + * Filter for assignment phases. Defined in the servers' constants + */ + public phaseFilter: OsFilter = { + property: 'phase', + 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(); + } + + /** + * Subscribes to the phases of an assignment that are defined in the server's + * constants + */ + private createPhaseOptions(): void { + this.constants.get('AssignmentPhases').subscribe(phases => { + this.phaseFilter.options = phases.map(ph => { + return { + label: ph.display_name, + condition: ph.value, + isActive: false + }; }); }); - options.push('-'); - options.push({ - label: 'Other', - condition: null - }); - return options; + this.updateFilterDefinitions(this.filterOptions); } } diff --git a/client/src/app/site/assignments/services/assignment-poll.service.spec.ts b/client/src/app/site/assignments/services/assignment-poll.service.spec.ts new file mode 100644 index 000000000..63c7046b1 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-poll.service.spec.ts @@ -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(); + }); +}); diff --git a/client/src/app/site/assignments/services/assignment-poll.service.ts b/client/src/app/site/assignments/services/assignment-poll.service.ts new file mode 100644 index 000000000..1e719e99f --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-poll.service.ts @@ -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('assignments_poll_default_majority_method') + .subscribe(method => (this.defaultMajorityMethod = method)); + config + .get('assignments_poll_vote_values') + .subscribe(method => (this.pollMethod = method)); + config + .get('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); + } +} diff --git a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.scss b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.scss index 85e080af4..f2b05fc13 100644 --- a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.scss +++ b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.scss @@ -25,11 +25,6 @@ mat-expansion-panel { margin: auto; } -.flex-spaced { - display: flex; - justify-content: space-between; -} - .full-width-form { display: flex; width: 100%; diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts index 438ddbe95..8b6cb309a 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll-dialog.component.ts @@ -3,7 +3,8 @@ import { MatDialogRef, MAT_DIALOG_DATA, MatSnackBar } from '@angular/material'; import { TranslateService } from '@ngx-translate/core'; import { MotionPoll } from 'app/shared/models/motions/motion-poll'; -import { MotionPollService, CalculablePollKey } from '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. @@ -15,7 +16,8 @@ import { MotionPollService, CalculablePollKey } from 'app/site/motions/services/ }) 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][]; diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.html b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.html index afd63e411..359bfe616 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.html +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.html @@ -20,7 +20,7 @@ diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts index 9ae5115b8..856bc5101 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-poll/motion-poll.component.ts @@ -86,7 +86,7 @@ export class MotionPollComponent implements OnInit { */ public constructor( public dialog: MatDialog, - private pollService: MotionPollService, + public pollService: MotionPollService, private motionRepo: MotionRepositoryService, private constants: ConstantsService, private translate: TranslateService, @@ -138,26 +138,6 @@ export class MotionPollComponent implements OnInit { return this.pollService.getIcon(key); } - /** - * Get the progressbar class for a decision key - * - * @param key - * - * @returns a css class designing a progress bar in a color, or an empty string - */ - public getProgressBarColor(key: CalculablePollKey): string { - switch (key) { - case 'yes': - return 'progress-green'; - case 'no': - return 'progress-red'; - case 'abstain': - return 'progress-yellow'; - default: - return ''; - } - } - /** * Transform special case numbers into their strings * @param key diff --git a/client/src/app/site/motions/modules/motion-workflow/components/workflow-detail/workflow-detail.component.html b/client/src/app/site/motions/modules/motion-workflow/components/workflow-detail/workflow-detail.component.html index 2bf76810f..ef9e883b3 100644 --- a/client/src/app/site/motions/modules/motion-workflow/components/workflow-detail/workflow-detail.component.html +++ b/client/src/app/site/motions/modules/motion-workflow/components/workflow-detail/workflow-detail.component.html @@ -34,7 +34,7 @@ -
+
diff --git a/client/src/app/site/motions/services/motion-import.service.ts b/client/src/app/site/motions/services/motion-import.service.ts index 80a91b5d7..7c997af55 100644 --- a/client/src/app/site/motions/services/motion-import.service.ts +++ b/client/src/app/site/motions/services/motion-import.service.ts @@ -228,7 +228,7 @@ export class MotionImportService extends BaseImportService { * @param categoryString * @returns categories mapped to existing categories */ - public getCategory(categoryString: string): CsvMapping { + public getCategory(categoryString: string): CsvMapping | null { if (!categoryString) { return null; } @@ -266,7 +266,7 @@ export class MotionImportService extends BaseImportService { * @param blockString * @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) { return null; } diff --git a/client/src/app/site/motions/services/motion-pdf.service.ts b/client/src/app/site/motions/services/motion-pdf.service.ts index 3dc5149fe..20e3cdd88 100644 --- a/client/src/app/site/motions/services/motion-pdf.service.ts +++ b/client/src/app/site/motions/services/motion-pdf.service.ts @@ -2,10 +2,11 @@ import { Injectable } from '@angular/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 { ConfigService } from 'app/core/ui-services/config.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 { StatuteParagraphRepositoryService } from 'app/core/repositories/motions/statute-paragraph-repository.service'; import { ViewMotion, LineNumberingMode, ChangeRecoMode } from '../models/view-motion'; diff --git a/client/src/app/site/motions/services/motion-poll.service.ts b/client/src/app/site/motions/services/motion-poll.service.ts index dabdcc45a..1f623e163 100644 --- a/client/src/app/site/motions/services/motion-poll.service.ts +++ b/client/src/app/site/motions/services/motion-poll.service.ts @@ -2,9 +2,7 @@ import { Injectable } from '@angular/core'; import { ConfigService } from 'app/core/ui-services/config.service'; import { MotionPoll } from 'app/shared/models/motions/motion-poll'; -import { PollService } from 'app/core/ui-services/poll.service'; - -export type CalculablePollKey = 'votesvalid' | 'votesinvalid' | 'votescast' | 'yes' | 'no' | 'abstain'; +import { PollService, PollMajorityMethod, CalculablePollKey } from 'app/core/ui-services/poll.service'; /** * Service class for motion polls. @@ -37,7 +35,7 @@ export class MotionPollService extends PollService { * @param key * @returns a percentage number with two digits, null if the value cannot be calculated (consider 0 !== null) */ - public calculatePercentage(poll: MotionPoll, key: CalculablePollKey): number { + public calculatePercentage(poll: MotionPoll, key: CalculablePollKey): number | null { const baseNumber = this.getBaseAmount(poll); if (!baseNumber) { return null; @@ -126,18 +124,11 @@ export class MotionPollService extends PollService { return undefined; } let result: number; - switch (method) { - case 'simple_majority': - result = baseNumber * 0.5; - break; - case 'two-thirds_majority': - result = (baseNumber / 3) * 2; - break; - case 'three-quarters_majority': - result = (baseNumber / 4) * 3; - break; - default: - return undefined; + const calc = PollMajorityMethod.find(m => m.value === method); + if (calc && calc.calc) { + result = calc.calc(baseNumber); + } else { + result = null; } // rounding up, or if a integer was hit, adding one. if (result % 1 !== 0) { diff --git a/client/src/app/site/users/components/group-list/group-list.component.html b/client/src/app/site/users/components/group-list/group-list.component.html index 7bab89daf..295a40307 100644 --- a/client/src/app/site/users/components/group-list/group-list.component.html +++ b/client/src/app/site/users/components/group-list/group-list.component.html @@ -44,7 +44,7 @@ -
+
diff --git a/client/src/styles.scss b/client/src/styles.scss index 72fab746e..9ce91972f 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -305,6 +305,12 @@ mat-paginator { 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*/ .spacer { flex: 1 1 auto; diff --git a/openslides/assignments/serializers.py b/openslides/assignments/serializers.py index 02cd0d945..7953217de 100644 --- a/openslides/assignments/serializers.py +++ b/openslides/assignments/serializers.py @@ -6,12 +6,12 @@ from openslides.utils.rest_api import ( DictField, IntegerField, ListField, - ListSerializer, ModelSerializer, SerializerMethodField, ValidationError, ) +from ..utils.autoupdate import inform_changed_data from ..utils.validate import validate_html from .models import ( Assignment, @@ -19,7 +19,6 @@ from .models import ( AssignmentPoll, AssignmentRelatedUser, AssignmentVote, - models, ) @@ -79,25 +78,6 @@ class AssignmentOptionSerializer(ModelSerializer): 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): """ Serializer for assignment.models.AssignmentPoll objects. @@ -197,31 +177,6 @@ class AssignmentAllPollSerializer(ModelSerializer): 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): """ 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_parent_id = validated_data.pop("agenda_parent_id", None) + tags = validated_data.pop("tags", []) assignment = Assignment(**validated_data) assignment.agenda_item_update_information["type"] = agenda_type assignment.agenda_item_update_information["parent_id"] = agenda_parent_id assignment.save() + assignment.tags.add(*tags) + inform_changed_data(assignment) return assignment diff --git a/openslides/utils/rest_api.py b/openslides/utils/rest_api.py index a70943b09..e2f1665ef 100644 --- a/openslides/utils/rest_api.py +++ b/openslides/utils/rest_api.py @@ -204,6 +204,13 @@ class ModelSerializerRegisterer(SerializerMetaclass): except AttributeError: pass 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 return serializer_class