Internal flag for motion blocks
- ServerSide - Adds the 'internal'-flag to the edit view of motion blocks
This commit is contained in:
parent
a3b5f083d5
commit
658b1a360d
@ -342,7 +342,7 @@ export class OperatorService implements OnAfterAppsLoaded {
|
||||
public isInGroupIds(...groupIds: number[]): boolean {
|
||||
if (!this.isInGroupIdsNonAdminCheck(...groupIds)) {
|
||||
// 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;
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ export class MotionBlock extends BaseModelWithAgendaItemAndListOfSpeakers<Motion
|
||||
|
||||
public id: number;
|
||||
public title: string;
|
||||
public internal: boolean;
|
||||
|
||||
public constructor(input?: any) {
|
||||
super(MotionBlock.COLLECTIONSTRING, input);
|
||||
|
@ -27,6 +27,10 @@ export class ViewMotionBlock extends BaseViewModelWithAgendaItemAndListOfSpeaker
|
||||
return this.motionBlock.title;
|
||||
}
|
||||
|
||||
public get internal(): boolean {
|
||||
return this.motionBlock.internal;
|
||||
}
|
||||
|
||||
public constructor(motionBlock: MotionBlock, agendaItem?: ViewItem, listOfSpeakers?: ViewListOfSpeakers) {
|
||||
super(MotionBlock.COLLECTIONSTRING, motionBlock, agendaItem, listOfSpeakers);
|
||||
}
|
||||
|
@ -1,20 +1,10 @@
|
||||
<os-head-bar [nav]="false">
|
||||
<!-- Title -->
|
||||
<div class="title-slot">
|
||||
<h2 *ngIf="block && !editBlock">{{ block.title }}</h2>
|
||||
|
||||
<form [formGroup]="blockEditForm" (ngSubmit)="saveBlock()" (keydown)="onKeyDown($event)" *ngIf="editBlock">
|
||||
<mat-form-field>
|
||||
<input
|
||||
type="text"
|
||||
matInput
|
||||
osAutofocus
|
||||
required
|
||||
formControlName="title"
|
||||
placeholder="{{ 'Title' | translate }}"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
<h2 *ngIf="block">
|
||||
<mat-icon *ngIf="block.internal">lock</mat-icon>
|
||||
{{ block.title }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
@ -23,10 +13,6 @@
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<mat-card>
|
||||
@ -111,7 +97,7 @@
|
||||
<div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']">
|
||||
<button mat-menu-item (click)="toggleEditMode()">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span translate>Edit title</span>
|
||||
<span translate>Edit</span>
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
@ -121,3 +107,31 @@
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
@ -51,3 +51,7 @@
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
import { Component, OnInit, ViewChild, TemplateRef } from '@angular/core';
|
||||
import { FormGroup, Validators, FormBuilder } from '@angular/forms';
|
||||
import { MatSnackBar, MatDialog } from '@angular/material';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
@ -32,17 +32,18 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
|
||||
*/
|
||||
public block: ViewMotionBlock;
|
||||
|
||||
/**
|
||||
* Determine the edit mode
|
||||
*/
|
||||
public editBlock = false;
|
||||
|
||||
/**
|
||||
* The form to edit blocks
|
||||
*/
|
||||
@ViewChild('blockEditForm')
|
||||
public blockEditForm: FormGroup;
|
||||
|
||||
/**
|
||||
* Reference to the template for edit-dialog
|
||||
*/
|
||||
@ViewChild('editDialog')
|
||||
private editDialog: TemplateRef<string>;
|
||||
|
||||
/**
|
||||
* Constructor for motion block details
|
||||
*
|
||||
@ -66,7 +67,9 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
|
||||
private router: Router,
|
||||
protected repo: MotionBlockRepositoryService,
|
||||
protected motionRepo: MotionRepositoryService,
|
||||
private promptService: PromptService
|
||||
private promptService: PromptService,
|
||||
private fb: FormBuilder,
|
||||
private dialog: MatDialog
|
||||
) {
|
||||
super(titleService, translate, matSnackBar, motionRepo, route, storage);
|
||||
}
|
||||
@ -80,10 +83,6 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
|
||||
this.initTable();
|
||||
const blockId = parseInt(this.route.snapshot.params.id, 10);
|
||||
|
||||
this.blockEditForm = new FormGroup({
|
||||
title: new FormControl('', Validators.required)
|
||||
});
|
||||
|
||||
// pseudo filter
|
||||
this.subscriptions.push(
|
||||
this.repo.getViewModelObservable(blockId).subscribe(newBlock => {
|
||||
@ -171,7 +170,7 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
|
||||
*/
|
||||
public onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
this.editBlock = false;
|
||||
this.dialog.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
@ -191,15 +190,33 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
|
||||
* Save event handler
|
||||
*/
|
||||
public saveBlock(): void {
|
||||
this.editBlock = false;
|
||||
this.repo.update(this.blockEditForm.value as MotionBlock, this.block);
|
||||
this.repo
|
||||
.update(this.blockEditForm.value as MotionBlock, this.block)
|
||||
.then(() => this.dialog.closeAll())
|
||||
.catch(this.raiseError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for the edit button
|
||||
*/
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -4,7 +4,7 @@
|
||||
</os-head-bar>
|
||||
|
||||
<!-- 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-content>
|
||||
<form [formGroup]="createBlockForm" (keydown)="onKeyDown($event)">
|
||||
@ -18,6 +18,11 @@
|
||||
</mat-form-field>
|
||||
</p>
|
||||
|
||||
<!-- Internal -->
|
||||
<p>
|
||||
<mat-checkbox formControlName="internal"><span translate>Internal</span></mat-checkbox>
|
||||
</p>
|
||||
|
||||
<!-- Parent item -->
|
||||
<p>
|
||||
<os-search-value-selector
|
||||
@ -44,7 +49,7 @@
|
||||
|
||||
<!-- Save and Cancel buttons -->
|
||||
<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>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
@ -65,7 +70,10 @@
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span translate>Title</span>
|
||||
</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>
|
||||
|
||||
<!-- amount column -->
|
||||
|
@ -36,9 +36,9 @@ export class MotionBlockListComponent
|
||||
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
|
||||
@ -99,7 +99,8 @@ export class MotionBlockListComponent
|
||||
this.createBlockForm = this.formBuilder.group({
|
||||
title: ['', 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
|
||||
*/
|
||||
public onPlusButton(): void {
|
||||
if (!this.blockToCreate) {
|
||||
if (!this.isCreatingNewBlock) {
|
||||
this.resetForm();
|
||||
this.blockToCreate = new MotionBlock();
|
||||
this.isCreatingNewBlock = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,15 +177,18 @@ export class MotionBlockListComponent
|
||||
*/
|
||||
public onSaveNewButton(): void {
|
||||
if (this.createBlockForm.valid) {
|
||||
const blockPatch = this.createBlockForm.value;
|
||||
if (!blockPatch.agenda_parent_id) {
|
||||
delete blockPatch.agenda_parent_id;
|
||||
const block = this.createBlockForm.value;
|
||||
if (!block.agenda_parent_id) {
|
||||
delete block.agenda_parent_id;
|
||||
}
|
||||
|
||||
this.blockToCreate.patchValues(blockPatch);
|
||||
this.repo.create(this.blockToCreate);
|
||||
try {
|
||||
this.repo.create(block);
|
||||
this.resetForm();
|
||||
this.blockToCreate = null;
|
||||
this.isCreatingNewBlock = false;
|
||||
} catch (e) {
|
||||
this.raiseError(e);
|
||||
}
|
||||
}
|
||||
// set a form control as "touched" to trigger potential error messages
|
||||
this.createBlockForm.get('title').markAsTouched();
|
||||
@ -209,6 +213,6 @@ export class MotionBlockListComponent
|
||||
* Cancels the current form action
|
||||
*/
|
||||
public onCancel(): void {
|
||||
this.blockToCreate = null;
|
||||
this.isCreatingNewBlock = false;
|
||||
}
|
||||
}
|
||||
|
@ -150,6 +150,20 @@ class MotionBlockAccessPermissions(BaseAccessPermissions):
|
||||
|
||||
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):
|
||||
"""
|
||||
|
16
openslides/motions/migrations/0027_motion_block_internal.py
Normal file
16
openslides/motions/migrations/0027_motion_block_internal.py
Normal 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),
|
||||
)
|
||||
]
|
@ -833,6 +833,12 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode
|
||||
|
||||
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:
|
||||
verbose_name = "Motion block"
|
||||
default_permissions = ()
|
||||
|
@ -82,6 +82,7 @@ class MotionBlockSerializer(ModelSerializer):
|
||||
"list_of_speakers_id",
|
||||
"agenda_type",
|
||||
"agenda_parent_id",
|
||||
"internal",
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
|
@ -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):
|
||||
"""
|
||||
Tests following the recommendations of motions in an motion block.
|
||||
|
Loading…
Reference in New Issue
Block a user