Merge pull request #5902 from FinnStutzenstein/ChatAccessGroups

Change chat access groups
This commit is contained in:
Emanuel Schütze 2021-02-19 15:18:08 +01:00 committed by GitHub
commit baad950698
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 218 additions and 76 deletions

View File

@ -9,8 +9,10 @@ run-dev: | build-dev
stop-dev:
docker-compose -f docker/docker-compose.dev.yml down
get-server-shell:
docker-compose -f docker/docker-compose.dev.yml run server bash
server-shell:
docker-compose -f docker/docker-compose.dev.yml run --entrypoint="" server docker/wait-for-dev-dependencies.sh
UID=$$(id -u $${USER}) GID=$$(id -g $${USER}) docker-compose -f docker/docker-compose.dev.yml run --entrypoint="" server bash
docker-compose -f docker/docker-compose.dev.yml down
reload-proxy:
docker-compose -f docker/docker-compose.dev.yml exec -w /etc/caddy proxy caddy reload

@ -1 +1 @@
Subproject commit 0197c762d94c0723b377b0b2773fb329b7ccaeca
Subproject commit 76d3f5385e0a76e79d09d573041a0839d8f483d0

View File

@ -18,8 +18,14 @@ import { DataStoreService } from '../../core-services/data-store.service';
const ChatGroupRelations: RelationDefinition[] = [
{
type: 'M2M',
ownIdKey: 'access_groups_id',
ownKey: 'access_groups',
ownIdKey: 'read_groups_id',
ownKey: 'read_groups',
foreignViewModel: ViewGroup
},
{
type: 'M2M',
ownIdKey: 'write_groups_id',
ownKey: 'write_groups',
foreignViewModel: ViewGroup
}
];

View File

@ -1,5 +1,10 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
const fadeSpeed = {
fast: 200,
slow: 600
};
const slideIn = [style({ transform: 'translateX(-85%)' }), animate('600ms ease')];
const slideOut = [
style({ transform: 'translateX(0)' }),
@ -11,9 +16,15 @@ const slideOut = [
)
];
export const collapseAndFade = trigger('collapse', [
state('in', style({ opacity: 1, height: '100%' })),
transition(':enter', [style({ opacity: 0, height: 0 }), animate(fadeSpeed.fast)]),
transition(':leave', animate(fadeSpeed.fast, style({ opacity: 0, height: 0 })))
]);
export const fadeAnimation = trigger('fade', [
state('in', style({ opacity: 1 })),
transition(':enter', [style({ opacity: 0 }), animate(600)]),
transition(':leave', animate(600, style({ opacity: 0 })))
transition(':enter', [style({ opacity: 0 }), animate(fadeSpeed.slow)]),
transition(':leave', animate(fadeSpeed.slow, style({ opacity: 0 })))
]);
export const navItemAnim = trigger('navItemAnim', [transition(':enter', slideIn), transition(':leave', slideOut)]);

View File

@ -18,10 +18,6 @@ os-icon-container {
display: flex;
align-items: center;
& + & {
margin-top: 5px;
}
&.small-container {
@include icon-container(12px, 12px, 400);
}

View File

@ -5,7 +5,8 @@ export class ChatGroup extends BaseModel<ChatGroup> {
public id: number;
public name: string;
public access_groups_id: number[];
public read_groups_id: number[];
public write_groups_id: number[];
public constructor(input?: any) {
super(ChatGroup.COLLECTIONSTRING, input);

View File

@ -1,17 +1,29 @@
<div class="chat-header" *osPerms="permission.chatCanManage">
<os-icon-container
icon="group"
*ngIf="chatGroup.access_groups.length"
matTooltip="{{ 'Access groups' | translate }}"
*ngIf="chatGroup.write_groups.length"
matTooltip="{{ 'Groups with write permissions' | translate }}"
>
<span *ngFor="let group of chatGroup.access_groups | slice: 0:3; let last = last">
<span *ngFor="let group of chatGroup.write_groups | slice: 0:3; let last = last">
<span>{{ group.getTitle() | translate }}</span>
<span *ngIf="!last">, </span>
<span *ngIf="last && chatGroup.access_groups.length > 3">...</span>
<span *ngIf="last && chatGroup.write_groups.length > 3">...</span>
</span>
</os-icon-container>
<os-icon-container
icon="remove_red_eye"
*ngIf="readOnlyGroups.length"
matTooltip="{{ 'Groups with read permissions' | translate }}"
>
<span *ngFor="let group of readOnlyGroups | slice: 0:3; let last = last">
<span>{{ group.getTitle() | translate }}</span>
<span *ngIf="!last">, </span>
<span *ngIf="last && chatGroup.read_groups.length > 3">...</span>
</span>
</os-icon-container>
<button class="chat-options" mat-icon-button [matMenuTriggerFor]="chatgroupMenu">
<mat-icon> more_vert </mat-icon>
<mat-icon>more_vert</mat-icon>
</button>
</div>
@ -41,5 +53,4 @@
<span>{{ 'Delete' | translate }}</span>
</button>
</ng-container>
<!-- edit -->
</mat-menu>

View File

@ -22,6 +22,7 @@ import { ChatGroup } from 'app/shared/models/chat/chat-group';
import { infoDialogSettings } from 'app/shared/utils/dialog-settings';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ChatNotificationService } from 'app/site/chat/services/chat-notification.service';
import { ViewGroup } from 'app/site/users/models/view-group';
import {
ChatGroupData,
EditChatGroupDialogComponent
@ -51,6 +52,12 @@ export class ChatGroupDetailComponent extends BaseViewComponentDirective impleme
return isOnBottom;
}
public get readOnlyGroups(): ViewGroup[] {
const readGroups = this.chatGroup?.read_groups;
const writeGrous = this.chatGroup?.write_groups;
return readGroups?.filter(group => !writeGrous.includes(group)) || [];
}
public constructor(
titleService: Title,
protected translate: TranslateService,
@ -111,7 +118,8 @@ export class ChatGroupDetailComponent extends BaseViewComponentDirective impleme
public editChat(): void {
const chatData: ChatGroupData = {
name: this.chatGroup.name,
access_groups_id: this.chatGroup.access_groups_id
read_groups_id: this.chatGroup.read_groups_id,
write_groups_id: this.chatGroup.write_groups_id
};
const dialogRef = this.dialog.open(EditChatGroupDialogComponent, {

View File

@ -4,8 +4,12 @@
</div>
<div
class="chat-text"
[ngClass]="{ 'background-primary': !isOwnMessage, 'background-primary-darkest': isOwnMessage }"
[matMenuTriggerFor]="canDelete ? singleChatMenu : null"
[ngClass]="{
disabled: !canDelete,
'background-primary': !isOwnMessage,
'background-primary-darkest': isOwnMessage
}"
[matMenuTriggerFor]="singleChatMenu"
>
{{ text }}
<div class="timestamp">{{ date | localizedDate }}</div>

View File

@ -1,9 +1,9 @@
<mat-tab-group (selectedTabChange)="selectedTabChange($event)" *ngIf="chatGroupsExist()">
<mat-tab-group (selectedTabChange)="selectedTabChange($event)" *ngIf="chatGroupsExist">
<mat-tab *ngFor="let chat of chatGroupSubject | async">
<ng-template mat-tab-label>
<span
[matBadgeHidden]="!getNotidficationsForChatId(chat.id)"
[matBadge]="getNotidficationsForChatId(chat.id)"
[matBadgeHidden]="!getNotificationsForChatId(chat.id)"
[matBadge]="getNotificationsForChatId(chat.id)"
matBadgeColor="accent"
matBadgeOverlap="false"
>
@ -16,14 +16,14 @@
</mat-tab>
</mat-tab-group>
<div *ngIf="!chatGroupsExist()">
<div *ngIf="!chatGroupsExist">
<span>
{{ 'No chat groups available' | translate }}
</span>
</div>
<!-- send chat -->
<form [formGroup]="newMessageForm" (ngSubmit)="send()" *ngIf="chatGroupsExist()">
<form [formGroup]="newMessageForm" (ngSubmit)="send()" *ngIf="chatGroupsExist && canSendInSelectedChat" [@collapse]>
<mat-form-field appearance="outline" class="chat-form-field">
<input class="chat-input" type="text" matInput formControlName="text" />
<mat-label>{{ 'Message' | translate }}</mat-label>

View File

@ -7,8 +7,10 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { OperatorService } from 'app/core/core-services/operator.service';
import { ChatGroupRepositoryService } from 'app/core/repositories/chat/chat-group-repository.service';
import { ChatMessageRepositoryService } from 'app/core/repositories/chat/chat-message-repository.service';
import { collapseAndFade } from 'app/shared/animations';
import { ChatMessage } from 'app/shared/models/chat/chat-message';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ChatNotificationService, NotificationAmount } from '../../services/chat-notification.service';
@ -18,7 +20,8 @@ import { ViewChatGroup } from '../../models/view-chat-group';
selector: 'os-chat-tabs',
templateUrl: './chat-tabs.component.html',
styleUrls: ['./chat-tabs.component.scss'],
encapsulation: ViewEncapsulation.None
encapsulation: ViewEncapsulation.None,
animations: [collapseAndFade]
})
export class ChatTabsComponent extends BaseViewComponentDirective implements OnInit {
public chatGroupSubject: BehaviorSubject<ViewChatGroup[]>;
@ -27,6 +30,21 @@ export class ChatTabsComponent extends BaseViewComponentDirective implements OnI
private notifications: NotificationAmount;
private get chatGroupFromIndex(): ViewChatGroup {
return this.chatGroupSubject.value[this.selectedTabIndex];
}
public get chatGroupsExist(): boolean {
return this.chatGroupSubject.value.length > 0;
}
public get canSendInSelectedChat(): boolean {
if (!this.chatGroupFromIndex) {
return false;
}
return this.operator.isInGroupIds(...this.chatGroupFromIndex?.write_groups_id) || false;
}
public constructor(
titleService: Title,
translate: TranslateService,
@ -34,6 +52,7 @@ export class ChatTabsComponent extends BaseViewComponentDirective implements OnI
private repo: ChatGroupRepositoryService,
private chatMessageRepo: ChatMessageRepositoryService,
private chatNotificationService: ChatNotificationService,
private operator: OperatorService,
formBuilder: FormBuilder
) {
super(titleService, translate, matSnackBar);
@ -55,14 +74,10 @@ export class ChatTabsComponent extends BaseViewComponentDirective implements OnI
this.selectedTabIndex = event.index;
}
public getNotidficationsForChatId(chatId: number): number {
public getNotificationsForChatId(chatId: number): number {
return this.notifications?.[chatId] ?? 0;
}
public chatGroupsExist(): boolean {
return this.chatGroupSubject.value.length > 0;
}
public isChatMessageEmpty(): boolean {
return !this.newMessageForm?.value?.text?.trim();
}
@ -70,7 +85,7 @@ export class ChatTabsComponent extends BaseViewComponentDirective implements OnI
public send(): void {
const payload = {
text: this.newMessageForm.value.text,
chatgroup_id: this.chatGroupSubject.value[this.selectedTabIndex].id
chatgroup_id: this.chatGroupFromIndex.id
};
this.chatMessageRepo
.create(payload as ChatMessage)

View File

@ -24,9 +24,17 @@
<!-- Groups -->
<mat-form-field>
<os-search-value-selector
formControlName="access_groups_id"
formControlName="read_groups_id"
[multiple]="true"
placeholder="{{ 'Access groups' | translate }}"
placeholder="{{ 'Groups with read permissions' | translate }}"
[inputListValues]="groupsBehaviorSubject"
></os-search-value-selector>
</mat-form-field>
<mat-form-field>
<os-search-value-selector
formControlName="write_groups_id"
[multiple]="true"
placeholder="{{ 'Groups with write permissions' | translate }}"
[inputListValues]="groupsBehaviorSubject"
></os-search-value-selector>
</mat-form-field>

View File

@ -10,7 +10,8 @@ import { ViewGroup } from 'app/site/users/models/view-group';
export interface ChatGroupData {
name: string;
access_groups_id: number[];
read_groups_id: number[];
write_groups_id: number[];
}
@Component({
@ -36,7 +37,8 @@ export class EditChatGroupDialogComponent {
this.createMode = !data;
this.createUpdateForm = formBuilder.group({
name: [data?.name || '', Validators.required],
access_groups_id: [data?.access_groups_id || []]
read_groups_id: [data?.read_groups_id || []],
write_groups_id: [data?.write_groups_id || []]
});
this.groupsBehaviorSubject = groupRepo.getViewModelListBehaviorSubject();
}

View File

@ -15,5 +15,6 @@ export class ViewChatGroup extends BaseViewModel<ChatGroup> implements ChatGroup
}
}
export interface ViewChatGroup extends ChatGroup {
access_groups: ViewGroup[];
read_groups: ViewGroup[];
write_groups: ViewGroup[];
}

View File

@ -18,9 +18,9 @@ export class ChatService {
private canSeeSomeChatGroup = false;
private canManage = false;
private canSeeChat = new BehaviorSubject<boolean>(false);
private canSeeChatSubject = new BehaviorSubject<boolean>(false);
public get canSeeChatObservable(): Observable<boolean> {
return this.canSeeChat.asObservable();
return this.canSeeChatSubject.asObservable();
}
public constructor(
@ -34,7 +34,7 @@ export class ChatService {
});
this.repo.getViewModelListBehaviorSubject().subscribe(groups => {
this.canSeeSomeChatGroup = !!groups && groups.length > 0;
this.canSeeSomeChatGroup = groups?.length > 0;
this.update();
});
@ -45,6 +45,6 @@ export class ChatService {
}
private update(): void {
this.canSeeChat.next(this.chatEnabled && (this.canSeeSomeChatGroup || this.canManage));
this.canSeeChatSubject.next(this.chatEnabled && (this.canSeeSomeChatGroup || this.canManage));
}
}

View File

@ -18,6 +18,7 @@ services:
- ../server:/app
depends_on:
- redis
- postgres
postgres:
image: postgres:11

View File

@ -19,12 +19,7 @@ function isSettingsFileOk() {
return 0;
}
wait-for-it -t 0 redis:6379
until pg_isready -h postgres -p 5432 -U openslides; do
echo "Waiting for Postgres to become available..."
sleep 3
done
/app/docker/wait-for-dev-dependencies.sh
if [[ ! -f "/app/personal_data/var/settings.py" ]]; then
echo "Create settings"

View File

@ -0,0 +1,10 @@
#!/bin/bash
set -e
wait-for-it -t 0 redis:6379
until pg_isready -h postgres -p 5432 -U openslides; do
echo "Waiting for Postgres to become available..."
sleep 3
done

0
server/manage.py Executable file → Normal file
View File

View File

@ -7,7 +7,7 @@ from openslides.utils.auth import async_has_perm, async_in_some_groups
class ChatGroupAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for ChatGroup and ChatGroupViewSet.
No base perm: The access permissions are done with the access groups
No base perm: The access permissions are done with the read/write groups.
"""
async def get_restricted_data(
@ -22,10 +22,11 @@ class ChatGroupAccessPermissions(BaseAccessPermissions):
data = full_data
else:
for full in full_data:
access_groups = full.get("access_groups_id", [])
if len(
full.get("access_groups_id", [])
) == 0 or await async_in_some_groups(user_id, access_groups):
read_groups = full.get("read_groups_id", [])
write_groups = full.get("write_groups_id", [])
if await async_in_some_groups(
user_id, read_groups
) or await async_in_some_groups(user_id, write_groups):
data.append(full)
return data

View File

@ -0,0 +1,33 @@
# Generated by Finn Stutzenstein on 2021-02-18 08:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat", "0001_initial"),
("users", "0016_remove_user_ordering"),
]
operations = [
migrations.RenameField(
model_name="chatgroup",
old_name="access_groups",
new_name="read_groups",
),
migrations.AlterField(
model_name="chatgroup",
name="read_groups",
field=models.ManyToManyField(
blank=True, related_name="chat_read_groups", to="users.Group"
),
),
migrations.AddField(
model_name="chatgroup",
name="write_groups",
field=models.ManyToManyField(
blank=True, related_name="chat_write_groups", to="users.Group"
),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Finn Stutzenstein on 2021-02-18 08:12
from django.db import migrations
def copy_read_groups_to_write_groups(apps, schema_editor):
ChatGroup = apps.get_model("chat", "ChatGroup")
for chatgroup in ChatGroup.objects.all():
chatgroup.write_groups.add(*chatgroup.read_groups.all())
class Migration(migrations.Migration):
dependencies = [
("chat", "0002_new_access_groups_1"),
]
operations = [migrations.RunPython(copy_read_groups_to_write_groups)]

View File

@ -18,7 +18,7 @@ class ChatGroupManager(BaseManager):
return (
super()
.get_prefetched_queryset(*args, **kwargs)
.prefetch_related("access_groups")
.prefetch_related("read_groups", "write_groups")
)
@ -30,8 +30,11 @@ class ChatGroup(RESTModelMixin, models.Model):
objects = ChatGroupManager()
name = models.CharField(max_length=256)
access_groups = models.ManyToManyField(
settings.AUTH_GROUP_MODEL, blank=True, related_name="chat_access_groups"
read_groups = models.ManyToManyField(
settings.AUTH_GROUP_MODEL, blank=True, related_name="chat_read_groups"
)
write_groups = models.ManyToManyField(
settings.AUTH_GROUP_MODEL, blank=True, related_name="chat_write_groups"
)
class Meta:
@ -41,14 +44,11 @@ class ChatGroup(RESTModelMixin, models.Model):
def __str__(self):
return self.name
def can_access(self, user):
def can_write(self, user):
if has_perm(user.id, "chat.can_manage"):
return True
if not self.access_groups.exists():
return True
return in_some_groups(user.id, self.access_groups.values_list(flat=True))
return in_some_groups(user.id, self.write_groups.values_list(flat=True))
class ChatMessageManager(BaseManager):
@ -61,7 +61,9 @@ class ChatMessageManager(BaseManager):
return (
super()
.get_prefetched_queryset(*args, **kwargs)
.prefetch_related("chatgroup", "chatgroup__access_groups")
.prefetch_related(
"chatgroup", "chatgroup__read_groups", "chatgroup__write_groups"
)
)

View File

@ -14,7 +14,10 @@ class ChatGroupSerializer(ModelSerializer):
Serializer for chat.models.ChatGroup objects.
"""
access_groups = IdPrimaryKeyRelatedField(
read_groups = IdPrimaryKeyRelatedField(
many=True, required=False, queryset=get_group_model().objects.all()
)
write_groups = IdPrimaryKeyRelatedField(
many=True, required=False, queryset=get_group_model().objects.all()
)
@ -23,7 +26,8 @@ class ChatGroupSerializer(ModelSerializer):
fields = (
"id",
"name",
"access_groups",
"read_groups",
"write_groups",
)
@ -35,7 +39,8 @@ class ChatMessageSerializer(ModelSerializer):
chatgroup = IdPrimaryKeyRelatedField(
required=False, queryset=ChatGroup.objects.all()
)
access_groups_id = SerializerMethodField()
read_groups_id = SerializerMethodField()
write_groups_id = SerializerMethodField()
class Meta:
model = ChatMessage
@ -46,7 +51,8 @@ class ChatMessageSerializer(ModelSerializer):
"timestamp",
"username",
"user_id",
"access_groups_id",
"read_groups_id",
"write_groups_id",
)
read_only_fields = (
"username",
@ -58,5 +64,8 @@ class ChatMessageSerializer(ModelSerializer):
data["text"] = validate_html_strict(data["text"])
return data
def get_access_groups_id(self, chatmessage):
return [group.id for group in chatmessage.chatgroup.access_groups.all()]
def get_read_groups_id(self, chatmessage):
return [group.id for group in chatmessage.chatgroup.read_groups.all()]
def get_write_groups_id(self, chatmessage):
return [group.id for group in chatmessage.chatgroup.write_groups.all()]

View File

@ -51,8 +51,8 @@ class ChatGroupViewSet(ModelViewSet):
def update(self, *args, **kwargs):
response = super().update(*args, **kwargs)
# Update all affected chatmessages to update their `access_groups_id` field,
# which is taken from the updated chatgroup.
# Update all affected chatmessages to update their `read_groups_id` and
# `write_groups_id` field, which is taken from the updated chatgroup.
inform_changed_data(ChatMessage.objects.filter(chatgroup=self.get_object()))
return response
@ -93,7 +93,7 @@ class ChatMessageViewSet(
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
if not serializer.validated_data["chatgroup"].can_access(self.request.user):
if not serializer.validated_data["chatgroup"].can_write(self.request.user):
self.permission_denied(self.request)
# Do not use the serializer.save since it will put the model in the history.

View File

@ -22,6 +22,7 @@ from django.http.request import QueryDict
from django.utils.encoding import force_bytes, force_text
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from openslides.chat.models import ChatGroup
from openslides.saml import SAML_ENABLED
from openslides.utils import logging
@ -229,6 +230,7 @@ class UserViewSet(ModelViewSet):
old_delegation_user.save()
inform_changed_data(user)
inform_changed_data(ChatGroup.objects.all())
return response
def assert_vote_not_delegated(self, user):

View File

@ -10,19 +10,24 @@ def test_motion_db_queries():
"""
Tests that only the following db queries for chat groups are done:
* 1 request to get all chat groups
* 1 request to get all access groups
* 1 request to get all read groups
* 1 request to get all write groups
Tests that only the following db queries for chat messages are done:
* 1 request to fet all chat messages
* 1 request to get all chat groups
* 1 request to get all access groups
* 1 request to get all read groups
* 1 request to get all write groups
"""
group1 = get_group_model().objects.create(name="group1")
group2 = get_group_model().objects.create(name="group2")
group3 = get_group_model().objects.create(name="group3")
group4 = get_group_model().objects.create(name="group4")
for i1 in range(5):
chatgroup = ChatGroup.objects.create(name=f"motion{i1}")
chatgroup.access_groups.add(group1, group2)
chatgroup.read_groups.add(group1, group2)
chatgroup.write_groups.add(group3, group4)
for i2 in range(10):
ChatMessage.objects.create(
@ -32,5 +37,5 @@ def test_motion_db_queries():
chatgroup=chatgroup,
)
assert count_queries(ChatGroup.get_elements)() == 2
assert count_queries(ChatMessage.get_elements)() == 3
assert count_queries(ChatGroup.get_elements)() == 3
assert count_queries(ChatMessage.get_elements)() == 4