Projector for polls: Server, client structure and data modeling

This commit is contained in:
FinnStutzenstein 2019-11-20 17:09:03 +01:00
parent 84a39ccb62
commit e2585fb757
29 changed files with 328 additions and 316 deletions

View File

@ -33,14 +33,13 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
// TODO: update to new voting system?
return {
getBasicProjectorElement: options => ({
name: 'assignments/assignment-poll',
assignment_id: this.assignment_id,
poll_id: this.id,
getIdentifiers: () => ['name', 'assignment_id', 'poll_id']
name: AssignmentPoll.COLLECTIONSTRING,
id: this.id,
getIdentifiers: () => ['name', 'id']
}),
slideOptions: [],
projectionDefaultName: 'assignment-poll',
getDialogTitle: () => 'TODO'
projectionDefaultName: 'assignment_poll',
getDialogTitle: this.getTitle
};
}

View File

@ -62,7 +62,7 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
getIdentifiers: () => ['name', 'id']
}),
slideOptions: [],
projectionDefaultName: 'motion-poll',
projectionDefaultName: 'motion_poll',
getDialogTitle: this.getTitle
};
}

View File

@ -87,6 +87,8 @@
<div>{{ 'Required majority' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
</div>
<os-projector-button [object]="poll"></os-projector-button>
</ng-container>
</ng-template>

View File

@ -25,6 +25,11 @@ export const allSlidesDynamicConfiguration: (SlideDynamicConfiguration & Slide)[
scaleable: true,
scrollable: true
},
{
slide: 'motions/motion-poll',
scaleable: true,
scrollable: true
},
{
slide: 'users/user',
scaleable: true,
@ -83,7 +88,7 @@ export const allSlidesDynamicConfiguration: (SlideDynamicConfiguration & Slide)[
scrollable: true
},
{
slide: 'assignments/poll',
slide: 'assignments/assignment-poll',
scaleable: true,
scrollable: true
},

View File

@ -41,6 +41,14 @@ export const allSlides: SlideManifest[] = [
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true
},
{
slide: 'motions/motion-poll',
path: 'motions/motion-poll',
loadChildren: './slides/motions/motion-poll/motion-poll-slide.module#MotionPollSlideModule',
verboseName: 'Motion Poll',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true
},
{
slide: 'users/user',
path: 'users/user',
@ -126,12 +134,12 @@ export const allSlides: SlideManifest[] = [
canBeMappedToModel: true
},
{
slide: 'assignments/poll',
path: 'assignments/poll',
slide: 'assignments/assignment-poll',
path: 'assignments/assignment-poll',
loadChildren: () => import('./assignments/poll/poll-slide.module').then(m => m.PollSlideModule),
verboseName: 'Poll',
elementIdentifiers: ['name', 'assignment_id', 'poll_id'],
canBeMappedToModel: false
verboseName: 'Assignment Poll',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true
},
{
slide: 'mediafiles/mediafile',

View File

@ -0,0 +1,31 @@
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
import { AssignmentTitleInformation } from 'app/site/assignments/models/view-assignment';
export interface AssignmentPollSlideData {
assignment: AssignmentTitleInformation;
poll: {
title: string;
type: PollType;
pollmethod: AssignmentPollMethods;
votes_amount: number;
description: string;
state: PollState;
onehundered_percent_base: PercentBase;
majority_method: MajorityMethod;
options: {
user: string;
yes?: string;
no?: string;
abstain?: string;
}[];
// optional for published polls:
amount_global_no?: string;
amount_global_abstain: string;
votesvalid: string;
votesinvalid: string;
votescast: string;
};
}

View File

@ -0,0 +1,5 @@
<div *ngIf="data">
<pre>{{ verboseData }}</pre>
</div>

View File

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

View File

@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { BaseSlideComponent } from 'app/slides/base-slide-component';
import { AssignmentPollSlideData } from './assignment-poll-slide-data';
@Component({
selector: 'os-assignment-poll-slide',
templateUrl: './assignment-poll-slide.component.html',
styleUrls: ['./assignment-poll-slide.component.scss']
})
export class AssignmentPollSlideComponent extends BaseSlideComponent<AssignmentPollSlideData> {
public get verboseData(): string {
return JSON.stringify(this.data, null, 2);
}
}

View File

@ -0,0 +1,13 @@
import { AssignmentPollSlideModule } from './assignment-poll-slide.module';
describe('AssignmentPollSlideModule', () => {
let assignmentPollSlideModule: AssignmentPollSlideModule;
beforeEach(() => {
assignmentPollSlideModule = new AssignmentPollSlideModule();
});
it('should create an instance', () => {
expect(assignmentPollSlideModule).toBeTruthy();
});
});

View File

@ -0,0 +1,7 @@
import { NgModule } from '@angular/core';
import { makeSlideModule } from 'app/slides/base-slide-module';
import { AssignmentPollSlideComponent } from './assignment-poll-slide.component';
@NgModule(makeSlideModule(AssignmentPollSlideComponent))
export class AssignmentPollSlideModule {}

View File

@ -1,27 +0,0 @@
import { PollVoteValue } from 'app/site/polls/services/poll.service';
export interface PollSlideOption {
user: string;
is_elected: boolean;
votes: {
weight: string;
value: PollVoteValue;
}[];
}
export interface PollSlideData {
title: string;
assignments_poll_100_percent_base: any /*AssignmentPercentBase*/;
poll: {
published: boolean;
description?: string;
has_votes?: boolean;
pollmethod?: any /*AssignmentPollmethods*/;
votesno?: string;
votesabstain?: string;
votesvalid?: string;
votesinvalid?: string;
votescast?: string;
options?: PollSlideOption[];
};
}

View File

@ -1,43 +0,0 @@
<div *ngIf="data">
<div class="slidetitle">
<h1>{{ data.data.title }}</h1>
<h2 translate>Election result</h2>
</div>
<div class="spacer-top-10"></div>
<div *ngIf="!data.data.poll.published"><span translate>Waiting for results</span><span>...</span></div>
<div *ngIf="data.data.poll.published">
<div *ngIf="data.data.poll.has_votes" class="result-table">
<div class="row">
<div class="option-name heading">
<h3 translate>Candidates</h3>
</div>
<div class="option-percents heading">
<h3 translate>Votes</h3>
</div>
</div>
<div *ngFor="let option of data.data.poll.options">
<div class="row">
<div class="option-name">
<span class="bold">{{ option.user }}</span>
<mat-icon *ngIf="option.is_elected">star</mat-icon>
</div>
<div class="option-percents">
<div *ngFor="let vote of option.votes" class="bold">
<span *ngIf="vote.value !== 'Votes'">{{ getLabel(vote.value) }}:</span>
<span> {{ getVotePercent(vote.value, option) }}</span>
</div>
</div>
</div>
</div>
<div *ngFor="let value of pollValues" class="row">
<div class="option-name grey">
<span>{{ getLabel(value) }}</span>
</div>
<div class="option-percents grey">
{{ getPollPercent(value) }}
</div>
</div>
</div>
</div>
</div>

View File

@ -1,31 +0,0 @@
.row {
border-top: 1px solid #ddd;
display: table;
width: 100%;
.heading {
text-transform: uppercase;
h3 {
font-weight: normal;
}
}
.option-name {
display: table-cell;
padding: 5px;
vertical-align: middle;
width: 70%;
}
.option-percents {
display: table-cell;
padding: 5px;
width: 30%;
}
.grey {
background-color: #ddd !important;
color: rgba(0, 0, 0, 0.87) !important;
}
}

View File

@ -1,88 +0,0 @@
import { Component, Input } from '@angular/core';
import { SlideData } from 'app/core/core-services/projector-data.service';
import { CalculablePollKey, PollVoteValue } from 'app/site/polls/services/poll.service';
import { BaseSlideComponent } from 'app/slides/base-slide-component';
import { PollSlideData, PollSlideOption } from './poll-slide-data';
@Component({
selector: 'os-poll-slide',
templateUrl: './poll-slide.component.html',
styleUrls: ['./poll-slide.component.scss']
})
export class PollSlideComponent extends BaseSlideComponent<PollSlideData> {
private _data: SlideData<PollSlideData>;
public get pollValues(): any {
// SummaryPollKey[] {
if (!this.data) {
return [];
}
const values: any /*SummaryPollKey[]*/ = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast'];
return values.filter(val => this.data.data.poll[val] !== null);
}
@Input()
public set data(data: SlideData<PollSlideData>) {
this._data = data;
/*this.calculationData = {
pollMethod: data.data.poll.pollmethod,
votesno: parseFloat(data.data.poll.votesno),
votesabstain: parseFloat(data.data.poll.votesabstain),
votescast: parseFloat(data.data.poll.votescast),
votesvalid: parseFloat(data.data.poll.votesvalid),
votesinvalid: parseFloat(data.data.poll.votesinvalid),
pollOptions: data.data.poll.options.map(opt => {
return {
votes: opt.votes.map(vote => {
return {
weight: parseFloat(vote.weight),
value: vote.value
};
})
};
}),
percentBase: data.data.assignments_poll_100_percent_base
};*/
}
public get data(): SlideData<PollSlideData> {
return this._data;
}
/**
* get a vote's numerical or special label, including percent values if these are to
* be displayed
*
* @param key
* @param option
*/
public getVotePercent(key: PollVoteValue, option: PollSlideOption): string {
/*const calcOption = {
votes: option.votes.map(vote => {
return { weight: parseFloat(vote.weight), value: vote.value };
})
};
const percent = this.pollService.getPercent(this.calculationData, calcOption, key);
const number = this.translate.instant(
this.pollService.getSpecialLabel(parseFloat(option.votes.find(v => v.value === key).weight))
);
return percent === null ? number : `${number} (${percent}%)`;*/
throw new Error('TODO');
}
public getPollPercent(key: CalculablePollKey): string {
/*const percent = this.pollService.getValuePercent(this.calculationData, key);
const number = this.translate.instant(this.pollService.getSpecialLabel(this.calculationData[key]));
return percent === null ? number : `${number} (${percent}%)`;*/
throw new Error('TODO');
}
/**
* @returns a translated label for a key
*/
public getLabel(key: CalculablePollKey): string {
// return this.translate.instant(this.pollService.getLabel(key));
throw new Error('TODO');
}
}

View File

@ -1,13 +0,0 @@
import { PollSlideModule } from './poll-slide.module';
describe('PollSlideModule', () => {
let pollSlideModule: PollSlideModule;
beforeEach(() => {
pollSlideModule = new PollSlideModule();
});
it('should create an instance', () => {
expect(pollSlideModule).toBeTruthy();
});
});

View File

@ -1,7 +0,0 @@
import { NgModule } from '@angular/core';
import { makeSlideModule } from 'app/slides/base-slide-module';
import { PollSlideComponent } from './poll-slide.component';
@NgModule(makeSlideModule(PollSlideComponent))
export class PollSlideModule {}

View File

@ -0,0 +1,26 @@
import { MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
import { MotionTitleInformation } from 'app/site/motions/models/view-motion';
export interface MotionPollSlideData {
motion: MotionTitleInformation;
poll: {
title: string;
type: PollType;
pollmethod: MotionPollMethods;
state: PollState;
onehundered_percent_base: PercentBase;
majority_method: MajorityMethod;
options: {
yes?: string;
no?: string;
abstain?: string;
}[];
// optional for published polls:
votesvalid: string;
votesinvalid: string;
votescast: string;
};
}

View File

@ -0,0 +1,5 @@
<div *ngIf="data">
<pre>{{ verboseData }}</pre>
</div>

View File

@ -1,21 +1,21 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from '../../../../e2e-imports.module';
import { PollSlideComponent } from './poll-slide.component';
import { MotionPollSlideComponent } from './motion-poll-slide.component';
describe('PollSlideComponent', () => {
let component: PollSlideComponent;
let fixture: ComponentFixture<PollSlideComponent>;
describe('MotionPollSlideComponent', () => {
let component: MotionPollSlideComponent;
let fixture: ComponentFixture<MotionPollSlideComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [PollSlideComponent]
declarations: [MotionPollSlideComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PollSlideComponent);
fixture = TestBed.createComponent(MotionPollSlideComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { BaseSlideComponent } from 'app/slides/base-slide-component';
import { MotionPollSlideData } from './motion-poll-slide-data';
@Component({
selector: 'os-motion-poll-slide',
templateUrl: './motion-poll-slide.component.html',
styleUrls: ['./motion-poll-slide.component.scss']
})
export class MotionPollSlideComponent extends BaseSlideComponent<MotionPollSlideData> {
public get verboseData(): string {
return JSON.stringify(this.data, null, 2);
}
}

View File

@ -0,0 +1,13 @@
import { MotionPollSlideModule } from './motion-poll-slide.module';
describe('MotionPollSlideModule', () => {
let motionPollSlideModule: MotionPollSlideModule;
beforeEach(() => {
motionPollSlideModule = new MotionPollSlideModule();
});
it('should create an instance', () => {
expect(motionPollSlideModule).toBeTruthy();
});
});

View File

@ -0,0 +1,7 @@
import { NgModule } from '@angular/core';
import { makeSlideModule } from 'app/slides/base-slide-module';
import { MotionPollSlideComponent } from './motion-poll-slide.component';
@NgModule(makeSlideModule(MotionPollSlideComponent))
export class MotionPollSlideModule {}

View File

@ -1,12 +1,8 @@
from typing import Any, Dict, List
from ..users.projector import get_user_name
from ..utils.projector import (
AllData,
ProjectorElementException,
get_config,
register_projector_slide,
)
from ..utils.projector import AllData, get_model, register_projector_slide
from .models import AssignmentPoll
# Important: All functions have to be prune. This means, that thay can only
@ -14,24 +10,13 @@ from ..utils.projector import (
# side effects.
def get_assignment(all_data: AllData, id: Any) -> Dict[str, Any]:
if id is None:
raise ProjectorElementException("id is required for assignment slide")
try:
assignment = all_data["assignments/assignment"][id]
except KeyError:
raise ProjectorElementException(f"assignment with id {id} does not exist")
return assignment
async def assignment_slide(
all_data: AllData, element: Dict[str, Any], projector_id: int
) -> Dict[str, Any]:
"""
Assignment slide.
"""
assignment = get_assignment(all_data, element.get("id"))
assignment = get_model(all_data, "assignments/assignment", element.get("id"))
assignment_related_users: List[Dict[str, Any]] = [
{
@ -52,57 +37,52 @@ async def assignment_slide(
}
async def poll_slide(
async def assignment_poll_slide(
all_data: AllData, element: Dict[str, Any], projector_id: int
) -> Dict[str, Any]:
"""
Poll slide.
"""
assignment = get_assignment(all_data, element.get("assignment_id"))
poll = get_model(all_data, "assignments/assignment-poll", element.get("id"))
assignment = get_model(all_data, "assignments/assignment", poll["assignment_id"])
# get poll
poll_id = element.get("poll_id")
if poll_id is None:
raise ProjectorElementException("id is required for poll slide")
poll_data = {
key: poll[key]
for key in (
"title",
"type",
"pollmethod",
"votes_amount",
"description",
"state",
"onehundred_percent_base",
"majority_method",
)
}
poll = None
for p in assignment["polls"]:
if p["id"] == poll_id:
poll = p
break
if poll is None:
raise ProjectorElementException(f"poll with id {poll_id} does not exist")
# Add options:
poll_data["options"] = []
for option in sorted(poll["options"], key=lambda option: option["weight"]):
option_data = {"user": await get_user_name(all_data, option["user_id"])}
if poll["state"] == AssignmentPoll.STATE_PUBLISHED:
option_data["yes"] = option["yes"]
option_data["no"] = option["no"]
option_data["abstain"] = option["abstain"]
poll_data["options"].append(option_data)
poll_data = {"published": poll["published"]}
if poll["published"]:
poll_data["description"] = poll["description"]
poll_data["has_votes"] = poll["has_votes"]
poll_data["pollmethod"] = poll["pollmethod"]
poll_data["votesno"] = poll["votesno"]
poll_data["votesabstain"] = poll["votesabstain"]
if poll["state"] == AssignmentPoll.STATE_PUBLISHED:
poll_data["amount_global_no"] = poll["amount_global_no"]
poll_data["amount_global_abstain"] = poll["amount_global_abstain"]
poll_data["votesvalid"] = poll["votesvalid"]
poll_data["votesinvalid"] = poll["votesinvalid"]
poll_data["votescast"] = poll["votescast"]
poll_data["options"] = [
{
"user": await get_user_name(all_data, option["candidate_id"]),
"is_elected": option["is_elected"],
"votes": option["votes"],
}
for option in sorted(poll["options"], key=lambda option: option["weight"])
]
return {
"title": assignment["title"],
"assignments_poll_100_percent_base": await get_config(
all_data, "assignments_poll_100_percent_base"
),
"assignment": {"title": assignment["title"]},
"poll": poll_data,
}
def register_projector_slides() -> None:
register_projector_slide("assignments/assignment", assignment_slide)
register_projector_slide("assignments/poll", poll_slide)
register_projector_slide("assignments/assignment-poll", assignment_poll_slide)

View File

@ -0,0 +1,40 @@
# Generated by Fin Stutzenstein on 2019-20-11 16:30
from django.db import migrations
def add_poll_projection_defaults(apps, schema_editor):
"""
Adds projectiondefaults for messages and countdowns.
"""
Projector = apps.get_model("core", "Projector")
ProjectionDefault = apps.get_model("core", "ProjectionDefault")
default_projector = Projector.objects.order_by("pk").first()
projectiondefaults = []
projectiondefaults.append(
ProjectionDefault(
name="assignment_poll",
display_name="Assignment poll",
projector=default_projector,
)
)
projectiondefaults.append(
ProjectionDefault(
name="motion_poll", display_name="Motion Poll", projector=default_projector
)
)
# Create all new projectiondefaults
ProjectionDefault.objects.bulk_create(projectiondefaults)
class Migration(migrations.Migration):
dependencies = [
("core", "0029_remove_history_restricted"),
]
operations = [
migrations.RunPython(add_poll_projection_defaults),
]

View File

@ -6,8 +6,10 @@ from ..utils.projector import (
AllData,
ProjectorElementException,
get_config,
get_model,
register_projector_slide,
)
from .models import MotionPoll
motion_placeholder_regex = re.compile(r"\[motion:(\d+)\]")
@ -91,11 +93,7 @@ async def get_amendments_for_motion(motion, all_data):
async def get_amendment_base_motion(amendment, all_data):
try:
motion = all_data["motions/motion"][amendment["parent_id"]]
except KeyError:
motion_id = amendment["parent_id"]
raise ProjectorElementException(f"motion with id {motion_id} does not exist")
motion = get_model(all_data, "motions/motion", amendment.get("parent_id"))
return {
"identifier": motion["identifier"],
@ -105,14 +103,9 @@ async def get_amendment_base_motion(amendment, all_data):
async def get_amendment_base_statute(amendment, all_data):
try:
statute = all_data["motions/statute-paragraph"][
amendment["statute_paragraph_id"]
]
except KeyError:
statute_id = amendment["statute_paragraph_id"]
raise ProjectorElementException(f"statute with id {statute_id} does not exist")
statute = get_model(
all_data, "motions/statute-paragraph", amendment.get("statute_paragraph_id")
)
return {"title": statute["title"], "text": statute["text"]}
@ -167,15 +160,7 @@ async def motion_slide(
mode = element.get(
"mode", await get_config(all_data, "motions_recommendation_text_mode")
)
motion_id = element.get("id")
if motion_id is None:
raise ProjectorElementException("id is required for motion slide")
try:
motion = all_data["motions/motion"][motion_id]
except KeyError:
raise ProjectorElementException(f"motion with id {motion_id} does not exist")
motion = get_model(all_data, "motions/motion", element.get("id"))
# Add submitters
submitters = [
@ -270,7 +255,7 @@ async def motion_slide(
# Add recommendation-referencing motions
return_value[
"recommendation_referencing_motions"
] = await get_recommendation_referencing_motions(all_data, motion_id)
] = await get_recommendation_referencing_motions(all_data, motion["id"])
return return_value
@ -317,17 +302,7 @@ async def motion_block_slide(
"""
Motion block slide.
"""
motion_block_id = element.get("id")
if motion_block_id is None:
raise ProjectorElementException("id is required for motion block slide")
try:
motion_block = all_data["motions/motion-block"][motion_block_id]
except KeyError:
raise ProjectorElementException(
f"motion block with id {motion_block_id} does not exist"
)
motion_block = get_model(all_data, "motions/motion-block", element.get("id"))
# All motions in this motion block
motions = []
@ -337,7 +312,7 @@ async def motion_block_slide(
# Search motions.
for motion in all_data["motions/motion"].values():
if motion["motion_block_id"] == motion_block_id:
if motion["motion_block_id"] == motion_block["id"]:
motion_object = {
"title": motion["title"],
"identifier": motion["identifier"],
@ -366,6 +341,40 @@ async def motion_block_slide(
}
async def motion_poll_slide(
all_data: AllData, element: Dict[str, Any], projector_id: int
) -> Dict[str, Any]:
"""
Poll slide.
"""
poll = get_model(all_data, "motions/motion-poll", element.get("id"))
motion = get_model(all_data, "motions/motion", poll["motion_id"])
poll_data = {
key: poll[key]
for key in (
"title",
"type",
"pollmethod",
"state",
"onehundred_percent_base",
"majority_method",
)
}
if poll["state"] == MotionPoll.STATE_PUBLISHED:
poll_data["options"] = poll["options"]
poll_data["votesvalid"] = poll["votesvalid"]
poll_data["votesinvalid"] = poll["votesinvalid"]
poll_data["votescast"] = poll["votescast"]
return {
"motion": {"title": motion["title"], "identifier": motion["identifier"]},
"poll": poll_data,
}
def register_projector_slides() -> None:
register_projector_slide("motions/motion", motion_slide)
register_projector_slide("motions/motion-block", motion_block_slide)
register_projector_slide("motions/motion-poll", motion_poll_slide)

View File

@ -100,3 +100,18 @@ async def get_config(all_data: AllData, key: str) -> Any:
config_id = (await config.async_get_key_to_id())[key]
return all_data[config.get_collection_string()][config_id]["value"]
def get_model(all_data: AllData, collection: str, id: Any) -> Dict[str, Any]:
"""
Tries to get the model identified by the collection and id.
If the id is invalid or the model not found, ProjectorElementExceptions will be raised.
"""
if id is None:
raise ProjectorElementException(f"id is required for {collection} slide")
try:
model = all_data[collection][id]
except KeyError:
raise ProjectorElementException(f"{collection} with id {id} does not exist")
return model