Internal flag for motion blocks

- ServerSide
- Adds the 'internal'-flag to the edit view of motion blocks
This commit is contained in:
FinnStutzenstein 2019-05-21 13:52:10 +02:00 committed by GabrielMeyer
parent a3b5f083d5
commit 658b1a360d
13 changed files with 202 additions and 53 deletions

View File

@ -342,7 +342,7 @@ export class OperatorService implements OnAfterAppsLoaded {
public isInGroupIds(...groupIds: number[]): boolean { public isInGroupIds(...groupIds: number[]): boolean {
if (!this.isInGroupIdsNonAdminCheck(...groupIds)) { if (!this.isInGroupIdsNonAdminCheck(...groupIds)) {
// An admin has all perms and is technically in every group. // An admin has all perms and is technically in every group.
return this.user.groups_id.includes(2); return this.user && this.user.groups_id.includes(2);
} }
return true; return true;
} }

View File

@ -9,6 +9,7 @@ export class MotionBlock extends BaseModelWithAgendaItemAndListOfSpeakers<Motion
public id: number; public id: number;
public title: string; public title: string;
public internal: boolean;
public constructor(input?: any) { public constructor(input?: any) {
super(MotionBlock.COLLECTIONSTRING, input); super(MotionBlock.COLLECTIONSTRING, input);

View File

@ -27,6 +27,10 @@ export class ViewMotionBlock extends BaseViewModelWithAgendaItemAndListOfSpeaker
return this.motionBlock.title; return this.motionBlock.title;
} }
public get internal(): boolean {
return this.motionBlock.internal;
}
public constructor(motionBlock: MotionBlock, agendaItem?: ViewItem, listOfSpeakers?: ViewListOfSpeakers) { public constructor(motionBlock: MotionBlock, agendaItem?: ViewItem, listOfSpeakers?: ViewListOfSpeakers) {
super(MotionBlock.COLLECTIONSTRING, motionBlock, agendaItem, listOfSpeakers); super(MotionBlock.COLLECTIONSTRING, motionBlock, agendaItem, listOfSpeakers);
} }

View File

@ -1,20 +1,10 @@
<os-head-bar [nav]="false"> <os-head-bar [nav]="false">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 *ngIf="block && !editBlock">{{ block.title }}</h2> <h2 *ngIf="block">
<mat-icon *ngIf="block.internal">lock</mat-icon>
<form [formGroup]="blockEditForm" (ngSubmit)="saveBlock()" (keydown)="onKeyDown($event)" *ngIf="editBlock"> {{ block.title }}
<mat-form-field> </h2>
<input
type="text"
matInput
osAutofocus
required
formControlName="title"
placeholder="{{ 'Title' | translate }}"
/>
</mat-form-field>
</form>
</div> </div>
<!-- Menu --> <!-- Menu -->
@ -23,10 +13,6 @@
<mat-icon>more_vert</mat-icon> <mat-icon>more_vert</mat-icon>
</button> </button>
</div> </div>
<!-- Save button -->
<div *ngIf="editBlock" class="extra-controls-slot on-transition-fade">
<button mat-button (click)="saveBlock()"><strong translate class="upper">Save</strong></button>
</div>
</os-head-bar> </os-head-bar>
<mat-card> <mat-card>
@ -111,7 +97,7 @@
<div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']"> <div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']">
<button mat-menu-item (click)="toggleEditMode()"> <button mat-menu-item (click)="toggleEditMode()">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
<span translate>Edit title</span> <span translate>Edit</span>
</button> </button>
<mat-divider></mat-divider> <mat-divider></mat-divider>
@ -121,3 +107,31 @@
</button> </button>
</div> </div>
</mat-menu> </mat-menu>
<ng-template #editDialog>
<h1 mat-dialog-title>
<span>{{ 'Edit details for' | translate }} {{ block.title }}</span>
</h1>
<div class="os-form-card-mobile" mat-dialog-content>
<form class="edit-form" [formGroup]="blockEditForm" (ngSubmit)="saveBlock()" (keydown)="onKeyDown($event)">
<mat-form-field>
<input matInput osAutofocus placeholder="{{ 'Title' | translate }}" formControlName="title" required/>
</mat-form-field>
<mat-checkbox formControlName="internal">Internal</mat-checkbox>
</form>
</div>
<div mat-dialog-actions>
<button
type="submit"
mat-button
[disabled]="!blockEditForm.valid"
color="primary"
(click)="saveBlock()"
>
<span translate>Save</span>
</button>
<button type="button" mat-button [mat-dialog-close]="null">
<span translate>Cancel</span>
</button>
</div>
</ng-template>

View File

@ -51,3 +51,7 @@
justify-content: flex-end !important; justify-content: flex-end !important;
} }
} }
.edit-form {
overflow: hidden;
}

View File

@ -1,7 +1,7 @@
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Component, OnInit, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild, TemplateRef } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms'; import { FormGroup, Validators, FormBuilder } from '@angular/forms';
import { MatSnackBar } from '@angular/material'; import { MatSnackBar, MatDialog } from '@angular/material';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
@ -32,17 +32,18 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
*/ */
public block: ViewMotionBlock; public block: ViewMotionBlock;
/**
* Determine the edit mode
*/
public editBlock = false;
/** /**
* The form to edit blocks * The form to edit blocks
*/ */
@ViewChild('blockEditForm') @ViewChild('blockEditForm')
public blockEditForm: FormGroup; public blockEditForm: FormGroup;
/**
* Reference to the template for edit-dialog
*/
@ViewChild('editDialog')
private editDialog: TemplateRef<string>;
/** /**
* Constructor for motion block details * Constructor for motion block details
* *
@ -66,7 +67,9 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
private router: Router, private router: Router,
protected repo: MotionBlockRepositoryService, protected repo: MotionBlockRepositoryService,
protected motionRepo: MotionRepositoryService, protected motionRepo: MotionRepositoryService,
private promptService: PromptService private promptService: PromptService,
private fb: FormBuilder,
private dialog: MatDialog
) { ) {
super(titleService, translate, matSnackBar, motionRepo, route, storage); super(titleService, translate, matSnackBar, motionRepo, route, storage);
} }
@ -80,10 +83,6 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
this.initTable(); this.initTable();
const blockId = parseInt(this.route.snapshot.params.id, 10); const blockId = parseInt(this.route.snapshot.params.id, 10);
this.blockEditForm = new FormGroup({
title: new FormControl('', Validators.required)
});
// pseudo filter // pseudo filter
this.subscriptions.push( this.subscriptions.push(
this.repo.getViewModelObservable(blockId).subscribe(newBlock => { this.repo.getViewModelObservable(blockId).subscribe(newBlock => {
@ -171,7 +170,7 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
*/ */
public onKeyDown(event: KeyboardEvent): void { public onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') { if (event.key === 'Escape') {
this.editBlock = false; this.dialog.closeAll();
} }
} }
@ -191,15 +190,33 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
* Save event handler * Save event handler
*/ */
public saveBlock(): void { public saveBlock(): void {
this.editBlock = false; this.repo
this.repo.update(this.blockEditForm.value as MotionBlock, this.block); .update(this.blockEditForm.value as MotionBlock, this.block)
.then(() => this.dialog.closeAll())
.catch(this.raiseError);
} }
/** /**
* Click handler for the edit button * Click handler for the edit button
*/ */
public toggleEditMode(): void { public toggleEditMode(): void {
this.editBlock = !this.editBlock; this.blockEditForm = this.fb.group({
title: [this.block.title, Validators.required],
internal: [this.block.internal]
});
const dialogRef = this.dialog.open(this.editDialog, {
width: '400px',
maxWidth: '90vw',
maxHeight: '90vh',
disableClose: true
});
dialogRef.keydownEvents().subscribe((event: KeyboardEvent) => {
if (event.key === 'Enter' && event.shiftKey) {
this.saveBlock();
}
});
} }
/** /**

View File

@ -4,7 +4,7 @@
</os-head-bar> </os-head-bar>
<!-- Creating a new motion block --> <!-- Creating a new motion block -->
<mat-card class="os-card" *ngIf="blockToCreate"> <mat-card class="os-card" *ngIf="isCreatingNewBlock">
<mat-card-title translate>New motion block</mat-card-title> <mat-card-title translate>New motion block</mat-card-title>
<mat-card-content> <mat-card-content>
<form [formGroup]="createBlockForm" (keydown)="onKeyDown($event)"> <form [formGroup]="createBlockForm" (keydown)="onKeyDown($event)">
@ -18,6 +18,11 @@
</mat-form-field> </mat-form-field>
</p> </p>
<!-- Internal -->
<p>
<mat-checkbox formControlName="internal"><span translate>Internal</span></mat-checkbox>
</p>
<!-- Parent item --> <!-- Parent item -->
<p> <p>
<os-search-value-selector <os-search-value-selector
@ -44,7 +49,7 @@
<!-- Save and Cancel buttons --> <!-- Save and Cancel buttons -->
<mat-card-actions> <mat-card-actions>
<button mat-button (click)="onSaveNewButton()"><span translate>Save</span></button> <button mat-button [disabled]="!createBlockForm.valid" (click)="onSaveNewButton()"><span translate>Save</span></button>
<button mat-button (click)="onCancel()"><span translate>Cancel</span></button> <button mat-button (click)="onCancel()"><span translate>Cancel</span></button>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
@ -65,7 +70,10 @@
<mat-header-cell *matHeaderCellDef> <mat-header-cell *matHeaderCellDef>
<span translate>Title</span> <span translate>Title</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let block"> {{ block.title }} </mat-cell> <mat-cell *matCellDef="let block">
<mat-icon matTooltip="Internal" *ngIf="block.internal">lock</mat-icon>
{{ block.title }}
</mat-cell>
</ng-container> </ng-container>
<!-- amount column --> <!-- amount column -->

View File

@ -36,9 +36,9 @@ export class MotionBlockListComponent
public createBlockForm: FormGroup; public createBlockForm: FormGroup;
/** /**
* The new motion block to create * Flag, if the creation panel is open
*/ */
public blockToCreate: MotionBlock | null; public isCreatingNewBlock = false;
/** /**
* Holds the agenda items to select the parent item * Holds the agenda items to select the parent item
@ -99,7 +99,8 @@ export class MotionBlockListComponent
this.createBlockForm = this.formBuilder.group({ this.createBlockForm = this.formBuilder.group({
title: ['', Validators.required], title: ['', Validators.required],
agenda_type: ['', Validators.required], agenda_type: ['', Validators.required],
agenda_parent_id: [] agenda_parent_id: [],
internal: [false]
}); });
} }
@ -164,9 +165,9 @@ export class MotionBlockListComponent
* Click handler for the plus button * Click handler for the plus button
*/ */
public onPlusButton(): void { public onPlusButton(): void {
if (!this.blockToCreate) { if (!this.isCreatingNewBlock) {
this.resetForm(); this.resetForm();
this.blockToCreate = new MotionBlock(); this.isCreatingNewBlock = true;
} }
} }
@ -176,15 +177,18 @@ export class MotionBlockListComponent
*/ */
public onSaveNewButton(): void { public onSaveNewButton(): void {
if (this.createBlockForm.valid) { if (this.createBlockForm.valid) {
const blockPatch = this.createBlockForm.value; const block = this.createBlockForm.value;
if (!blockPatch.agenda_parent_id) { if (!block.agenda_parent_id) {
delete blockPatch.agenda_parent_id; delete block.agenda_parent_id;
} }
this.blockToCreate.patchValues(blockPatch); try {
this.repo.create(this.blockToCreate); this.repo.create(block);
this.resetForm(); this.resetForm();
this.blockToCreate = null; this.isCreatingNewBlock = false;
} catch (e) {
this.raiseError(e);
}
} }
// set a form control as "touched" to trigger potential error messages // set a form control as "touched" to trigger potential error messages
this.createBlockForm.get('title').markAsTouched(); this.createBlockForm.get('title').markAsTouched();
@ -209,6 +213,6 @@ export class MotionBlockListComponent
* Cancels the current form action * Cancels the current form action
*/ */
public onCancel(): void { public onCancel(): void {
this.blockToCreate = null; this.isCreatingNewBlock = false;
} }
} }

View File

@ -150,6 +150,20 @@ class MotionBlockAccessPermissions(BaseAccessPermissions):
base_permission = "motions.can_see" base_permission = "motions.can_see"
async def get_restricted_data(
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Users without `motions.can_manage` cannot see internal blocks.
"""
data: List[Dict[str, Any]] = []
if await async_has_perm(user_id, "motions.can_manage"):
data = full_data
else:
data = [full for full in full_data if not full["internal"]]
return data
class WorkflowAccessPermissions(BaseAccessPermissions): class WorkflowAccessPermissions(BaseAccessPermissions):
""" """

View File

@ -0,0 +1,16 @@
# Generated by Django 2.2 on 2019-05-21 06:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("motions", "0026_rename_restriction")]
operations = [
migrations.AddField(
model_name="motionblock",
name="internal",
field=models.BooleanField(default=False),
)
]

View File

@ -833,6 +833,12 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
internal = models.BooleanField(default=False)
"""
If a motion block is internal, only users with `motions.can_manage` can see and
manage these blocks.
"""
class Meta: class Meta:
verbose_name = "Motion block" verbose_name = "Motion block"
default_permissions = () default_permissions = ()

View File

@ -82,6 +82,7 @@ class MotionBlockSerializer(ModelSerializer):
"list_of_speakers_id", "list_of_speakers_id",
"agenda_type", "agenda_type",
"agenda_parent_id", "agenda_parent_id",
"internal",
) )
def create(self, validated_data): def create(self, validated_data):

View File

@ -1867,6 +1867,66 @@ class NumberMotionsInCategory(TestCase):
) )
class TestMotionBlock(TestCase):
def setUp(self):
self.client = APIClient()
self.client.login(username="admin", password="admin")
def make_admin_delegate(self):
admin = get_user_model().objects.get(username="admin")
admin.groups.add(GROUP_DELEGATE_PK)
admin.groups.remove(GROUP_ADMIN_PK)
inform_changed_data(admin)
def test_creation(self):
response = self.client.post(
reverse("motionblock-list"), {"title": "test_title_r23098OMFwoqof3if3kO"}
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertTrue(MotionBlock.objects.exists())
self.assertEqual(
MotionBlock.objects.get().title, "test_title_r23098OMFwoqof3if3kO"
)
def test_creation_no_data(self):
response = self.client.post(reverse("motionblock-list"), {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(MotionBlock.objects.exists())
def test_creation_not_authenticated(self):
self.make_admin_delegate()
response = self.client.post(
reverse("motionblock-list"), {"title": "test_title_2PFjpf39ap,38fuMPO§8"}
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(MotionBlock.objects.exists())
def test_retrieve_simple(self):
motion_block = MotionBlock(title="test_title")
motion_block.save()
response = self.client.get(
reverse("motionblock-detail", args=[motion_block.pk])
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(response.data.keys()),
sorted(
("agenda_item_id", "id", "internal", "list_of_speakers_id", "title")
),
)
def test_retrieve_internal_non_admin(self):
self.make_admin_delegate()
motion_block = MotionBlock(title="test_title", internal=True)
motion_block.save()
response = self.client.get(
reverse("motionblock-detail", args=[motion_block.pk])
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
class FollowRecommendationsForMotionBlock(TestCase): class FollowRecommendationsForMotionBlock(TestCase):
""" """
Tests following the recommendations of motions in an motion block. Tests following the recommendations of motions in an motion block.