diff --git a/client/src/app/core/core-services/operator.service.ts b/client/src/app/core/core-services/operator.service.ts
index 7d72004c2..44e3ddc13 100644
--- a/client/src/app/core/core-services/operator.service.ts
+++ b/client/src/app/core/core-services/operator.service.ts
@@ -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;
}
diff --git a/client/src/app/shared/models/motions/motion-block.ts b/client/src/app/shared/models/motions/motion-block.ts
index 954d89d77..d70d55684 100644
--- a/client/src/app/shared/models/motions/motion-block.ts
+++ b/client/src/app/shared/models/motions/motion-block.ts
@@ -9,6 +9,7 @@ export class MotionBlock extends BaseModelWithAgendaItemAndListOfSpeakers
-
{{ block.title }}
-
-
+
+ lock
+ {{ block.title }}
+
@@ -23,10 +13,6 @@
more_vert
-
-
@@ -111,7 +97,7 @@
@@ -121,3 +107,31 @@
+
+
+
+ {{ 'Edit details for' | translate }} {{ block.title }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.scss b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.scss
index d06686661..2f5db7e35 100644
--- a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.scss
+++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.scss
@@ -51,3 +51,7 @@
justify-content: flex-end !important;
}
}
+
+.edit-form {
+ overflow: hidden;
+}
diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts
index edc7769e4..0744edb65 100644
--- a/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts
+++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-detail/motion-block-detail.component.ts
@@ -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;
+
/**
* Constructor for motion block details
*
@@ -66,7 +67,9 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent {
@@ -171,7 +170,7 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent 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();
+ }
+ });
}
/**
diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.html b/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.html
index 69b7f960b..66ed79089 100644
--- a/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.html
+++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.html
@@ -4,7 +4,7 @@
-
+
New motion block
+
+
+ Internal
+
+
-
+
@@ -65,7 +70,10 @@
Title
- {{ block.title }}
+
+ lock
+ {{ block.title }}
+
diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.ts b/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.ts
index 3f1dcf8c3..9e26fe912 100644
--- a/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.ts
+++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.ts
@@ -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);
- this.resetForm();
- this.blockToCreate = null;
+ try {
+ this.repo.create(block);
+ this.resetForm();
+ 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;
}
}
diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py
index b514d7b1b..f718d2eff 100644
--- a/openslides/motions/access_permissions.py
+++ b/openslides/motions/access_permissions.py
@@ -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):
"""
diff --git a/openslides/motions/migrations/0027_motion_block_internal.py b/openslides/motions/migrations/0027_motion_block_internal.py
new file mode 100644
index 000000000..b6bd36d36
--- /dev/null
+++ b/openslides/motions/migrations/0027_motion_block_internal.py
@@ -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),
+ )
+ ]
diff --git a/openslides/motions/models.py b/openslides/motions/models.py
index b190e351d..ae2cb6006 100644
--- a/openslides/motions/models.py
+++ b/openslides/motions/models.py
@@ -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 = ()
diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py
index a94335f8b..fdfae7ead 100644
--- a/openslides/motions/serializers.py
+++ b/openslides/motions/serializers.py
@@ -82,6 +82,7 @@ class MotionBlockSerializer(ModelSerializer):
"list_of_speakers_id",
"agenda_type",
"agenda_parent_id",
+ "internal",
)
def create(self, validated_data):
diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py
index d6c203760..965ce01e0 100644
--- a/tests/integration/motions/test_viewset.py
+++ b/tests/integration/motions/test_viewset.py
@@ -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.