Optional agenda items

This commit is contained in:
FinnStutzenstein 2019-06-03 17:04:30 +02:00
parent a5e1646f3c
commit 8ff0f73477
44 changed files with 549 additions and 210 deletions

View File

@ -1,7 +1,5 @@
import { Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
@ -14,7 +12,8 @@ import { TreeIdNode } from 'app/core/ui-services/tree.service';
import { ViewItem, ItemTitleInformation } from 'app/site/agenda/models/view-item';
import {
BaseViewModelWithAgendaItem,
isBaseViewModelWithAgendaItem
isBaseViewModelWithAgendaItem,
IBaseViewModelWithAgendaItem
} from 'app/site/base/base-view-model-with-agenda-item';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { BaseViewModel } from 'app/site/base/base-view-model';
@ -24,6 +23,7 @@ import { Topic } from 'app/shared/models/topics/topic';
import { Assignment } from 'app/shared/models/assignments/assignment';
import { BaseIsAgendaItemContentObjectRepository } from '../base-is-agenda-item-content-object-repository';
import { BaseHasContentObjectRepository } from '../base-has-content-object-repository';
import { Identifiable } from 'app/shared/models/base/identifiable';
/**
* Repository service for items
@ -105,16 +105,10 @@ export class ItemRepositoryService extends BaseHasContentObjectRepository<
agendaItem.content_object.collection,
agendaItem.content_object.id
);
if (!contentObject) {
if (!contentObject || !isBaseViewModelWithAgendaItem(contentObject)) {
return null;
}
if (isBaseViewModelWithAgendaItem(contentObject)) {
return contentObject;
} else {
throw new Error(
`The content object (${agendaItem.content_object.collection}, ${agendaItem.content_object.id}) of item ${agendaItem.id} is not a BaseAgendaItemViewModel.`
);
}
}
/**
@ -124,19 +118,6 @@ export class ItemRepositoryService extends BaseHasContentObjectRepository<
await this.httpService.post('/rest/agenda/item/numbering/');
}
/**
* @ignore
*
* TODO: Usually, agenda items are deleted with their corresponding content object
* However, deleting an agenda item might be interpretet with "removing an item
* from the agenda" permanently. Usually, items might juse be hidden but not
* deleted (right now)
*/
public async delete(item: ViewItem): Promise<void> {
const restUrl = `/rest/${item.contentObject.collectionString}/${item.contentObject.id}/`;
await this.httpService.delete(restUrl);
}
/**
* TODO: Copied from BaseRepository and added the cloned model to write back the
* item_number correctly. This must be reversed with #4738 (indroduced with #4639)
@ -158,13 +139,23 @@ export class ItemRepositoryService extends BaseHasContentObjectRepository<
return await this.httpService.put(restPath, clone);
}
/**
* Get agenda visibility from the config
*
* @return An observable to the default agenda visibility
*/
public getDefaultAgendaVisibility(): Observable<number> {
return this.config.get('agenda_new_items_default_visibility').pipe(map(key => +key));
public async addItemToAgenda(contentObject: IBaseViewModelWithAgendaItem<any>): Promise<Identifiable> {
return await this.httpService.post('/rest/agenda/item/', {
collection: contentObject.collectionString,
id: contentObject.id
});
}
public async removeFromAgenda(item: ViewItem): Promise<void> {
return await this.httpService.delete(`/rest/agenda/item/${item.id}/`);
}
public async create(item: Item): Promise<Identifiable> {
throw new Error('Use `addItemToAgenda` for creations');
}
public async delete(item: ViewItem): Promise<void> {
throw new Error('Use `removeFromAgenda` for deletions');
}
/**

View File

@ -243,10 +243,10 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
const sendModel = new this.baseModelCtor();
sendModel.patchValues(model);
// Strips empty fields from the sending mode data.
// Strips empty fields from the sending mode data (except false)
// required for i.e. users, since group list is mandatory
Object.keys(sendModel).forEach(key => {
if (!sendModel[key]) {
if (!sendModel[key] && sendModel[key] !== false) {
delete sendModel[key];
}
});

View File

@ -0,0 +1,33 @@
<ng-container *ngIf="showForm">
<div [formGroup]="form">
<mat-checkbox formControlName="agenda_create">
<span translate>Add to agenda</span>
</mat-checkbox>
</div>
<ng-container *ngIf="!!checkbox.value">
<!-- Visibility -->
<div>
<mat-form-field [formGroup]="form">
<mat-select formControlName="agenda_type" placeholder="{{ 'Agenda visibility' | translate }}">
<mat-option *ngFor="let type of ItemVisibilityChoices" [value]="type.key">
<span>{{ type.name | translate }}</span>
</mat-option>
</mat-select>
</mat-form-field>
</div>
<!-- Parent item -->
<div *ngIf="itemObserver.value.length > 0">
<os-search-value-selector
ngDefaultControl
[form]="form"
[formControl]="form.get('agenda_parent_id')"
[multiple]="false"
[includeNone]="true"
listname="{{ 'Parent agenda item' | translate }}"
[InputListValues]="itemObserver"
></os-search-value-selector>
</div>
</ng-container>
</ng-container>

View File

@ -0,0 +1,33 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormBuilder } from '@angular/forms';
import { E2EImportsModule } from 'e2e-imports.module';
import { AgendaContentObjectFormComponent } from './agenda-content-object-form.component';
describe('AgendaContentObjectFormComponent', () => {
let component: AgendaContentObjectFormComponent;
let fixture: ComponentFixture<AgendaContentObjectFormComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AgendaContentObjectFormComponent);
component = fixture.componentInstance;
});
it('should create', () => {
const formBuilder: FormBuilder = TestBed.get(FormBuilder);
component.form = formBuilder.group({
agenda_create: [''],
agenda_parent_id: [],
agenda_type: ['']
});
fixture.detectChanges();
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,66 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { BehaviorSubject } from 'rxjs';
import { ConfigService } from 'app/core/ui-services/config.service';
import { ViewItem } from 'app/site/agenda/models/view-item';
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
import { ItemVisibilityChoices } from 'app/shared/models/agenda/item';
type AgendaItemCreateChoices = 'always' | 'never' | 'default_yes' | 'default_no';
@Component({
selector: 'os-agenda-content-object-form',
templateUrl: './agenda-content-object-form.component.html',
styleUrls: ['./agenda-content-object-form.component.scss']
})
export class AgendaContentObjectFormComponent implements OnInit {
@Input()
public form: FormGroup;
public showForm = false;
public checkbox: FormControl;
/**
* Determine visibility states for the agenda that will be created implicitly
*/
public ItemVisibilityChoices = ItemVisibilityChoices;
/**
* Subject for agenda items
*/
public itemObserver: BehaviorSubject<ViewItem[]>;
public constructor(private configService: ConfigService, private itemRepo: ItemRepositoryService) {}
public ngOnInit(): void {
this.checkbox = this.form.controls.agenda_create as FormControl;
this.configService.get<AgendaItemCreateChoices>('agenda_item_creation').subscribe(value => {
if (value === 'always') {
this.showForm = true;
this.checkbox.disable();
this.form.patchValue({ agenda_create: true });
} else if (value === 'never') {
this.showForm = false;
this.checkbox.disable();
this.form.patchValue({ agenda_create: false });
} else {
const defaultValue = value === 'default_yes';
// check if alrady touched..
this.showForm = true;
this.checkbox.enable();
this.form.patchValue({ agenda_create: defaultValue });
}
});
// Set the default visibility using observers
this.configService.get('agenda_new_items_default_visibility').subscribe(visibility => {
this.form.get('agenda_type').setValue(+visibility);
});
this.itemObserver = this.itemRepo.getViewModelListBehaviorSubject();
}
}

View File

@ -5,7 +5,7 @@ import { BaseModelWithContentObject } from '../base/base-model-with-content-obje
* Determine visibility states for agenda items
* Coming from "ConfigVariables" property "agenda_hide_internal_items_on_projector"
*/
export const itemVisibilityChoices = [
export const ItemVisibilityChoices = [
{ key: 1, name: 'public', csvName: '' },
{ key: 2, name: 'internal', csvName: 'internal' },
{ key: 3, name: 'hidden', csvName: 'hidden' }

View File

@ -94,6 +94,7 @@ import { TileComponent } from './components/tile/tile.component';
import { BlockTileComponent } from './components/block-tile/block-tile.component';
import { IconContainerComponent } from './components/icon-container/icon-container.component';
import { ListViewTableComponent } from './components/list-view-table/list-view-table.component';
import { AgendaContentObjectFormComponent } from './components/agenda-content-object-form/agenda-content-object-form.component';
/**
* Share Module for all "dumb" components and pipes.
@ -230,7 +231,8 @@ import { ListViewTableComponent } from './components/list-view-table/list-view-t
SpeakerButtonComponent,
PblNgridModule,
PblNgridMaterialModule,
ListViewTableComponent
ListViewTableComponent,
AgendaContentObjectFormComponent
],
declarations: [
PermsDirective,
@ -264,7 +266,8 @@ import { ListViewTableComponent } from './components/list-view-table/list-view-t
TileComponent,
BlockTileComponent,
IconContainerComponent,
ListViewTableComponent
ListViewTableComponent,
AgendaContentObjectFormComponent
],
providers: [
{ provide: DateAdapter, useClass: OpenSlidesDateAdapter },

View File

@ -6,7 +6,7 @@ import { TranslateService } from '@ngx-translate/core';
import { BaseImportService, NewEntry } from 'app/core/ui-services/base-import.service';
import { CreateTopic } from './models/create-topic';
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 { TopicRepositoryService } from '../../core/repositories/topics/topic-repository.service';
import { ViewCreateTopic } from './models/view-create-topic';
@ -162,13 +162,13 @@ export class AgendaImportService extends BaseImportService<ViewCreateTopic> {
if (!input) {
return 1; // default, public item
} else if (typeof input === 'string') {
const visibility = itemVisibilityChoices.find(choice => choice.csvName === input);
const visibility = ItemVisibilityChoices.find(choice => choice.csvName === input);
if (visibility) {
return visibility.key;
}
} else if (input === 1) {
// Compatibility with the old client's isInternal column
const visibility = itemVisibilityChoices.find(choice => choice.name === 'Internal item');
const visibility = ItemVisibilityChoices.find(choice => choice.name === 'Internal item');
if (visibility) {
return visibility.key;
}

View File

@ -8,7 +8,7 @@ import { AgendaImportService } from '../../agenda-import.service';
import { BaseImportListComponent } from 'app/site/base/base-import-list';
import { CsvExportService } from 'app/core/ui-services/csv-export.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 { ViewCreateTopic } from '../../models/view-create-topic';
/**
@ -72,7 +72,7 @@ export class AgendaImportListComponent extends BaseImportListComponent<ViewCreat
* @returns A string, which may be empty if the type is not found in the visibilityChoices
*/
public getTypeString(type: number): string {
const visibility = itemVisibilityChoices.find(choice => choice.key === type);
const visibility = ItemVisibilityChoices.find(choice => choice.key === type);
return visibility ? visibility.name : '';
}

View File

@ -175,9 +175,9 @@
<mat-divider></mat-divider>
<!-- Delete selected -->
<button mat-menu-item [disabled]="!selectedRows.length" class="red-warning-text" (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
<button mat-menu-item [disabled]="!selectedRows.length" (click)="removeSelected()">
<mat-icon>remove</mat-icon>
<span translate>Remove from agenda</span>
</button>
</div>
</div>
@ -198,7 +198,12 @@
</button>
<!-- Delete Button -->
<button mat-menu-item class="red-warning-text" (click)="onDelete(item)">
<button mat-menu-item (click)="removeFromAgenda(item)" *ngIf="item.contentObjectData.collection !== 'topics/topic'">
<mat-icon>remove</mat-icon>
<span translate>Remove from agenda</span>
</button>
<button mat-menu-item class="red-warning-text" (click)="deleteTopic(item)" *ngIf="item.contentObjectData.collection === 'topics/topic'">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>

View File

@ -24,6 +24,8 @@ import { ViewportService } from 'app/core/ui-services/viewport.service';
import { ViewItem } from '../../models/view-item';
import { ViewListOfSpeakers } from '../../models/view-list-of-speakers';
import { _ } from 'app/core/translate/translation-marker';
import { TopicRepositoryService } from 'app/core/repositories/topics/topic-repository.service';
import { ViewTopic } from '../../models/view-topic';
/**
* List view for the agenda.
@ -123,7 +125,8 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
public filterService: AgendaFilterListService,
private agendaPdfService: AgendaPdfService,
private pdfService: PdfDocumentService,
private listOfSpeakersRepo: ListOfSpeakersRepositoryService
private listOfSpeakersRepo: ListOfSpeakersRepositoryService,
private topicRepo: TopicRepositoryService
) {
super(titleService, translate, matSnackBar, storage);
this.canMultiSelect = true;
@ -194,7 +197,7 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
*/
public async onAutoNumbering(): Promise<void> {
const title = this.translate.instant('Are you sure you want to number all agenda items?');
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
await this.repo.autoNumbering().then(null, this.raiseError);
}
}
@ -215,15 +218,26 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
}
/**
* Delete handler for a single item
* Remove handler for a single item
*
* @param item The item to delete
* @param item The item to remove from the agenda
*/
public async onDelete(item: ViewItem): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete this entry?');
public async removeFromAgenda(item: ViewItem): Promise<void> {
const title = this.translate.instant('Are you sure you want to remove this entry from the agenda?');
const content = item.contentObject.getTitle();
if (await this.promptService.open(title, content)) {
await this.repo.delete(item).then(null, this.raiseError);
await this.repo.removeFromAgenda(item).then(null, this.raiseError);
}
}
public async deleteTopic(item: ViewItem): Promise<void> {
if (!(item.contentObject instanceof ViewTopic)) {
return;
}
const title = this.translate.instant('Are you sure you want to delete this topic?');
const content = item.contentObject.getTitle();
if (await this.promptService.open(title, content)) {
await this.topicRepo.delete(item.contentObject).then(null, this.raiseError);
}
}
@ -231,11 +245,16 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
* Handler for deleting multiple entries. Needs items in selectedRows, which
* is only filled with any data in multiSelect mode
*/
public async deleteSelected(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete all selected items?');
if (await this.promptService.open(title, null)) {
for (const agenda of this.selectedRows) {
await this.repo.delete(agenda);
public async removeSelected(): Promise<void> {
const title = this.translate.instant('Are you sure you want to remove all selected items from the agenda?');
const content = this.translate.instant("All topics will be deleted and won't be accessible afterwards.");
if (await this.promptService.open(title, content)) {
for (const item of this.selectedRows) {
if (item.contentObject instanceof ViewTopic) {
await this.topicRepo.delete(item.contentObject);
} else {
await this.repo.removeFromAgenda(item);
}
}
}
}
@ -247,8 +266,8 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
* @param closed true if the item is to be considered done
*/
public async setClosedSelected(closed: boolean): Promise<void> {
for (const agenda of this.selectedRows) {
await this.repo.update({ closed: closed }, agenda);
for (const item of this.selectedRows) {
await this.repo.update({ closed: closed }, item);
}
}
@ -259,8 +278,8 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
* @param visible true if the item is to be shown
*/
public async setAgendaType(agendaType: number): Promise<void> {
for (const agenda of this.selectedRows) {
await this.repo.update({ type: agendaType }, agenda).then(null, this.raiseError);
for (const item of this.selectedRows) {
await this.repo.update({ type: agendaType }, item).then(null, this.raiseError);
}
}

View File

@ -5,11 +5,11 @@ import { MatSnackBar } from '@angular/material';
import { BehaviorSubject, Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
import { SortTreeViewComponent, SortTreeFilterOption } from 'app/site/base/sort-tree.component';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewItem } from '../../models/view-item';
import { ItemVisibilityChoices } from 'app/shared/models/agenda/item';
/**
* Sort view for the agenda.
@ -30,7 +30,7 @@ export class AgendaSortComponent extends SortTreeViewComponent<ViewItem> impleme
* Adds the property `state` to identify if the option is marked as active.
* When reset the filters, the option `state` will be set to `false`.
*/
public filterOptions: SortTreeFilterOption[] = itemVisibilityChoices.map(item => {
public filterOptions: SortTreeFilterOption[] = ItemVisibilityChoices.map(item => {
return { label: item.name, id: item.key, state: false };
});

View File

@ -3,7 +3,7 @@ import { FormBuilder, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { ViewItem } from '../../models/view-item';
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
import { ItemVisibilityChoices } from 'app/shared/models/agenda/item';
import { DurationService } from 'app/core/ui-services/duration.service';
/**
@ -23,7 +23,7 @@ export class ItemInfoDialogComponent {
/**
* Hold item visibility
*/
public itemVisibility = itemVisibilityChoices;
public itemVisibility = ItemVisibilityChoices;
/**
* Constructor

View File

@ -402,7 +402,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
const title = this.translate.instant(
'Are you sure you want to delete all speakers from this list of speakers?'
);
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
this.listOfSpeakersRepo.deleteAllSpeakers(this.viewListOfSpeakers);
}
}

View File

@ -12,7 +12,7 @@ import { TopicRepositoryService } from 'app/core/repositories/topics/topic-repos
import { ViewTopic } from '../../models/view-topic';
import { OperatorService } from 'app/core/core-services/operator.service';
import { BehaviorSubject } from 'rxjs';
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
import { ItemVisibilityChoices } from 'app/shared/models/agenda/item';
import { CreateTopic } from '../../models/create-topic';
import { Topic } from 'app/shared/models/topics/topic';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
@ -62,7 +62,7 @@ export class TopicDetailComponent extends BaseViewComponent {
/**
* Determine visibility states for the agenda that will be created implicitly
*/
public itemVisibility = itemVisibilityChoices;
public itemVisibility = ItemVisibilityChoices;
/**
* Constructor for the topic detail page.

View File

@ -1,4 +1,4 @@
import { Item, itemVisibilityChoices } from 'app/shared/models/agenda/item';
import { Item, ItemVisibilityChoices } from 'app/shared/models/agenda/item';
import {
BaseViewModelWithAgendaItem,
isBaseViewModelWithAgendaItem
@ -56,7 +56,7 @@ export class ViewItem extends BaseViewModelWithContentObject<Item, BaseViewModel
if (!this.type) {
return '';
}
const type = itemVisibilityChoices.find(choice => choice.key === this.type);
const type = ItemVisibilityChoices.find(choice => choice.key === this.type);
return type ? type.name : '';
}
@ -68,7 +68,7 @@ export class ViewItem extends BaseViewModelWithContentObject<Item, BaseViewModel
if (!this.type) {
return '';
}
const type = itemVisibilityChoices.find(choice => choice.key === this.type);
const type = ItemVisibilityChoices.find(choice => choice.key === this.type);
return type ? type.csvName : '';
}

View File

@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
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 { StorageService } from 'app/core/core-services/storage.service';
@ -60,7 +60,7 @@ export class AgendaFilterListService extends BaseFilterListService<ViewItem> {
* @returns a list of choices to filter from
*/
private createVisibilityFilterOptions(): OsFilterOption[] {
return itemVisibilityChoices.map(choice => ({
return ItemVisibilityChoices.map(choice => ({
condition: choice.key as number,
label: choice.name
}));

View File

@ -33,6 +33,18 @@
<!-- Project -->
<os-projector-button [object]="assignment" [menuItem]="true"></os-projector-button>
<!-- Add/remove to/from agenda -->
<div *osPerms="'agenda.can_manage'">
<button mat-menu-item (click)="addToAgenda()" *ngIf="assignment && !assignment.agendaItem">
<mat-icon>add</mat-icon>
<span translate>Add to agenda</span>
</button>
<button mat-menu-item (click)="removeFromAgenda()" *ngIf="assignment && assignment.agendaItem">
<mat-icon>remove</mat-icon>
<span translate>Remove from agenda</span>
</button>
</div>
<!-- Delete -->
<div *ngIf="assignment && hasPerms('manage')">
<!-- Delete -->
@ -263,18 +275,7 @@
></os-search-value-selector>
</div>
<!-- searchValueSelector: agendaItem -->
<div class="content-field" *ngIf="parentsAvailable">
<os-search-value-selector
ngDefaultControl
[form]="assignmentForm"
[formControl]="assignmentForm.get('agenda_item_id')"
[multiple]="false"
[includeNone]="false"
listname="{{ 'Parent agenda item' | translate }}"
[InputListValues]="agendaObserver"
></os-search-value-selector>
</div>
<os-agenda-content-object-form *ngIf="newAssignment" [form]="assignmentForm"></os-agenda-content-object-form>
<!-- poll_description_default -->
<div>

View File

@ -161,7 +161,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
* @param repo
* @param userRepo
* @param pollService
* @param agendaRepo
* @param itemRepo
* @param tagRepo
* @param promptService
*/
@ -177,7 +177,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
public repo: AssignmentRepositoryService,
private userRepo: UserRepositoryService,
public pollService: AssignmentPollService,
private agendaRepo: ItemRepositoryService,
private itemRepo: ItemRepositoryService,
private tagRepo: TagRepositoryService,
private promptService: PromptService,
private pdfService: AssignmentPdfExportService,
@ -200,7 +200,9 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
description: '',
poll_description_default: '',
open_posts: 0,
agenda_item_id: '' // create agenda item
agenda_create: [''],
agenda_parent_id: [],
agenda_type: ['']
});
this.candidatesForm = formBuilder.group({
userId: null
@ -212,7 +214,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
*/
public ngOnInit(): void {
this.getAssignmentByUrl();
this.agendaObserver = this.agendaRepo.getViewModelListBehaviorSubject();
this.agendaObserver = this.itemRepo.getViewModelListBehaviorSubject();
this.tagsObserver = this.tagRepo.getViewModelListBehaviorSubject();
this.mediafilesObserver = this.mediafileRepo.getViewModelListBehaviorSubject();
}
@ -292,7 +294,6 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
title: assignment.title || '',
tags_id: assignment.assignment.tags_id || [],
attachments_id: assignment.assignment.attachments_id || [],
agendaItem: assignment.assignment.agenda_item_id || null,
phase: assignment.phase, // todo default: 0?
description: assignment.assignment.description || '',
poll_description_default: assignment.assignment.poll_description_default,
@ -516,4 +517,12 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn
public getSanitizedText(text: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(text);
}
public addToAgenda(): void {
this.itemRepo.addItemToAgenda(this.assignment).then(null, this.raiseError);
}
public removeFromAgenda(): void {
this.itemRepo.removeFromAgenda(this.assignment.agendaItem).then(null, this.raiseError);
}
}

View File

@ -114,7 +114,7 @@ export class AssignmentListComponent extends ListViewBaseComponent<ViewAssignmen
*/
public async deleteSelected(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete all selected elections?');
if (await this.promptService.open(title, '')) {
if (await this.promptService.open(title)) {
for (const assignment of this.selectedRows) {
await this.repo.delete(assignment);
}

View File

@ -157,7 +157,7 @@ export class AssignmentPollComponent extends BaseViewComponent implements OnInit
*/
public async onDeletePoll(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete this ballot?');
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
await this.assignmentRepo.deletePoll(this.poll).then(null, this.raiseError);
}
}

View File

@ -99,10 +99,10 @@
</div>
</div>
<os-speaker-button [object]="file" [menuItem]="true"></os-speaker-button>
<!-- Edit and delete for all images -->
<mat-divider></mat-divider>
<os-speaker-button [object]="file" [menuItem]="true"></os-speaker-button>
<button mat-menu-item (click)="onEditFile(file)">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>

View File

@ -218,7 +218,7 @@ export class MediafileListComponent extends ListViewBaseComponent<ViewMediafile>
*/
public async deleteSelected(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete all selected files?');
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
for (const mediafile of this.selectedRows) {
await this.repo.delete(mediafile);
}

View File

@ -185,7 +185,7 @@ export class ViewMotion extends BaseViewModelWithAgendaItemAndListOfSpeakers<Mot
return this.motion.workflow_id;
}
public get state(): WorkflowState {
public get state(): WorkflowState | null {
return this._state;
}
@ -349,7 +349,7 @@ export class ViewMotion extends BaseViewModelWithAgendaItemAndListOfSpeakers<Mot
* @returns a string representing a color
*/
public get stateCssColor(): string {
return StateCssClassMapping[this.state.css_class] || '';
return this.state ? StateCssClassMapping[this.state.css_class] : '';
}
// This is set by the repository

View File

@ -157,7 +157,7 @@ export class CategoryMotionsSortComponent extends BaseViewComponent implements O
*/
public async sendUpdate(): Promise<void> {
const title = this.translate.instant('Do you really want to save your changes?');
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
const ids = this.motionsCopy.map(motion => motion.id);
this.repo.sortMotionsInCategory(this.category.category, ids);
this.hasChanged = false;

View File

@ -81,6 +81,17 @@
<os-projector-button *ngIf="block" [object]="block" [menuItem]="true"></os-projector-button>
<div *osPerms="'agenda.can_manage'">
<button mat-menu-item (click)="addToAgenda()" *ngIf="block && !block.agendaItem">
<mat-icon>add</mat-icon>
<span translate>Add to agenda</span>
</button>
<button mat-menu-item (click)="removeFromAgenda()" *ngIf="block && block.agendaItem">
<mat-icon>remove</mat-icon>
<span translate>Remove from agenda</span>
</button>
</div>
<div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']">
<button mat-menu-item (click)="toggleEditMode()">
<mat-icon>edit</mat-icon>

View File

@ -14,6 +14,7 @@ import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewMotion } from 'app/site/motions/models/view-motion';
import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
/**
* Detail component to display one motion block
@ -105,7 +106,8 @@ export class MotionBlockDetailComponent extends BaseViewComponent implements OnI
protected motionRepo: MotionRepositoryService,
private promptService: PromptService,
private fb: FormBuilder,
private dialog: MatDialog
private dialog: MatDialog,
private itemRepo: ItemRepositoryService
) {
super(titleService, translate, matSnackBar);
}
@ -248,4 +250,12 @@ export class MotionBlockDetailComponent extends BaseViewComponent implements OnI
public getStateLabel(motion: ViewMotion): string {
return this.motionRepo.getExtendedStateLabel(motion);
}
public addToAgenda(): void {
this.itemRepo.addItemToAgenda(this.block).then(null, this.raiseError);
}
public removeFromAgenda(): void {
this.itemRepo.removeFromAgenda(this.block.agendaItem).then(null, this.raiseError);
}
}

View File

@ -23,27 +23,7 @@
<mat-checkbox formControlName="internal"><span translate>Internal</span></mat-checkbox>
</p>
<!-- Parent item -->
<p>
<os-search-value-selector
ngDefaultControl
listname="{{ 'Parent agenda item' | translate }}"
[form]="createBlockForm"
[formControl]="createBlockForm.get('agenda_parent_id')"
[multiple]="false"
[includeNone]="true"
[InputListValues]="items"
></os-search-value-selector>
</p>
<!-- Visibility -->
<mat-form-field>
<mat-select formControlName="agenda_type" placeholder="{{ 'Agenda visibility' | translate }}">
<mat-option *ngFor="let type of itemVisibility" [value]="type.key">
<span>{{ type.name | translate }}</span>
</mat-option>
</mat-select>
</mat-form-field>
<os-agenda-content-object-form [form]="createBlockForm"></os-agenda-content-object-form>
</form>
</mat-card-content>

View File

@ -8,7 +8,6 @@ import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid';
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
import { ListViewBaseComponent } from 'app/site/base/list-view-base';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { MotionBlockRepositoryService } from 'app/core/repositories/motions/motion-block-repository.service';
@ -47,11 +46,6 @@ export class MotionBlockListComponent extends ListViewBaseComponent<ViewMotionBl
*/
public defaultVisibility: number;
/**
* Determine visibility states for the agenda that will be created implicitly
*/
public itemVisibility = itemVisibilityChoices;
/**
* helper for permission checks
*
@ -82,23 +76,21 @@ export class MotionBlockListComponent extends ListViewBaseComponent<ViewMotionBl
* @param titleService sets the title
* @param translate translpations
* @param matSnackBar display errors in the snack bar
* @param router routing to children
* @param route determine the local route
* @param storage
* @param repo the motion block repository
* @param agendaRepo the agenda repository service
* @param DS the dataStore
* @param formBuilder creates forms
* @param promptService the delete prompt
* @param itemRepo
* @param operator permission checks
* @param sortService
*/
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
storage: StorageService,
public repo: MotionBlockRepositoryService,
private agendaRepo: ItemRepositoryService,
private repo: MotionBlockRepositoryService,
private formBuilder: FormBuilder,
private itemRepo: ItemRepositoryService,
private operator: OperatorService,
@ -108,8 +100,9 @@ export class MotionBlockListComponent extends ListViewBaseComponent<ViewMotionBl
this.createBlockForm = this.formBuilder.group({
title: ['', Validators.required],
agenda_type: ['', Validators.required],
agenda_create: [''],
agenda_parent_id: [],
agenda_type: [''],
internal: [false]
});
}
@ -120,7 +113,6 @@ export class MotionBlockListComponent extends ListViewBaseComponent<ViewMotionBl
public ngOnInit(): void {
super.setTitle('Motion blocks');
this.items = this.itemRepo.getViewModelListBehaviorSubject();
this.agendaRepo.getDefaultAgendaVisibility().subscribe(visibility => (this.defaultVisibility = visibility));
}
/**
@ -155,7 +147,7 @@ export class MotionBlockListComponent extends ListViewBaseComponent<ViewMotionBl
* Click handler for the save button.
* Sends the block to create to the repository and resets the form.
*/
public onSaveNewButton(): void {
public async onSaveNewButton(): Promise<void> {
if (this.createBlockForm.valid) {
const block = this.createBlockForm.value;
if (!block.agenda_parent_id) {
@ -163,7 +155,7 @@ export class MotionBlockListComponent extends ListViewBaseComponent<ViewMotionBl
}
try {
this.repo.create(block);
await this.repo.create(block);
this.resetForm();
this.isCreatingNewBlock = false;
} catch (e) {

View File

@ -269,7 +269,7 @@ export class MotionDetailDiffComponent extends BaseViewComponent implements Afte
$event.stopPropagation();
$event.preventDefault();
const title = this.translate.instant('Are you sure you want to delete this change recommendation?');
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
this.recoRepo.delete(reco).then(null, this.raiseError);
}
}

View File

@ -75,6 +75,17 @@
[menuItem]="true"
*osPerms="'core.can_manage_projector'"
></os-projector-button>
<!-- Add/remove to/from agenda -->
<div *osPerms="'agenda.can_manage'">
<button mat-menu-item (click)="addToAgenda()" *ngIf="motion && !motion.agendaItem">
<mat-icon>add</mat-icon>
<span translate>Add to agenda</span>
</button>
<button mat-menu-item (click)="removeFromAgenda()" *ngIf="motion && motion.agendaItem">
<mat-icon>remove</mat-icon>
<span translate>Remove from agenda</span>
</button>
</div>
<!-- New amendment -->
<button mat-menu-item (click)="createAmendment()" *ngIf="perms.isAllowed('can_create_amendments', motion)">
<mat-icon>add</mat-icon>
@ -89,6 +100,7 @@
<mat-icon>{{ !showAmendmentContext ? 'check_box_outline_blank' : 'check_box' }}</mat-icon>
<span translate>Show entire motion text</span>
</button>
<div *ngIf="perms.isAllowed('manage')">
<mat-divider></mat-divider>
<!-- Delete -->
@ -791,28 +803,8 @@
</div>
</div>
<!-- Visibility -->
<div class="content-field" *ngIf="newMotion">
<mat-form-field>
<mat-select formControlName="agenda_type" placeholder="{{ 'Agenda visibility' | translate }}">
<mat-option *ngFor="let type of itemVisibility" [value]="type.key">
<span>{{ type.name | translate }}</span>
</mat-option>
</mat-select>
</mat-form-field>
</div>
<!-- Parent item -->
<div class="content-field" *ngIf="newMotion && agendaItemObserver.value.length > 0">
<os-search-value-selector
ngDefaultControl
[form]="contentForm"
[formControl]="contentForm.get('agenda_parent_id')"
[multiple]="false"
[includeNone]="true"
listname="{{ 'Parent agenda item' | translate }}"
[InputListValues]="agendaItemObserver"
></os-search-value-selector>
<div *ngIf="newMotion">
<os-agenda-content-object-form [form]="contentForm"></os-agenda-content-object-form>
</div>
<!-- Supporter form -->

View File

@ -13,8 +13,6 @@ import { ChangeRecommendationRepositoryService } from 'app/core/repositories/mot
import { CreateMotion } from 'app/site/motions/models/create-motion';
import { ConfigService } from 'app/core/ui-services/config.service';
import { DiffLinesInParagraph, LineRange } from 'app/core/ui-services/diff.service';
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
import { LinenumberingService } from 'app/core/ui-services/linenumbering.service';
import { LocalPermissionsService } from 'app/site/motions/services/local-permissions.service';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
@ -37,7 +35,6 @@ import { ViewWorkflow } from 'app/site/motions/models/view-workflow';
import { ViewUser } from 'app/site/users/models/view-user';
import { ViewCategory } from 'app/site/motions/models/view-category';
import { ViewCreateMotion } from 'app/site/motions/models/view-create-motion';
import { ViewItem } from 'app/site/agenda/models/view-item';
import { ViewportService } from 'app/core/ui-services/viewport.service';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-motion-change-recommendation';
@ -58,6 +55,7 @@ import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.
import { WorkflowRepositoryService } from 'app/core/repositories/motions/workflow-repository.service';
import { MotionBlockRepositoryService } from 'app/core/repositories/motions/motion-block-repository.service';
import { MotionSortListService } from 'app/site/motions/services/motion-sort-list.service';
import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service';
/**
* Component for the motion detail view
@ -254,11 +252,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
*/
public mediafilesObserver: BehaviorSubject<ViewMediafile[]>;
/**
* Subject for agenda items
*/
public agendaItemObserver: BehaviorSubject<ViewItem[]>;
/**
* Subject for tags
*/
@ -294,16 +287,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
*/
public showAmendmentContext = false;
/**
* Determines the default agenda item visibility
*/
public defaultVisibility: number;
/**
* Determine visibility states for the agenda that will be created implicitly
*/
public itemVisibility = itemVisibilityChoices;
/**
* For using the enum constants from the template
*/
@ -395,7 +378,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
* @param dialogService For opening dialogs
* @param el The native element
* @param repo Motion Repository
* @param agendaRepo Read out agenda variables
* @param changeRecoRepo Change Recommendation Repository
* @param statuteRepo: Statute Paragraph Repository
* @param mediafileRepo Mediafile Repository
@ -431,7 +413,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
private dialogService: MatDialog,
private el: ElementRef,
public repo: MotionRepositoryService,
private agendaRepo: ItemRepositoryService,
private changeRecoRepo: ChangeRecommendationRepositoryService,
private statuteRepo: StatuteParagraphRepositoryService,
private configService: ConfigService,
@ -447,8 +428,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
private mediaFilerepo: MediafileRepositoryService,
private workflowRepo: WorkflowRepositoryService,
private blockRepo: MotionBlockRepositoryService,
private itemRepo: ItemRepositoryService,
private motionSortService: MotionSortListService
private motionSortService: MotionSortListService,
private itemRepo: ItemRepositoryService
) {
super(title, translate, matSnackBar);
}
@ -463,7 +444,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
this.mediafilesObserver = this.mediaFilerepo.getViewModelListBehaviorSubject();
this.workflowObserver = this.workflowRepo.getViewModelListBehaviorSubject();
this.blockObserver = this.blockRepo.getViewModelListBehaviorSubject();
this.agendaItemObserver = this.itemRepo.getViewModelListBehaviorSubject();
this.motionObserver = this.repo.getViewModelListBehaviorSubject();
this.submitterObserver = this.userRepo.getViewModelListBehaviorSubject();
this.supporterObserver = this.userRepo.getViewModelListBehaviorSubject();
@ -513,13 +493,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
}
});
// Set the default visibility using observers
this.agendaRepo.getDefaultAgendaVisibility().subscribe(visibility => {
if (visibility && this.newMotion) {
this.contentForm.get('agenda_type').setValue(visibility);
}
});
// Update statute paragraphs
this.statuteRepo.getViewModelListObservable().subscribe(newViewStatuteParagraphs => {
this.statuteParagraphs = newViewStatuteParagraphs;
@ -724,6 +697,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
reason: reason,
category_id: [''],
attachments_id: [[]],
agenda_create: [''],
agenda_parent_id: [],
agenda_type: [''],
submitters_id: [],
@ -1092,7 +1066,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
const title = this.translate.instant(
'Are you sure you want to copy the final version to the print template?'
);
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
this.updateMotion({ modified_final_version: finalVersion }, this.motion).then(
() => this.setChangeRecoMode(ChangeRecoMode.ModifiedFinal),
this.raiseError
@ -1111,7 +1085,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
*/
public async deleteModifiedFinalVersion(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete the print template?');
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
this.finalEditMode = false;
this.updateMotion({ modified_final_version: '' }, this.motion).then(
() => this.setChangeRecoMode(ChangeRecoMode.Final),
@ -1610,4 +1584,12 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
public editModifiedFinal(): void {
this.finalEditMode = true;
}
public addToAgenda(): void {
this.itemRepo.addItemToAgenda(this.motion).then(null, this.raiseError);
}
public removeFromAgenda(): void {
this.itemRepo.removeFromAgenda(this.motion.agendaItem).then(null, this.raiseError);
}
}

View File

@ -119,7 +119,7 @@ export class MotionPollComponent implements OnInit {
*/
public async deletePoll(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete this vote?');
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
this.motionRepo.deletePoll(this.poll);
}
}

View File

@ -71,7 +71,7 @@ export class MotionMultiselectService {
*/
public async delete(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete all selected motions?');
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
let i = 0;
for (const motion of motions) {

View File

@ -280,7 +280,7 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
*/
public async deleteSelected(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete all selected participants?');
if (await this.promptService.open(title, null)) {
if (await this.promptService.open(title)) {
for (const user of this.selectedRows) {
await this.repo.delete(user);
}

View File

@ -30,6 +30,22 @@ def get_config_variables():
validators=(MaxLengthValidator(20),),
)
yield ConfigVariable(
name="agenda_item_creation",
label="Auto add to agenda",
default_value="always",
input_type="choice",
choices=(
{"value": "always", "display_name": "Always"},
{"value": "never", "display_name": "Never"},
{"value": "default_yes", "display_name": "Ask, default yes"},
{"value": "default_no", "display_name": "Ask, default no"},
),
weight=212,
group="Agenda",
subgroup="General",
)
yield ConfigVariable(
name="agenda_numeral_system",
default_value="arabic",

View File

@ -23,6 +23,9 @@ class AgendaItemMixin(models.Model):
"""
Container for runtime information for agenda app (on create or update of this instance).
Can be an attribute of an item, e.g. "type", "parent_id", "comment", "duration", "weight",
or "create", which determinates, if the items should be created. If not given, the
config value is used.
"""
agenda_item_update_information: Dict[str, Any] = {}
@ -31,17 +34,20 @@ class AgendaItemMixin(models.Model):
@property
def agenda_item(self):
"""
Returns the related agenda item.
Returns the related agenda item, if it exists.
"""
# We support only one agenda item so just return the first element of
# the queryset.
try:
return self.agenda_items.all()[0]
except IndexError:
return None
@property
def agenda_item_id(self):
"""
Returns the id of the agenda item object related to this object.
"""
if self.agenda_item is None:
return None
return self.agenda_item.pk
def get_agenda_title_information(self):

View File

@ -4,7 +4,9 @@ from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db import models
from ..core.config import config
from ..utils.autoupdate import inform_changed_data
from ..utils.rest_api import ValidationError
from .models import Item, ListOfSpeakers
@ -33,16 +35,51 @@ def listen_to_related_object_post_save(sender, instance, created, **kwargs):
if is_agenda_item_content_object:
if created:
if instance.get_collection_string() == "topics/topic":
should_create_item = True
elif config["agenda_item_creation"] == "always":
should_create_item = True
elif config["agenda_item_creation"] == "never":
should_create_item = False
else:
should_create_item = instance.agenda_item_update_information.get(
"create"
)
if should_create_item is None:
should_create_item = config["agenda_item_creation"] == "default_yes"
if should_create_item:
attrs = {}
for attr in ("type", "parent_id", "comment", "duration", "weight"):
if instance.agenda_item_update_information.get(attr):
attrs[attr] = instance.agenda_item_update_information.get(attr)
# Validation: The type is validated in the serializers (to be between 1 and 3).
# If the parent id is given, set the weight to the parent's weight +1 to
# ensure the right placement in the tree. Also validate the parent_id!
parent_id = attrs.get("parent_id")
if parent_id is not None:
try:
parent = Item.objects.get(pk=parent_id)
except Item.DoesNotExist:
raise ValidationError(
{
"detail": f"The parent item with id {parent_id} does not exist"
}
)
attrs["weight"] = parent.weight + 1
Item.objects.create(content_object=instance, **attrs)
if not instance.agenda_item_skip_autoupdate:
instance_inform_changed_data = True
else:
is_agenda_item_content_object = False
# important for the check for item and list of speakers together.
elif not instance.agenda_item_skip_autoupdate:
elif (
not instance.agenda_item_skip_autoupdate
and instance.agenda_item is not None
):
# If the object has changed, then also the agenda item has to be sent.
inform_changed_data(instance.agenda_item)

View File

@ -1,6 +1,7 @@
import jsonschema
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.utils import IntegrityError
from openslides.core.config import config
from openslides.utils.autoupdate import inform_changed_data
@ -15,10 +16,12 @@ from openslides.utils.rest_api import (
ValidationError,
detail_route,
list_route,
status,
)
from openslides.utils.views import TreeSortMixin
from ..utils.auth import has_perm
from ..utils.utils import get_model_from_collection_string
from .access_permissions import ItemAccessPermissions
from .models import Item, ListOfSpeakers, Speaker
@ -42,7 +45,14 @@ class ItemViewSet(ModelViewSet, TreeSortMixin):
"""
if self.action in ("list", "retrieve", "metadata"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action in ("partial_update", "update", "destroy", "sort", "assign"):
elif self.action in (
"partial_update",
"update",
"destroy",
"sort",
"assign",
"create",
):
result = (
has_perm(self.request.user, "agenda.can_see")
and has_perm(self.request.user, "agenda.can_see_internal_items")
@ -56,6 +66,62 @@ class ItemViewSet(ModelViewSet, TreeSortMixin):
result = False
return result
def create(self, request, *args, **kwargs):
"""
Creates an agenda item and adds the content object to the agenda.
Request args should specify the content object:
{
"collection": <The collection string>,
"id": <The content object id>
}
"""
collection = request.data.get("collection")
id = request.data.get("id")
if not isinstance(collection, str):
raise ValidationError({"detail": "The collection needs to be a string"})
if not isinstance(id, int):
raise ValidationError({"detail": "The id needs to be an int"})
try:
model = get_model_from_collection_string(collection)
except ValueError:
raise ValidationError("Invalid collection")
try:
content_object = model.objects.get(pk=id)
except model.DoesNotExist:
raise ValidationError({"detail": "The id is invalid"})
if not hasattr(content_object, "get_agenda_title_information"):
raise ValidationError(
{"detail": "The collection does not have agenda items"}
)
try:
item = Item.objects.create(content_object=content_object)
except IntegrityError:
raise ValidationError({"detail": "The item is already in the agenda"})
inform_changed_data(content_object)
return Response({id: item.id})
def destroy(self, request, *args, **kwargs):
"""
Removes the item from the agenda. This does not delete the content
object. Also, the deletion is denied for items with topics as content objects.
"""
item = self.get_object()
content_object = item.content_object
if content_object.get_collection_string() == "topics/topic":
raise ValidationError(
{"detail": "You cannot delete the agenda item to a topic"}
)
item.delete()
inform_changed_data(content_object)
return Response(status=status.HTTP_204_NO_CONTENT)
def update(self, *args, **kwargs):
"""
Customized view endpoint to update all children if the item type has changed.

View File

@ -8,6 +8,7 @@ from ..poll.serializers import default_votes_validator
from ..utils.auth import get_group_model
from ..utils.autoupdate import inform_changed_data
from ..utils.rest_api import (
BooleanField,
CharField,
DecimalField,
DictField,
@ -69,6 +70,7 @@ class MotionBlockSerializer(ModelSerializer):
Serializer for motion.models.Category objects.
"""
agenda_create = BooleanField(write_only=True, required=False, allow_null=True)
agenda_type = IntegerField(
write_only=True, required=False, min_value=1, max_value=3, allow_null=True
)
@ -81,6 +83,7 @@ class MotionBlockSerializer(ModelSerializer):
"title",
"agenda_item_id",
"list_of_speakers_id",
"agenda_create",
"agenda_type",
"agenda_parent_id",
"internal",
@ -91,9 +94,11 @@ class MotionBlockSerializer(ModelSerializer):
Customized create method. Set information about related agenda item
into agenda_item_update_information container.
"""
agenda_create = validated_data.pop("agenda_create", None)
agenda_type = validated_data.pop("agenda_type", None)
agenda_parent_id = validated_data.pop("agenda_parent_id", None)
motion_block = MotionBlock(**validated_data)
motion_block.agenda_item_update_information["create"] = agenda_create
motion_block.agenda_item_update_information["type"] = agenda_type
motion_block.agenda_item_update_information["parent_id"] = agenda_parent_id
motion_block.save()
@ -417,6 +422,7 @@ class MotionSerializer(ModelSerializer):
workflow_id = IntegerField(
min_value=1, required=False, validators=[validate_workflow_field]
)
agenda_create = BooleanField(write_only=True, required=False, allow_null=True)
agenda_type = IntegerField(
write_only=True, required=False, min_value=1, max_value=3, allow_null=True
)
@ -456,6 +462,7 @@ class MotionSerializer(ModelSerializer):
"polls",
"agenda_item_id",
"list_of_speakers_id",
"agenda_create",
"agenda_type",
"agenda_parent_id",
"sort_parent",
@ -528,6 +535,9 @@ class MotionSerializer(ModelSerializer):
motion.parent = validated_data.get("parent")
motion.statute_paragraph = validated_data.get("statute_paragraph")
motion.reset_state(validated_data.get("workflow_id"))
motion.agenda_item_update_information["create"] = validated_data.get(
"agenda_create"
)
motion.agenda_item_update_information["type"] = validated_data.get(
"agenda_type"
)

View File

@ -19,6 +19,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.serializers import (
BooleanField,
CharField,
DecimalField,
DictField,
@ -55,6 +56,7 @@ __all__ = [
"DestroyModelMixin",
"CharField",
"DictField",
"BooleanField",
"FileField",
"IntegerField",
"JSONField",

View File

@ -77,14 +77,20 @@ class TreeSortMixin:
# layer) and a weight.
nodes_to_check = [fake_root]
# Traverse and check, if every id is given, valid and there are no duplicate ids.
weight = 1
# The weight values are 2, 4, 6, 8,... to "make space" between entries. This is
# some work around for the agenda: If one creates a content object with an item
# and gives the item's parent, than the weight can be set to the parent's one +1.
# If multiple content objects witht he same parent are created, the ordering is not
# guaranteed.
weight = 2
while len(nodes_to_check) > 0:
node = nodes_to_check.pop()
id = node["id"]
if id is not None: # exclude the fake_root
node[weight_key] = weight
weight += 1
weight += 2
if id in ids_found:
raise ValidationError(f"Duplicate id: {id}")
if id not in all_model_ids:

View File

@ -27,6 +27,8 @@ class ContentObjects(TestCase):
lists of speakers. Asserts, that it is recognizes as a content
object and tests creation and deletion of it and the related item
and list of speaker.
Tests optional agenda items with motions, e.g. motion as a content
object without an item.
"""
def setUp(self):
@ -39,7 +41,15 @@ class ContentObjects(TestCase):
def test_topic_is_list_of_speakers_content_object(self):
assert hasattr(Topic(), "get_list_of_speakers_title_information")
def test_create_content_object(self):
def test_motion_is_agenda_item_content_object(self):
assert hasattr(Motion(), "get_agenda_title_information")
def test_motion_is_list_of_speakers_content_object(self):
assert hasattr(Motion(), "get_list_of_speakers_title_information")
def test_create_topic(self):
# Disable autocreation. Topics should create agenda items anyways.
config["agenda_item_creation"] = "never"
topic = Topic.objects.create(title="test_title_fk3Oc209JDiunw2!wwoH")
assert topic.agenda_item is not None
@ -51,7 +61,7 @@ class ContentObjects(TestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_delete_content_object(self):
def test_delete_topic(self):
topic = Topic.objects.create(title="test_title_lwOCK32jZGFb37DpmoP(")
item_id = topic.agenda_item_id
list_of_speakers_id = topic.list_of_speakers_id
@ -63,6 +73,65 @@ class ContentObjects(TestCase):
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_prevent_removing_topic_from_agenda(self):
topic = Topic.objects.create(title="test_title_lwOCK32jZGFb37DpmoP(")
item_id = topic.agenda_item_id
response = self.client.delete(reverse("item-detail", args=[item_id]))
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_adding_topic_twice(self):
topic = Topic.objects.create(title="test_title_lwOCK32jZGFb37DpmoP(")
response = self.client.post(
reverse("item-list"),
{"collection": topic.get_collection_string(), "id": topic.id},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_enabled_auto_adding_item_for_motion(self):
config["agenda_item_creation"] = "always"
response = self.client.post(
reverse("motion-list"),
{
"title": "test_title_F3pApc3em9zIGCie2iwf",
"text": "test_text_wcnLVzezeLcnqlqlC(31",
"agenda_create": False,
},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
motion = Motion.objects.get()
self.assertTrue(motion.agenda_item is not None)
self.assertEqual(motion.agenda_item_id, motion.agenda_item.id)
def test_disabled_auto_adding_item_for_motion(self):
config["agenda_item_creation"] = "never"
response = self.client.post(
reverse("motion-list"),
{
"title": "test_title_OoCoo3MeiT9li5Iengu9",
"text": "test_text_thuoz0iecheiheereiCi",
"agenda_create": True,
},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
motion = Motion.objects.get()
self.assertTrue(motion.agenda_item is None)
self.assertTrue(motion.agenda_item_id is None)
def test_ask_auto_adding_item_for_motion(self):
config["agenda_item_creation"] = "default_no"
response = self.client.post(
reverse("motion-list"),
{
"title": "test_title_wvlvowievgbpypoOV332",
"text": "test_text_tvewpxxcw9r72qNVV3uq",
"agenda_create": True,
},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
motion = Motion.objects.get()
self.assertTrue(motion.agenda_item is not None)
self.assertEqual(motion.agenda_item_id, motion.agenda_item.id)
class RetrieveItem(TestCase):
"""