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';
import { ViewMotion } from 'app/site/motions/models/view-motion';
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 { BaseHasContentObjectRepository } from '../base-has-content-object-repository';
import { BaseIsAgendaItemContentObjectRepository } from '../base-is-agenda-item-content-object-repository';
@ -34,6 +35,12 @@ const ItemRelations: RelationDefinition[] = [
VForeignVerbose: 'BaseViewModelWithAgendaItem',
ownContentObjectDataKey: 'contentObjectData',
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 parent_id: number;
public level: number;
public tags_id: number[];
public constructor(input?: any) {
super(Item.COLLECTIONSTRING, input);

View File

@ -33,7 +33,6 @@
<div *pblNgridCellDef="'title'; row as item; rowContext as rowContext" class="cell-slot fill">
<a class="detail-link" [routerLink]="getDetailUrl(item)" *ngIf="!isMultiSelect"></a>
<div [ngStyle]="{ 'margin-left': item.level * 25 + 'px' }" class="innerTable">
<!-- Title line -->
<div class="title-line ellipsis-overflow">
<!-- Is Closed -->
@ -64,9 +63,22 @@
<!-- Info Column -->
<div *pblNgridCellDef="'info'; row as item" class="cell-slot fill clickable" (click)="openEditInfo(item)">
<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>
</div>
<!-- Duration -->
<div *ngIf="item.duration" class="spacer-top-5">
<os-icon-container icon="access_time">
{{ durationService.durationToString(item.duration, 'h') }}
@ -98,6 +110,8 @@
<span>{{ 'Multiselect' | translate }}</span>
</button>
<mat-divider></mat-divider>
<!-- automatic numbering -->
<button mat-menu-item *ngIf="isNumberingAllowed" (click)="onAutoNumbering()">
<mat-icon>format_list_numbered</mat-icon>
@ -117,6 +131,15 @@
<mat-icon>mic</mat-icon>
<span>{{ 'Current list of speakers' | translate }}</span>
</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 -->
<button mat-menu-item *osPerms="'agenda.can_manage'" (click)="csvExportItemList()">
<mat-icon>archive</mat-icon>

View File

@ -1,6 +1,15 @@
<h1 mat-dialog-title *ngIf="item">{{ 'Edit details for' | translate }} {{ item.getTitle() }}</h1>
<div mat-dialog-content>
<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 -->
<mat-form-field>
<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 { 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 { ItemVisibilityChoices } from 'app/shared/models/agenda/item';
import { durationValidator } from 'app/shared/validators/custom-validators';
import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewItem } from '../../models/view-item';
/**
@ -15,7 +17,7 @@ import { ViewItem } from '../../models/view-item';
templateUrl: './item-info-dialog.component.html',
styleUrls: ['./item-info-dialog.component.scss']
})
export class ItemInfoDialogComponent {
export class ItemInfoDialogComponent implements OnInit {
/**
* Holds the agenda item form
*/
@ -26,6 +28,8 @@ export class ItemInfoDialogComponent {
*/
public itemVisibility = ItemVisibilityChoices;
public tags: ViewTag[] = [];
/**
* Constructor
*
@ -38,22 +42,42 @@ export class ItemInfoDialogComponent {
public formBuilder: FormBuilder,
public durationService: DurationService,
public dialogRef: MatDialogRef<ItemInfoDialogComponent>,
public tagRepo: TagRepositoryService,
@Inject(MAT_DIALOG_DATA) public item: ViewItem
) {
this.agendaInfoForm = this.formBuilder.group({
tags_id: [],
type: [''],
durationText: ['', durationValidator],
item_number: [''],
comment: ['']
});
}
public ngOnInit(): void {
// load current values
if (item) {
this.agendaInfoForm.get('type').setValue(item.type);
this.agendaInfoForm.get('durationText').setValue(this.durationService.durationToString(item.duration, 'h'));
this.agendaInfoForm.get('item_number').setValue(item.item_number);
this.agendaInfoForm.get('comment').setValue(item.comment);
if (this.item) {
this.agendaInfoForm.get('tags_id').setValue(this.item.tags_id);
this.agendaInfoForm.get('type').setValue(this.item.type);
this.agendaInfoForm
.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 { BaseViewModelWithAgendaItem } from 'app/site/base/base-view-model-with-agenda-item';
import { BaseViewModelWithContentObject } from 'app/site/base/base-view-model-with-content-object';
import { ViewTag } from 'app/site/tags/models/view-tag';
export interface ItemTitleInformation {
contentObject: BaseViewModelWithAgendaItem;
@ -53,4 +54,7 @@ export class ViewItem extends BaseViewModelWithContentObject<Item, BaseViewModel
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: 'Comment', property: 'comment' },
{ label: 'Item type', property: 'verboseCsvType' }
{ label: 'Item type', property: 'verboseCsvType' },
{ label: 'Tags', property: 'tags' }
],
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 { 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 { ItemVisibilityChoices } from 'app/shared/models/agenda/item';
import { ViewItem } from '../models/view-item';
@ -20,14 +21,27 @@ export class AgendaFilterListService extends BaseFilterListService<ViewItem> {
*/
protected storageKey = 'AgendaList';
public tagFilterOptions: OsFilter = {
property: 'tags_id',
label: 'Tags',
options: []
};
/**
* Constructor. Also creates the dynamic filter options
*
* @param store
* @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);
this.updateFilterForRepo(tagRepo, this.tagFilterOptions, this.translate.instant('No tags'));
}
/**
@ -35,11 +49,6 @@ export class AgendaFilterListService extends BaseFilterListService<ViewItem> {
*/
protected getFilterDefinitions(): OsFilter[] {
return [
{
label: 'Visibility',
property: 'type',
options: this.createVisibilityFilterOptions()
},
{
label: 'Status',
property: 'closed',
@ -48,6 +57,12 @@ export class AgendaFilterListService extends BaseFilterListService<ViewItem> {
{ label: this.translate.instant('Closed items'), condition: true }
]
},
this.tagFilterOptions,
{
label: 'Visibility',
property: 'type',
options: this.createVisibilityFilterOptions()
},
{
label: 'Type',
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 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.exceptions import OpenSlidesError
from openslides.utils.manager import BaseManager
@ -41,7 +41,7 @@ class ItemManager(BaseManager):
return (
super()
.get_prefetched_queryset(*args, **kwargs)
.prefetch_related("content_object", "parent")
.prefetch_related("content_object", "parent", "tags")
)
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.
"""
tags = models.ManyToManyField(Tag, blank=True)
"""
Tags for the agenda item.
"""
class Meta:
default_permissions = ()
permissions = (

View File

@ -51,6 +51,7 @@ class ItemSerializer(ModelSerializer):
"weight",
"parent",
"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 motion blocks and
* 1 request to get all parents
* 1 request to get all tags
"""
parent = Topic.objects.create(title="parent").agenda_item
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")
assert count_queries(Item.get_elements)() == 6
assert count_queries(Item.get_elements)() == 7
@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
be the submitter.
"""
with self.assertNumQueries(51):
with self.assertNumQueries(52):
response = self.client.post(
reverse("motion-list"),
{
@ -185,6 +185,7 @@ class CreateMotion(TestCase):
"weight": 10000,
"parent_id": None,
"level": 0,
"tags_id": [],
},
},
)