Add tags for agenda items

Adds tags for agnda items, adds tag filter in agenda list view, server
changes, client relations, adjust agenda csv exporter
This commit is contained in:
Sean 2020-05-14 15:07:59 +02:00
parent 0ee70b7434
commit b6bb1fe767
13 changed files with 132 additions and 21 deletions

View File

@ -20,6 +20,7 @@ import {
} from 'app/site/base/base-view-model-with-agenda-item'; } from 'app/site/base/base-view-model-with-agenda-item';
import { ViewMotion } from 'app/site/motions/models/view-motion'; import { ViewMotion } from 'app/site/motions/models/view-motion';
import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block'; import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block';
import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewTopic } from 'app/site/topics/models/view-topic'; import { ViewTopic } from 'app/site/topics/models/view-topic';
import { BaseHasContentObjectRepository } from '../base-has-content-object-repository'; import { BaseHasContentObjectRepository } from '../base-has-content-object-repository';
import { BaseIsAgendaItemContentObjectRepository } from '../base-is-agenda-item-content-object-repository'; import { BaseIsAgendaItemContentObjectRepository } from '../base-is-agenda-item-content-object-repository';
@ -34,6 +35,12 @@ const ItemRelations: RelationDefinition[] = [
VForeignVerbose: 'BaseViewModelWithAgendaItem', VForeignVerbose: 'BaseViewModelWithAgendaItem',
ownContentObjectDataKey: 'contentObjectData', ownContentObjectDataKey: 'contentObjectData',
ownKey: 'contentObject' ownKey: 'contentObject'
},
{
type: 'M2M',
ownIdKey: 'tags_id',
ownKey: 'tags',
foreignViewModel: ViewTag
} }
]; ];

View File

@ -49,6 +49,7 @@ export class Item extends BaseModelWithContentObject<Item> {
public weight: number; public weight: number;
public parent_id: number; public parent_id: number;
public level: number; public level: number;
public tags_id: number[];
public constructor(input?: any) { public constructor(input?: any) {
super(Item.COLLECTIONSTRING, input); super(Item.COLLECTIONSTRING, input);

View File

@ -33,7 +33,6 @@
<div *pblNgridCellDef="'title'; row as item; rowContext as rowContext" class="cell-slot fill"> <div *pblNgridCellDef="'title'; row as item; rowContext as rowContext" class="cell-slot fill">
<a class="detail-link" [routerLink]="getDetailUrl(item)" *ngIf="!isMultiSelect"></a> <a class="detail-link" [routerLink]="getDetailUrl(item)" *ngIf="!isMultiSelect"></a>
<div [ngStyle]="{ 'margin-left': item.level * 25 + 'px' }" class="innerTable"> <div [ngStyle]="{ 'margin-left': item.level * 25 + 'px' }" class="innerTable">
<!-- Title line --> <!-- Title line -->
<div class="title-line ellipsis-overflow"> <div class="title-line ellipsis-overflow">
<!-- Is Closed --> <!-- Is Closed -->
@ -64,9 +63,22 @@
<!-- Info Column --> <!-- Info Column -->
<div *pblNgridCellDef="'info'; row as item" class="cell-slot fill clickable" (click)="openEditInfo(item)"> <div *pblNgridCellDef="'info'; row as item" class="cell-slot fill clickable" (click)="openEditInfo(item)">
<div class="info-col-items"> <div class="info-col-items">
<div *osPerms="'agenda.can_manage'; and: item.verboseType"> <!-- Tags -->
<div *ngIf="item.tags && item.tags.length">
<os-icon-container icon="local_offer">
<span *ngFor="let tag of item.tags; let last = last">
{{ tag.getTitle() }}
<span *ngIf="!last">,&nbsp;</span>
</span>
</os-icon-container>
</div>
<!-- Visibility (Internal, Public, Hidden) -->
<div *osPerms="'agenda.can_manage'; and: item.verboseType" class="spacer-top-5">
<os-icon-container icon="visibility">{{ item.verboseType | translate }}</os-icon-container> <os-icon-container icon="visibility">{{ item.verboseType | translate }}</os-icon-container>
</div> </div>
<!-- Duration -->
<div *ngIf="item.duration" class="spacer-top-5"> <div *ngIf="item.duration" class="spacer-top-5">
<os-icon-container icon="access_time"> <os-icon-container icon="access_time">
{{ durationService.durationToString(item.duration, 'h') }} {{ durationService.durationToString(item.duration, 'h') }}
@ -98,6 +110,8 @@
<span>{{ 'Multiselect' | translate }}</span> <span>{{ 'Multiselect' | translate }}</span>
</button> </button>
<mat-divider></mat-divider>
<!-- automatic numbering --> <!-- automatic numbering -->
<button mat-menu-item *ngIf="isNumberingAllowed" (click)="onAutoNumbering()"> <button mat-menu-item *ngIf="isNumberingAllowed" (click)="onAutoNumbering()">
<mat-icon>format_list_numbered</mat-icon> <mat-icon>format_list_numbered</mat-icon>
@ -117,6 +131,15 @@
<mat-icon>mic</mat-icon> <mat-icon>mic</mat-icon>
<span>{{ 'Current list of speakers' | translate }}</span> <span>{{ 'Current list of speakers' | translate }}</span>
</button> </button>
<!-- Tags -->
<button mat-menu-item routerLink="/tags" *osPerms="'core.can_manage_tags'">
<mat-icon>local_offer</mat-icon>
<span>{{ 'Tags' | translate }}</span>
</button>
<mat-divider></mat-divider>
<!-- CSV export --> <!-- CSV export -->
<button mat-menu-item *osPerms="'agenda.can_manage'" (click)="csvExportItemList()"> <button mat-menu-item *osPerms="'agenda.can_manage'" (click)="csvExportItemList()">
<mat-icon>archive</mat-icon> <mat-icon>archive</mat-icon>

View File

@ -1,6 +1,15 @@
<h1 mat-dialog-title *ngIf="item">{{ 'Edit details for' | translate }} {{ item.getTitle() }}</h1> <h1 mat-dialog-title *ngIf="item">{{ 'Edit details for' | translate }} {{ item.getTitle() }}</h1>
<div mat-dialog-content> <div mat-dialog-content>
<form class="item-dialog-form" [formGroup]="agendaInfoForm" (keydown)="onKeyDown($event)"> <form class="item-dialog-form" [formGroup]="agendaInfoForm" (keydown)="onKeyDown($event)">
<!-- Tag -->
<mat-form-field *ngIf="isTagAvailable()">
<mat-select formControlName="tags_id" multiple placeholder="{{ 'Tags' | translate }}">
<mat-option *ngFor="let tag of tags" [value]="tag.id">
{{ tag.getTitle() | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<!-- Visibility --> <!-- Visibility -->
<mat-form-field> <mat-form-field>
<mat-select formControlName="type" placeholder="{{ 'Agenda visibility' | translate }}"> <mat-select formControlName="type" placeholder="{{ 'Agenda visibility' | translate }}">

View File

@ -1,10 +1,12 @@
import { Component, Inject } from '@angular/core'; import { Component, Inject, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.service';
import { DurationService } from 'app/core/ui-services/duration.service'; import { DurationService } from 'app/core/ui-services/duration.service';
import { ItemVisibilityChoices } from 'app/shared/models/agenda/item'; import { ItemVisibilityChoices } from 'app/shared/models/agenda/item';
import { durationValidator } from 'app/shared/validators/custom-validators'; import { durationValidator } from 'app/shared/validators/custom-validators';
import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewItem } from '../../models/view-item'; import { ViewItem } from '../../models/view-item';
/** /**
@ -15,7 +17,7 @@ import { ViewItem } from '../../models/view-item';
templateUrl: './item-info-dialog.component.html', templateUrl: './item-info-dialog.component.html',
styleUrls: ['./item-info-dialog.component.scss'] styleUrls: ['./item-info-dialog.component.scss']
}) })
export class ItemInfoDialogComponent { export class ItemInfoDialogComponent implements OnInit {
/** /**
* Holds the agenda item form * Holds the agenda item form
*/ */
@ -26,6 +28,8 @@ export class ItemInfoDialogComponent {
*/ */
public itemVisibility = ItemVisibilityChoices; public itemVisibility = ItemVisibilityChoices;
public tags: ViewTag[] = [];
/** /**
* Constructor * Constructor
* *
@ -38,22 +42,42 @@ export class ItemInfoDialogComponent {
public formBuilder: FormBuilder, public formBuilder: FormBuilder,
public durationService: DurationService, public durationService: DurationService,
public dialogRef: MatDialogRef<ItemInfoDialogComponent>, public dialogRef: MatDialogRef<ItemInfoDialogComponent>,
public tagRepo: TagRepositoryService,
@Inject(MAT_DIALOG_DATA) public item: ViewItem @Inject(MAT_DIALOG_DATA) public item: ViewItem
) { ) {
this.agendaInfoForm = this.formBuilder.group({ this.agendaInfoForm = this.formBuilder.group({
tags_id: [],
type: [''], type: [''],
durationText: ['', durationValidator], durationText: ['', durationValidator],
item_number: [''], item_number: [''],
comment: [''] comment: ['']
}); });
}
public ngOnInit(): void {
// load current values // load current values
if (item) { if (this.item) {
this.agendaInfoForm.get('type').setValue(item.type); this.agendaInfoForm.get('tags_id').setValue(this.item.tags_id);
this.agendaInfoForm.get('durationText').setValue(this.durationService.durationToString(item.duration, 'h')); this.agendaInfoForm.get('type').setValue(this.item.type);
this.agendaInfoForm.get('item_number').setValue(item.item_number); this.agendaInfoForm
this.agendaInfoForm.get('comment').setValue(item.comment); .get('durationText')
.setValue(this.durationService.durationToString(this.item.duration, 'h'));
this.agendaInfoForm.get('item_number').setValue(this.item.item_number);
this.agendaInfoForm.get('comment').setValue(this.item.comment);
} }
this.tagRepo.getViewModelListObservable().subscribe(tags => {
this.tags = tags;
});
}
/**
* Checks if tags are available.
*
* @returns A boolean if they are available.
*/
public isTagAvailable(): boolean {
return !!this.tags && this.tags.length > 0;
} }
/** /**

View File

@ -2,6 +2,7 @@ import { Item, ItemVisibilityChoices } from 'app/shared/models/agenda/item';
import { ContentObject } from 'app/shared/models/base/content-object'; import { ContentObject } from 'app/shared/models/base/content-object';
import { BaseViewModelWithAgendaItem } from 'app/site/base/base-view-model-with-agenda-item'; import { BaseViewModelWithAgendaItem } from 'app/site/base/base-view-model-with-agenda-item';
import { BaseViewModelWithContentObject } from 'app/site/base/base-view-model-with-content-object'; import { BaseViewModelWithContentObject } from 'app/site/base/base-view-model-with-content-object';
import { ViewTag } from 'app/site/tags/models/view-tag';
export interface ItemTitleInformation { export interface ItemTitleInformation {
contentObject: BaseViewModelWithAgendaItem; contentObject: BaseViewModelWithAgendaItem;
@ -53,4 +54,7 @@ export class ViewItem extends BaseViewModelWithContentObject<Item, BaseViewModel
return this.contentObjectData.collection; return this.contentObjectData.collection;
} }
} }
export interface ViewItem extends Item {} interface IItemRelations {
tags: ViewTag[];
}
export interface ViewItem extends Item, IItemRelations {}

View File

@ -36,7 +36,8 @@ export class AgendaCsvExportService {
}, },
{ label: 'Duration', property: 'duration' }, { label: 'Duration', property: 'duration' },
{ label: 'Comment', property: 'comment' }, { label: 'Comment', property: 'comment' },
{ label: 'Item type', property: 'verboseCsvType' } { label: 'Item type', property: 'verboseCsvType' },
{ label: 'Tags', property: 'tags' }
], ],
this.translate.instant('Agenda') + '.csv' this.translate.instant('Agenda') + '.csv'
); );

View File

@ -4,6 +4,7 @@ import { TranslateService } from '@ngx-translate/core';
import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service'; import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.service';
import { BaseFilterListService, OsFilter, OsFilterOption } from 'app/core/ui-services/base-filter-list.service'; import { BaseFilterListService, OsFilter, OsFilterOption } from 'app/core/ui-services/base-filter-list.service';
import { ItemVisibilityChoices } from 'app/shared/models/agenda/item'; import { ItemVisibilityChoices } from 'app/shared/models/agenda/item';
import { ViewItem } from '../models/view-item'; import { ViewItem } from '../models/view-item';
@ -20,14 +21,27 @@ export class AgendaFilterListService extends BaseFilterListService<ViewItem> {
*/ */
protected storageKey = 'AgendaList'; protected storageKey = 'AgendaList';
public tagFilterOptions: OsFilter = {
property: 'tags_id',
label: 'Tags',
options: []
};
/** /**
* Constructor. Also creates the dynamic filter options * Constructor. Also creates the dynamic filter options
* *
* @param store * @param store
* @param translate Translation service * @param translate Translation service
*/ */
public constructor(store: StorageService, OSStatus: OpenSlidesStatusService, private translate: TranslateService) { public constructor(
store: StorageService,
OSStatus: OpenSlidesStatusService,
private translate: TranslateService,
tagRepo: TagRepositoryService
) {
super(store, OSStatus); super(store, OSStatus);
this.updateFilterForRepo(tagRepo, this.tagFilterOptions, this.translate.instant('No tags'));
} }
/** /**
@ -35,11 +49,6 @@ export class AgendaFilterListService extends BaseFilterListService<ViewItem> {
*/ */
protected getFilterDefinitions(): OsFilter[] { protected getFilterDefinitions(): OsFilter[] {
return [ return [
{
label: 'Visibility',
property: 'type',
options: this.createVisibilityFilterOptions()
},
{ {
label: 'Status', label: 'Status',
property: 'closed', property: 'closed',
@ -48,6 +57,12 @@ export class AgendaFilterListService extends BaseFilterListService<ViewItem> {
{ label: this.translate.instant('Closed items'), condition: true } { label: this.translate.instant('Closed items'), condition: true }
] ]
}, },
this.tagFilterOptions,
{
label: 'Visibility',
property: 'type',
options: this.createVisibilityFilterOptions()
},
{ {
label: 'Type', label: 'Type',
property: 'collection', property: 'collection',

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.12 on 2020-05-14 10:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0031_projector_default_height"),
("agenda", "0008_default_ordering_item"),
]
operations = [
migrations.AddField(
model_name="item",
name="tags",
field=models.ManyToManyField(blank=True, to="core.Tag"),
),
]

View File

@ -9,7 +9,7 @@ from django.db import models, transaction
from django.utils import timezone from django.utils import timezone
from openslides.core.config import config from openslides.core.config import config
from openslides.core.models import Countdown from openslides.core.models import Countdown, Tag
from openslides.utils.autoupdate import inform_changed_data from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.manager import BaseManager from openslides.utils.manager import BaseManager
@ -41,7 +41,7 @@ class ItemManager(BaseManager):
return ( return (
super() super()
.get_prefetched_queryset(*args, **kwargs) .get_prefetched_queryset(*args, **kwargs)
.prefetch_related("content_object", "parent") .prefetch_related("content_object", "parent", "tags")
) )
def get_only_non_public_items(self): def get_only_non_public_items(self):
@ -276,6 +276,11 @@ class Item(RESTModelMixin, models.Model):
Field for generic relation to a related object. General field to the related object. Field for generic relation to a related object. General field to the related object.
""" """
tags = models.ManyToManyField(Tag, blank=True)
"""
Tags for the agenda item.
"""
class Meta: class Meta:
default_permissions = () default_permissions = ()
permissions = ( permissions = (

View File

@ -51,6 +51,7 @@ class ItemSerializer(ModelSerializer):
"weight", "weight",
"parent", "parent",
"level", "level",
"tags",
) )

View File

@ -32,6 +32,7 @@ def test_agenda_item_db_queries():
* 1 request to get all topics, * 1 request to get all topics,
* 1 request to get all motion blocks and * 1 request to get all motion blocks and
* 1 request to get all parents * 1 request to get all parents
* 1 request to get all tags
""" """
parent = Topic.objects.create(title="parent").agenda_item parent = Topic.objects.create(title="parent").agenda_item
for index in range(10): for index in range(10):
@ -45,7 +46,7 @@ def test_agenda_item_db_queries():
MotionBlock.objects.create(title="block1") MotionBlock.objects.create(title="block1")
MotionBlock.objects.create(title="block1") MotionBlock.objects.create(title="block1")
assert count_queries(Item.get_elements)() == 6 assert count_queries(Item.get_elements)() == 7
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)

View File

@ -109,7 +109,7 @@ class CreateMotion(TestCase):
The created motion should have an identifier and the admin user should The created motion should have an identifier and the admin user should
be the submitter. be the submitter.
""" """
with self.assertNumQueries(51): with self.assertNumQueries(52):
response = self.client.post( response = self.client.post(
reverse("motion-list"), reverse("motion-list"),
{ {
@ -185,6 +185,7 @@ class CreateMotion(TestCase):
"weight": 10000, "weight": 10000,
"parent_id": None, "parent_id": None,
"level": 0, "level": 0,
"tags_id": [],
}, },
}, },
) )