Merge pull request #4756 from FinnStutzenstein/subcategories

Subcategories
This commit is contained in:
Emanuel Schütze 2019-06-12 15:08:46 +02:00 committed by GitHub
commit 35c8dc97f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1447 additions and 713 deletions

View File

@ -44,6 +44,9 @@ Motions:
tags, states and recommendations [#4037, #4132, #4702]. tags, states and recommendations [#4037, #4132, #4702].
- Added timestampes for motions [#4134]. - Added timestampes for motions [#4134].
- New config option to set reason as required field [#4232]. - New config option to set reason as required field [#4232].
- Added subcategories and altered behaviour of motion numbering in
categories: Motions of subcategories are also numbered, and parents of
amendments needs to be in the numbered category or any subcategory [#4756].
User: User:
- Added new admin group which grants all permissions. Users of existing group - Added new admin group which grants all permissions. Users of existing group

View File

@ -5,14 +5,13 @@ import { TranslateService } from '@ngx-translate/core';
import { BaseRepository } from '../base-repository'; import { BaseRepository } from '../base-repository';
import { Category } from 'app/shared/models/motions/category'; import { Category } from 'app/shared/models/motions/category';
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
import { ConfigService } from 'app/core/ui-services/config.service';
import { DataSendService } from '../../core-services/data-send.service'; import { DataSendService } from '../../core-services/data-send.service';
import { DataStoreService } from '../../core-services/data-store.service'; import { DataStoreService } from '../../core-services/data-store.service';
import { HttpService } from '../../core-services/http.service'; import { HttpService } from '../../core-services/http.service';
import { ViewCategory, CategoryTitleInformation } from 'app/site/motions/models/view-category'; import { ViewCategory, CategoryTitleInformation } from 'app/site/motions/models/view-category';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { Motion } from 'app/shared/models/motions/motion';
type SortProperty = 'prefix' | 'name'; import { TreeIdNode } from 'app/core/ui-services/tree.service';
/** /**
* Repository Services for Categories * Repository Services for Categories
@ -28,8 +27,6 @@ type SortProperty = 'prefix' | 'name';
providedIn: 'root' providedIn: 'root'
}) })
export class CategoryRepositoryService extends BaseRepository<ViewCategory, Category, CategoryTitleInformation> { export class CategoryRepositoryService extends BaseRepository<ViewCategory, Category, CategoryTitleInformation> {
private sortProperty: SortProperty;
/** /**
* Creates a CategoryRepository * Creates a CategoryRepository
* Converts existing and incoming category to ViewCategories * Converts existing and incoming category to ViewCategories
@ -48,16 +45,11 @@ export class CategoryRepositoryService extends BaseRepository<ViewCategory, Cate
mapperService: CollectionStringMapperService, mapperService: CollectionStringMapperService,
viewModelStoreService: ViewModelStoreService, viewModelStoreService: ViewModelStoreService,
translate: TranslateService, translate: TranslateService,
private httpService: HttpService, private httpService: HttpService
private configService: ConfigService
) { ) {
super(DS, dataSend, mapperService, viewModelStoreService, translate, Category); super(DS, dataSend, mapperService, viewModelStoreService, translate, Category, [Category]);
this.sortProperty = this.configService.instant('motions_category_sorting'); this.setSortFunction((a, b) => a.weight - b.weight);
this.configService.get<SortProperty>('motions_category_sorting').subscribe(conf => {
this.sortProperty = conf;
this.setConfigSortFn();
});
} }
public getTitle = (titleInformation: CategoryTitleInformation) => { public getTitle = (titleInformation: CategoryTitleInformation) => {
@ -78,10 +70,9 @@ export class CategoryRepositoryService extends BaseRepository<ViewCategory, Cate
* Updates a categories numbering. * Updates a categories numbering.
* *
* @param category the category it should be updated in * @param category the category it should be updated in
* @param motionIds the list of motion ids on this category
*/ */
public async numberMotionsInCategory(category: Category, motionIds: number[]): Promise<void> { public async numberMotionsInCategory(category: ViewCategory): Promise<void> {
await this.httpService.post(`/rest/motions/category/${category.id}/numbering/`, { motions: motionIds }); await this.httpService.post(`/rest/motions/category/${category.id}/numbering/`);
} }
/** /**
@ -91,25 +82,25 @@ export class CategoryRepositoryService extends BaseRepository<ViewCategory, Cate
* @param motionIds the list of motion ids on this category * @param motionIds the list of motion ids on this category
*/ */
public async sortMotionsInCategory(category: Category, motionIds: number[]): Promise<void> { public async sortMotionsInCategory(category: Category, motionIds: number[]): Promise<void> {
await this.httpService.post(`/rest/motions/category/${category.id}/sort/`, { motions: motionIds }); await this.httpService.post(`/rest/motions/category/${category.id}/sort_motions/`, { motions: motionIds });
} }
/** /**
* Triggers an update for the sort function responsible for the default sorting of data items * Sends the changed nodes to the server.
*
* @param data The reordered data from the sorting
*/ */
public setConfigSortFn(): void { public async sortCategories(data: TreeIdNode[]): Promise<void> {
this.setSortFunction((a: ViewCategory, b: ViewCategory) => { await this.httpService.post('/rest/motions/category/sort_categories/', data);
if (a[this.sortProperty] && b[this.sortProperty]) { }
return this.languageCollator.compare(a[this.sortProperty], b[this.sortProperty]);
} else if (this.sortProperty === 'prefix') { /**
if (a.prefix) { * Filter the DataStore by Motions and returns the amount of motions in the given category
return 1; *
} else if (b.prefix) { * @param category the category
return -1; * @returns the number of motions inside the category
} else { */
return this.languageCollator.compare(a.name, b.name); public getMotionAmountByCategory(category: ViewCategory): number {
} return this.DS.filter(Motion, motion => motion.category_id === category.id).length;
}
});
} }
} }

View File

@ -10,6 +10,9 @@ export class Category extends BaseModel<Category> {
public id: number; public id: number;
public name: string; public name: string;
public prefix: string; public prefix: string;
public parent_id?: number;
public weight: number;
public level: number;
public constructor(input?: any) { public constructor(input?: any) {
super(Category.COLLECTIONSTRING, input); super(Category.COLLECTIONSTRING, input);

View File

@ -18,10 +18,16 @@ export interface CategoryTitleInformation {
export class ViewCategory extends BaseViewModel<Category> implements CategoryTitleInformation, Searchable { export class ViewCategory extends BaseViewModel<Category> implements CategoryTitleInformation, Searchable {
public static COLLECTIONSTRING = Category.COLLECTIONSTRING; public static COLLECTIONSTRING = Category.COLLECTIONSTRING;
private _parent?: ViewCategory;
public get category(): Category { public get category(): Category {
return this._model; return this._model;
} }
public get parent(): ViewCategory | null {
return this._parent;
}
public get name(): string { public get name(): string {
return this.category.name; return this.category.name;
} }
@ -30,26 +36,39 @@ export class ViewCategory extends BaseViewModel<Category> implements CategoryTit
return this.category.prefix; return this.category.prefix;
} }
/** public get weight(): number {
* TODO: Where is this used? Try to avoid this. return this.category.weight;
*/ }
public set prefix(prefix: string) {
this._model.prefix = prefix; public get parent_id(): number {
return this.category.parent_id;
}
public get level(): number {
return this.category.level;
} }
/** /**
* TODO: Where is this used? Try to avoid this. * TODO: Where is this used? Try to avoid this.
*/ */
public set name(name: string) { /*public set prefix(prefix: string) {
this._model.prefix = prefix;
}*/
/**
* TODO: Where is this used? Try to avoid this.
*/
/*public set name(name: string) {
this._model.name = name; this._model.name = name;
} }*/
public get prefixedName(): string { public get prefixedName(): string {
return this.prefix ? this.prefix + ' - ' + this.name : this.name; return this.prefix ? this.prefix + ' - ' + this.name : this.name;
} }
public constructor(category: Category) { public constructor(category: Category, parent?: ViewCategory) {
super(Category.COLLECTIONSTRING, category); super(Category.COLLECTIONSTRING, category);
this._parent = parent;
} }
public formatForSearch(): SearchRepresentation { public formatForSearch(): SearchRepresentation {
@ -64,5 +83,9 @@ export class ViewCategory extends BaseViewModel<Category> implements CategoryTit
* Updates the local objects if required * Updates the local objects if required
* @param update * @param update
*/ */
public updateDependencies(update: BaseViewModel): void {} public updateDependencies(update: BaseViewModel): void {
if (update instanceof ViewCategory && update.id === this.parent_id) {
this._parent = update;
}
}
} }

View File

@ -1,12 +1,16 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { CategoryListComponent } from './components/category-list/category-list.component'; import { CategoryListComponent } from './components/category-list/category-list.component';
import { CategorySortComponent } from './components/category-sort/category-sort.component'; import { CategoryMotionsSortComponent } from './components/category-motions-sort/category-motions-sort.component';
import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard'; import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard';
import { CategoryDetailComponent } from './components/category-detail/category-detail.component';
import { CategoriesSortComponent } from './components/categories-sort/categories-sort.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: CategoryListComponent, pathMatch: 'full' }, { path: '', component: CategoryListComponent, pathMatch: 'full' },
{ path: ':id', component: CategorySortComponent, canDeactivate: [WatchSortingTreeGuard] } { path: ':id/sort', component: CategoryMotionsSortComponent, canDeactivate: [WatchSortingTreeGuard] },
{ path: 'sort', component: CategoriesSortComponent, canDeactivate: [WatchSortingTreeGuard] },
{ path: ':id', component: CategoryDetailComponent }
]; ];
@NgModule({ @NgModule({

View File

@ -4,10 +4,17 @@ import { CommonModule } from '@angular/common';
import { CategoryRoutingModule } from './category-routing.module'; import { CategoryRoutingModule } from './category-routing.module';
import { SharedModule } from 'app/shared/shared.module'; import { SharedModule } from 'app/shared/shared.module';
import { CategoryListComponent } from './components/category-list/category-list.component'; import { CategoryListComponent } from './components/category-list/category-list.component';
import { CategorySortComponent } from './components/category-sort/category-sort.component'; import { CategoryMotionsSortComponent } from './components/category-motions-sort/category-motions-sort.component';
import { CategoryDetailComponent } from './components/category-detail/category-detail.component';
import { CategoriesSortComponent } from './components/categories-sort/categories-sort.component';
@NgModule({ @NgModule({
declarations: [CategoryListComponent, CategorySortComponent], declarations: [
CategoryListComponent,
CategoryDetailComponent,
CategoryMotionsSortComponent,
CategoriesSortComponent
],
imports: [CommonModule, CategoryRoutingModule, SharedModule] imports: [CommonModule, CategoryRoutingModule, SharedModule]
}) })
export class CategoryModule {} export class CategoryModule {}

View File

@ -0,0 +1,23 @@
<os-head-bar
[editMode]="hasChanged"
(saveEvent)="onSave()"
(mainEvent)="onCancel()"
[nav]="false">
<!-- Title -->
<div class="title-slot">
<h2 translate>Sort categories</h2>
</div>
</os-head-bar>
<mat-card>
<os-sorting-tree
#osSortedTree
parentKey="parent_id"
weightKey="weight"
(hasChanged)="receiveChanges($event)"
[model]="categoriesObservable">
<ng-template #innerNode let-item="item">
{{ item.getTitle() }}
</ng-template>
</os-sorting-tree>
</mat-card>

View File

@ -0,0 +1,22 @@
// TODO: partial duplicate of sorting-list
.line {
display: table;
width: 100%;
font-size: 14px;
min-height: 50px;
margin-bottom: 5px;
.left {
display: table-cell;
vertical-align: middle;
max-width: 100%;
}
.right {
display: table-cell;
padding-right: 10px;
vertical-align: middle;
width: auto;
white-space: nowrap;
}
}

View File

@ -1,21 +1,21 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CategorySortComponent } from './category-sort.component';
import { E2EImportsModule } from 'e2e-imports.module'; import { E2EImportsModule } from 'e2e-imports.module';
import { CategoriesSortComponent } from './categories-sort.component';
describe('CategorySortComponent', () => { describe('CategoriesSortComponent', () => {
let component: CategorySortComponent; let component: CategoriesSortComponent;
let fixture: ComponentFixture<CategorySortComponent>; let fixture: ComponentFixture<CategoriesSortComponent>;
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [E2EImportsModule], imports: [E2EImportsModule],
declarations: [CategorySortComponent] declarations: [CategoriesSortComponent]
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(CategorySortComponent); fixture = TestBed.createComponent(CategoriesSortComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });

View File

@ -0,0 +1,101 @@
import { Component, ViewChild } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { BaseViewComponent } from 'app/site/base/base-view';
import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component';
import { ViewCategory } from 'app/site/motions/models/view-category';
import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service';
/**
* Sort view for the call list.
*/
@Component({
selector: 'os-categories-sort',
templateUrl: './categories-sort.component.html',
styleUrls: ['./categories-sort.component.scss']
})
export class CategoriesSortComponent extends BaseViewComponent implements CanComponentDeactivate {
/**
* Reference to the sorting tree.
*/
@ViewChild('osSortedTree')
private osSortTree: SortingTreeComponent<ViewCategory>;
/**
* All motions sorted first by weight, then by id.
*/
public categoriesObservable: Observable<ViewCategory[]>;
/**
* Boolean to check if the tree has changed.
*/
public hasChanged = false;
/**
* Updates the motions member, and sorts it.
* @param title
* @param translate
* @param matSnackBar
* @param categoryRepo
* @param promptService
*/
public constructor(
title: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private categoryRepo: CategoryRepositoryService,
private promptService: PromptService
) {
super(title, translate, matSnackBar);
this.categoriesObservable = this.categoryRepo.getViewModelListObservable();
}
/**
* Function to save changes on click.
*/
public async onSave(): Promise<void> {
await this.categoryRepo
.sortCategories(this.osSortTree.getTreeData())
.then(() => this.osSortTree.setSubscription(), this.raiseError);
}
/**
* Function to restore the old state.
*/
public async onCancel(): Promise<void> {
if (await this.canDeactivate()) {
this.osSortTree.setSubscription();
}
}
/**
* Function to get an info if changes has been made.
*
* @param hasChanged Boolean received from the tree to see that changes has been made.
*/
public receiveChanges(hasChanged: boolean): void {
this.hasChanged = hasChanged;
}
/**
* Function to open a prompt dialog,
* so the user will be warned if he has made changes and not saved them.
*
* @returns The result from the prompt dialog.
*/
public async canDeactivate(): Promise<boolean> {
if (this.hasChanged) {
const title = this.translate.instant('Do you really want to exit this page?');
const content = this.translate.instant('You made changes.');
return await this.promptService.open(title, content);
}
return true;
}
}

View File

@ -0,0 +1,117 @@
<os-head-bar [nav]="false">
<!-- Title -->
<div class="title-slot">
<h2 *ngIf="selectedCategory">
{{ selectedCategory.prefixedName }}
</h2>
</div>
<!-- Menu -->
<div class="menu-slot">
<div *osPerms="'motions.can_manage'">
<button type="button" mat-icon-button [matMenuTriggerFor]="categoryMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</div>
</os-head-bar>
<mat-card>
<div *ngFor="let category of categories">
<h2>
<span>{{ getLevelDashes(category) }}</span>
{{ category.prefixedName }}
</h2>
<table class="os-headed-listview-table on-transition-fade" mat-table [dataSource]="dataSources[category.id]">
<!-- title column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef> <span translate>Motion</span> </mat-header-cell>
<mat-cell *matCellDef="let motion">
{{ motion.getTitle() }}
</mat-cell>
</ng-container>
<!-- state column -->
<ng-container matColumnDef="state">
<mat-header-cell *matHeaderCellDef> <span translate>State</span> </mat-header-cell>
<mat-cell class="chip-container" *matCellDef="let motion">
<mat-basic-chip disableRipple [ngClass]="motion.stateCssColor">
{{ getStateLabel(motion) }}
</mat-basic-chip>
</mat-cell>
</ng-container>
<!-- Recommendation column -->
<ng-container matColumnDef="recommendation">
<mat-header-cell *matHeaderCellDef> <span translate>Recommendation</span> </mat-header-cell>
<mat-cell class="chip-container" *matCellDef="let motion">
<mat-basic-chip *ngIf="motion.recommendation" disableRipple class="bluegrey">
{{ getRecommendationLabel(motion) }}
</mat-basic-chip>
</mat-cell>
</ng-container>
<!-- Anchor column to open the separate tab -->
<ng-container matColumnDef="anchor">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let motion">
<a [routerLink]="motion.getDetailStateURL()"></a>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
</table>
</div>
</mat-card>
<!-- The menu content -->
<mat-menu #categoryMenu="matMenu">
<button mat-menu-item (click)="toggleEditMode()">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
<button mat-menu-item [routerLink]="'./sort'">
<mat-icon>sort</mat-icon>
<span translate>Sort motions</span>
</button>
<button mat-menu-item (click)="numberMotions()">
<mat-icon>format_list_numbered</mat-icon>
<span translate>Number motions</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item class="red-warning-text" (click)="onDeleteButton()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</mat-menu>
<ng-template #editDialog>
<h1 mat-dialog-title>
<span>{{ 'Edit details for' | translate }} {{ selectedCategory.prefixedName }}</span>
</h1>
<div class="os-form-card-mobile" mat-dialog-content>
<form class="edit-form" [formGroup]="editForm" (ngSubmit)="save()" (keydown)="onKeyDown($event)">
<mat-form-field>
<input matInput osAutofocus placeholder="{{ 'Prefix' | translate }}" formControlName="prefix"/>
</mat-form-field>
<mat-form-field>
<input matInput osAutofocus placeholder="{{ 'Name' | translate }}" formControlName="name" required/>
</mat-form-field>
</form>
</div>
<div mat-dialog-actions>
<button
type="submit"
mat-button
[disabled]="!editForm.valid"
color="primary"
(click)="save()"
>
<span translate>Save</span>
</button>
<button type="button" mat-button [mat-dialog-close]="null">
<span translate>Cancel</span>
</button>
</div>
</ng-template>

View File

@ -0,0 +1,57 @@
@import '~assets/styles/tables.scss';
.block-title {
padding: 40px;
padding-left: 25px;
line-height: 180%;
font-size: 120%;
color: #317796; // TODO: put in theme as $primary
h2 {
margin: 0;
font-weight: normal;
}
}
.block-card {
margin: 0 20px 0 20px;
padding: 25px;
button {
.mat-icon {
margin-right: 5px;
}
}
}
.chip-container {
display: block;
min-height: 0; // default is inherit, will appear on the top edge of the cell
}
.os-headed-listview-table {
// Title
.mat-column-title {
flex: 4 0 0;
}
// State
.mat-column-state {
flex: 2 0 0;
}
// Recommendation
.mat-column-recommendation {
flex: 2 0 0;
}
// Remove
.mat-column-remove {
flex: 1 0 0;
justify-content: flex-end !important;
}
}
.edit-form {
overflow: hidden;
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CategoryDetailComponent } from './category-detail.component';
import { E2EImportsModule } from 'e2e-imports.module';
describe('CategoryDetailComponent', () => {
let component: CategoryDetailComponent;
let fixture: ComponentFixture<CategoryDetailComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [CategoryDetailComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CategoryDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,241 @@
import { ActivatedRoute, Router } from '@angular/router';
import { Component, OnInit, ViewChild, TemplateRef } from '@angular/core';
import { FormGroup, Validators, FormBuilder } from '@angular/forms';
import { MatSnackBar, MatDialog, MatTableDataSource } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewMotion } from 'app/site/motions/models/view-motion';
import { ViewCategory } from 'app/site/motions/models/view-category';
import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view';
/**
* Detail component to display one motion block
*/
@Component({
selector: 'os-category-detail',
templateUrl: './category-detail.component.html',
styleUrls: ['./category-detail.component.scss']
})
export class CategoryDetailComponent extends BaseViewComponent implements OnInit {
/**
* The one selected category
*/
public selectedCategory: ViewCategory;
/**
* All categories with the selected one and all children.
*/
public categories: ViewCategory[];
/**
* Datasources for `categories`. Holds all motions for one category.
*/
public readonly dataSources: { [id: number]: MatTableDataSource<ViewMotion> } = {};
/**
* The form to edit the selected category
*/
@ViewChild('editForm')
public editForm: FormGroup;
/**
* Reference to the template for edit-dialog
*/
@ViewChild('editDialog')
private editDialog: TemplateRef<string>;
/**
* helper for permission checks
*
* @returns true if the user may alter motions
*/
public get canEdit(): boolean {
return this.operator.hasPerms('motions.can_manage');
}
/**
* Constructor for motion block details
*
* @param titleService Setting the title
* @param translate translations
* @param matSnackBar showing errors
* @param operator the current user
* @param router navigating
* @param route determine the blocks ID by the route
* @param repo the motion blocks repository
* @param motionRepo the motion repository
* @param promptService the displaying prompts before deleting
*/
public constructor(
titleService: Title,
protected translate: TranslateService,
matSnackBar: MatSnackBar,
private route: ActivatedRoute,
private operator: OperatorService,
private router: Router,
private repo: CategoryRepositoryService,
private motionRepo: MotionRepositoryService,
private promptService: PromptService,
private fb: FormBuilder,
private dialog: MatDialog
) {
super(titleService, translate, matSnackBar);
}
/**
* Init function.
* Sets the title, observes the block and the motions belonging in this block
*/
public ngOnInit(): void {
super.setTitle('Category');
const selectedCategoryId = parseInt(this.route.snapshot.params.id, 10);
this.subscriptions.push(
this.repo.getViewModelListObservable().subscribe(categories => {
// Extract all categories, that is the selected one and all child categories
const selectedCategoryIndex = categories.findIndex(category => category.id === selectedCategoryId);
if (selectedCategoryIndex < 0) {
return;
}
// Find index of last child. THis can be easily done by searching, becuase this
// is the flat sorted tree
this.selectedCategory = categories[selectedCategoryIndex];
let lastChildIndex: number;
for (
lastChildIndex = selectedCategoryIndex + 1;
lastChildIndex < categories.length &&
categories[lastChildIndex].level > this.selectedCategory.level;
lastChildIndex++
) {}
this.categories = categories.slice(selectedCategoryIndex, lastChildIndex);
// setup datasources:
const allMotions = this.motionRepo
.getViewModelList()
.sort((a, b) => a.category_weight - b.category_weight);
this.categories.forEach(category => {
if (!this.dataSources[category.id]) {
const dataSource = new MatTableDataSource<ViewMotion>();
dataSource.data = allMotions.filter(motion => motion.category_id === category.id);
this.dataSources[category.id] = dataSource;
}
});
}),
this.motionRepo.getViewModelListObservable().subscribe(motions => {
motions = motions
.filter(motion => !!motion.category_id)
.sort((a, b) => a.category_weight - b.category_weight);
Object.keys(this.dataSources).forEach(_id => {
const id = +_id;
this.dataSources[id].data = motions.filter(motion => motion.category_id === id);
});
})
);
}
/**
* Returns the columns that should be shown in the table
*
* @returns an array of strings building the column definition
*/
public getColumnDefinition(): string[] {
return ['title', 'state', 'recommendation', 'anchor'];
}
/**
* Click handler to delete a category
*/
public async onDeleteButton(): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete this category?');
const content = this.selectedCategory.prefixedName;
if (await this.promptService.open(title, content)) {
await this.repo.delete(this.selectedCategory);
this.router.navigate(['../'], { relativeTo: this.route });
}
}
/**
* Clicking escape while in editForm should deactivate edit mode.
*
* @param event The key that was pressed
*/
public onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.dialog.closeAll();
}
if (event.key === 'Enter') {
this.save();
}
}
/**
* Save event handler
*/
public save(): void {
this.repo
.update(this.editForm.value, this.selectedCategory)
.then(() => this.dialog.closeAll())
.catch(this.raiseError);
}
/**
* Click handler for the edit button
*/
public toggleEditMode(): void {
this.editForm = this.fb.group({
prefix: [this.selectedCategory.prefix],
name: [this.selectedCategory.name, Validators.required]
});
this.dialog.open(this.editDialog, {
width: '400px',
maxWidth: '90vw',
maxHeight: '90vh',
disableClose: true
});
}
/**
* Fetch a motion's current recommendation label
*
* @param motion
* @returns the current recommendation label (with extension)
*/
public getRecommendationLabel(motion: ViewMotion): string {
return this.motionRepo.getExtendedRecommendationLabel(motion);
}
/**
* Fetch a motion's current state label
*
* @param motion
* @returns the current state label (with extension)
*/
public getStateLabel(motion: ViewMotion): string {
return this.motionRepo.getExtendedStateLabel(motion);
}
public getLevelDashes(category: ViewCategory): string {
const relativeLevel = category.level - this.selectedCategory.level;
return ''.repeat(relativeLevel);
}
/**
* Triggers a numbering of the motions
*/
public async numberMotions(): Promise<void> {
const title = this.translate.instant('Are you sure you want to renumber all motions of this category?');
const content = this.selectedCategory.getTitle();
if (await this.promptService.open(title, content)) {
await this.repo.numberMotionsInCategory(this.selectedCategory).then(null, this.raiseError);
}
}
}

View File

@ -1,133 +1,91 @@
<os-head-bar prevUrl="../.." [nav]="false" [mainButton]="true" (mainEvent)="onPlusButton()"> <os-head-bar prevUrl="../.." [nav]="false" [mainButton]="canEdit" (mainEvent)="onPlusButton()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Categories</h2> <h2 translate>Categories</h2>
</div> </div>
<!-- Menu -->
<div class="menu-slot" *osPerms="'motions.can_manage'">
<button type="button" mat-icon-button [matMenuTriggerFor]="categoryMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</os-head-bar> </os-head-bar>
<div class="spacer-top-20"></div> <!-- Creating a new motion block -->
<mat-card class="os-card" *ngIf="categoryToCreate"> <mat-card class="os-card" *ngIf="isCreatingNewCategory">
<mat-card-title translate>Create new category</mat-card-title> <mat-card-title translate>New motion block</mat-card-title>
<mat-card-content> <mat-card-content>
<form <form [formGroup]="createForm" (keydown)="onKeyDown($event)">
class="full-width-form flex-spaced" <!-- Prefix -->
id="createForm" <p>
[formGroup]="createForm" <mat-form-field>
(keydown)="keyDownFunction($event)" <input formControlName="prefix" matInput placeholder="{{ 'Prefix' | translate }}" />
> </mat-form-field>
<!-- prefix input --> </p>
<mat-form-field class="short-input">
<input formControlName="prefix" matInput placeholder="{{ 'Prefix' | translate }}" /> <!-- Name -->
</mat-form-field> <p>
<!-- name input --> <mat-form-field>
<mat-form-field class="long-input"> <input formControlName="name" matInput placeholder="{{ 'Name' | translate }}" required />
<input formControlName="name" matInput placeholder="{{ 'Name' | translate }}" required /> <mat-error *ngIf="!createForm.controls.name.valuid" translate>
<mat-hint *ngIf="!updateForm.controls.name.valid"> A name is required
<span translate>Required</span> </mat-error>
</mat-hint> </mat-form-field>
</mat-form-field> </p>
</form> </form>
</mat-card-content> </mat-card-content>
<!-- Save and Cancel buttons --> <!-- Save and Cancel buttons -->
<mat-card-actions> <mat-card-actions>
<button mat-button (click)="onCreateButton()"> <button mat-button [disabled]="!createForm.valid" (click)="onCreate()">
<span translate>Save</span> <span translate>Save</span>
</button> </button>
<button mat-button (click)="categoryToCreate = null"> <button mat-button (click)="onCancel()">
<span translate>Cancel</span> <span translate>Cancel</span>
</button> </button>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
<!-- Table -->
<mat-card class="os-card"> <mat-card class="os-card">
<mat-accordion displayMode="flat"> <table class="os-headed-listview-table on-transition-fade" mat-table [dataSource]="dataSource">
<ng-container *ngFor="let category of categories"> <!-- title column -->
<mat-expansion-panel <ng-container matColumnDef="title">
class="os-card-expandion-panel" <mat-header-cell *matHeaderCellDef>
(opened)="setValues(category)" <span translate>Title</span>
[expanded]="editId === category.id" </mat-header-cell>
(closed)="onCancelButton()" <mat-cell *matCellDef="let category">
> <div [style.margin-left]="getMargin(category)">{{ category.prefixedName }}</div>
<!-- Header shows Prefix and name --> </mat-cell>
<mat-expansion-panel-header>
<mat-panel-title>
<div class="header-container">
<div class="header-name">
<div>
{{ category.prefixedName }}
</div>
</div>
<div class="header-size os-amount-chip">
{{ motionsInCategory(category).length }}
</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<!-- Edit form shows during the edit event -->
<div class="full-width-form">
<form
class="full-width-form"
id="updateForm"
[formGroup]="updateForm"
*ngIf="editId === category.id"
(keydown)="keyDownFunction($event, category)"
>
<div class="flex-spaced">
<mat-form-field class="short-input">
<input formControlName="prefix" matInput placeholder="{{ 'Prefix' | translate }}" />
</mat-form-field>
<mat-form-field class="long-input">
<input
formControlName="name"
matInput
placeholder="{{ 'Name' | translate }}"
required
/>
<mat-hint *ngIf="!updateForm.controls.name.valid">
<span translate>Required</span>
</mat-hint>
</mat-form-field>
</div>
<div class="inline-form-submit" *osPerms="'motions.can_manage'">
<button
[disabled]="!updateForm.dirty"
mat-button
class="on-transition-fade"
(click)="onSaveButton(category)"
>
<span translate>Save</span>
</button>
<button
type="button"
mat-button
class="on-transition-fade"
[routerLink]="getSortUrl(category)"
>
<span translate>Sort motions</span>
</button>
<button
type="button"
mat-button
class="on-transition-fade"
(click)="onDeleteButton(category)"
>
<span translate>Delete</span>
</button>
</div>
</form>
</div>
<!-- Show and sort corresponding motions-->
<div *ngIf="motionsInCategory(category).length > 0">
<span translate>Motions</span>:
<div>
<ul *ngFor="let motion of getSortedMotionListInCategory(category)">
<li class="ellipsis-overflow">{{ motion.getListTitle() }}</li>
</ul>
</div>
</div>
</mat-expansion-panel>
</ng-container> </ng-container>
</mat-accordion>
<!-- amount column -->
<ng-container matColumnDef="amount">
<mat-header-cell *matHeaderCellDef>
<span translate>Motions</span>
</mat-header-cell>
<mat-cell *matCellDef="let category">
<span class="os-amount-chip">{{ getMotionAmount(category) }}</span>
</mat-cell>
</ng-container>
<!-- Anchor column to open the separate tab -->
<ng-container matColumnDef="anchor">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let category">
<a [routerLink]="category.id"></a>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
</table>
</mat-card> </mat-card>
<mat-menu #categoryMenu="matMenu">
<button mat-menu-item [routerLink]="'./sort'">
<mat-icon>sort</mat-icon>
<span translate>Sort categories</span>
</button>
</mat-menu>

View File

@ -1,45 +1,26 @@
.header-container { @import '~assets/styles/tables.scss';
display: grid;
grid-template-rows: auto;
grid-template-columns: 75% 25%;
width: 100%;
> div { .os-headed-listview-table {
grid-row-start: 1; // Title
grid-row-end: span 1; .mat-column-title {
grid-column-end: span 2; flex: 9 0 0;
} }
.header-name { // Amount
grid-column-start: 1; .mat-column-amount {
color: lightslategray; flex: 1 0 60px;
} }
.header-size { // Menu
grid-column-start: 2; .mat-column-menu {
flex: 0 0 40px;
} }
} }
mat-expansion-panel { ::ng-deep .mat-form-field {
max-width: 770px; width: 50%;
margin: auto;
} }
.full-width-form { .mat-cell > div {
display: flex; z-index: 1 !important;
width: 100%;
align-content: space-between;
flex: 2;
}
.short-input {
width: 20%;
}
.long-input {
width: 75%;
}
.inline-form-submit {
justify-content: end;
display: block;
flex: 1;
} }

View File

@ -1,7 +1,7 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CategoryListComponent } from './category-list.component';
import { E2EImportsModule } from 'e2e-imports.module'; import { E2EImportsModule } from 'e2e-imports.module';
import { CategoryListComponent } from './category-list.component';
describe('CategoryListComponent', () => { describe('CategoryListComponent', () => {
let component: CategoryListComponent; let component: CategoryListComponent;

View File

@ -1,226 +1,156 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormGroup, FormBuilder, Validators } from '@angular/forms'; import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BaseViewComponent } from 'app/site/base/base-view'; import { ListViewBaseComponent } from 'app/site/base/list-view-base';
import { OperatorService } from 'app/core/core-services/operator.service';
import { StorageService } from 'app/core/core-services/storage.service';
import { Category } from 'app/shared/models/motions/category'; import { Category } from 'app/shared/models/motions/category';
import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service'; import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service';
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewCategory } from 'app/site/motions/models/view-category'; import { ViewCategory } from 'app/site/motions/models/view-category';
import { ViewMotion } from 'app/site/motions/models/view-motion';
/** /**
* List view for the categories. * Table for categories
*/ */
@Component({ @Component({
selector: 'os-category-list', selector: 'os-category-list',
templateUrl: './category-list.component.html', templateUrl: './category-list.component.html',
styleUrls: ['./category-list.component.scss'] styleUrls: ['./category-list.component.scss']
}) })
export class CategoryListComponent extends BaseViewComponent implements OnInit { export class CategoryListComponent extends ListViewBaseComponent<ViewCategory, Category, CategoryRepositoryService>
implements OnInit {
/** /**
* Hold the category to create * Holds the create form
*/
public categoryToCreate: Category | null;
/**
* Determine which category is opened
*/
public editId: number | null;
/**
* Source of the data
*/
public categories: ViewCategory[];
/**
* For new categories
*/ */
public createForm: FormGroup; public createForm: FormGroup;
/** /**
* The current focussed form * Flag, if the creation panel is open
*/ */
public updateForm: FormGroup; public isCreatingNewCategory = false;
/**
* helper for permission checks
*
* @returns true if the user may alter motions or their metadata
*/
public get canEdit(): boolean {
return this.operator.hasPerms('motions.can_manage');
}
/** /**
* The usual component constructor * The usual component constructor
* @param titleService * @param titleService
* @param translate * @param translate
* @param matSnackBar * @param matSnackBar
* @param route
* @param storage
* @param repo * @param repo
* @param formBuilder * @param formBuilder
* @param promptService * @param promptService
* @param operator
*/ */
public constructor( public constructor(
titleService: Title, titleService: Title,
protected translate: TranslateService, // protected required for ng-translate-extract translate: TranslateService,
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
route: ActivatedRoute,
storage: StorageService,
private repo: CategoryRepositoryService, private repo: CategoryRepositoryService,
private motionRepo: MotionRepositoryService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private promptService: PromptService private operator: OperatorService
) { ) {
super(titleService, translate, matSnackBar); super(titleService, translate, matSnackBar, repo, route, storage);
this.createForm = this.formBuilder.group({ this.createForm = this.formBuilder.group({
prefix: [''], prefix: [''],
name: ['', Validators.required] name: ['', Validators.required],
}); parent_id: ['']
this.updateForm = this.formBuilder.group({
prefix: [''],
name: ['', Validators.required]
}); });
} }
/** /**
* Event on key-down in form. Submits the current form if the 'enter' button is pressed * Observe the agendaItems for changes.
*
* @param event
* @param viewCategory
*/
public keyDownFunction(event: KeyboardEvent, viewCategory?: ViewCategory): void {
if (event.key === 'Enter') {
if (viewCategory) {
this.onSaveButton(viewCategory);
} else {
this.onCreateButton();
}
}
}
/**
* Init function.
*
* Sets the title and gets/observes categories from DataStore
*/ */
public ngOnInit(): void { public ngOnInit(): void {
super.setTitle('Categories'); super.setTitle('Categories');
this.repo.getViewModelListObservable().subscribe(newViewCategories => { this.initTable();
this.categories = newViewCategories;
});
} }
/** /**
* Add a new Category. * Returns the columns that should be shown in the table
*
* @returns an array of strings building the column definition
*/
public getColumnDefinition(): string[] {
return ['title', 'amount', 'anchor'];
}
/**
* return the amount of motions in a category
*
* @param category the category to determine the amount of motions for
* @returns a number that indicates how many motions are in the given category
*/
public getMotionAmount(category: ViewCategory): number {
return this.repo.getMotionAmountByCategory(category);
}
/**
* Click handler for the plus button
*/ */
public onPlusButton(): void { public onPlusButton(): void {
if (!this.categoryToCreate) { if (!this.isCreatingNewCategory) {
this.categoryToCreate = new Category();
this.createForm.reset(); this.createForm.reset();
this.isCreatingNewCategory = true;
} }
} }
/** /**
* Creates a new category. Executed after hitting save. * Click handler for the save button.
* Sends the category to create to the repository and resets the form.
*/ */
public onCreateButton(): void { public onCreate(): void {
if (this.createForm.valid) { if (this.createForm.valid) {
const cat: Partial<Category> = { name: this.createForm.get('name').value }; try {
if (this.createForm.get('prefix').value) { this.repo.create(this.createForm.value);
cat.prefix = this.createForm.get('prefix').value; this.createForm.reset();
this.isCreatingNewCategory = false;
} catch (e) {
this.raiseError(e);
} }
this.categoryToCreate.patchValues(cat); }
// set a form control as "touched" to trigger potential error messages
this.createForm.get('name').markAsTouched();
}
this.repo.create(this.categoryToCreate).then(() => (this.categoryToCreate = null), this.raiseError); /**
* clicking Shift and Enter will save automatically
* clicking Escape will cancel the process
*
* @param event has the code
*/
public onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
this.onCreate();
}
if (event.key === 'Escape') {
this.onCancel();
} }
} }
/** /**
* Category specific edit button * Cancels the current form action
* @param viewCategory individual cat
*/ */
public onEditButton(viewCategory: ViewCategory): void { public onCancel(): void {
this.editId = viewCategory.id; this.isCreatingNewCategory = false;
this.updateForm.reset();
this.updateForm.patchValue({
prefix: viewCategory.category.prefix,
name: viewCategory.name
});
} }
/** public getMargin(category: ViewCategory): string {
* Saves a category return `${category.level * 20}px`;
* TODO: Some feedback
*
* @param viewCategory
*/
public async onSaveButton(viewCategory: ViewCategory): Promise<void> {
if (this.updateForm.dirty && this.updateForm.valid) {
const cat: Partial<Category> = { name: this.updateForm.get('name').value };
cat.prefix = this.updateForm.get('prefix').value || '';
await this.repo.update(cat, viewCategory);
this.updateForm.markAsPristine();
}
}
/**
* Trigger after cancelling an edit. The updateForm is reset to an original
* value, which might belong to a different category
*/
public onCancelButton(): void {
this.updateForm.markAsPristine();
}
/**
* is executed, when the delete button is pressed
*
* @param viewCategory The category to delete
*/
public async onDeleteButton(viewCategory: ViewCategory): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete this category?');
const content = viewCategory.getTitle();
if (await this.promptService.open(title, content)) {
this.repo.delete(viewCategory).then(() => this.onCancelButton(), this.raiseError);
}
}
/**
* Returns the motions corresponding to a category
*
* @param category target
* @returns all motions in the category
*/
public motionsInCategory(category: Category): ViewMotion[] {
return this.motionRepo.getSortedViewModelList().filter(m => m.category_id === category.id);
}
/**
* Function to get a sorted list of all motions in a specific category.
* Sorting by `category_weight`.
*
* @param category the target category in where the motions are.
*
* @returns all motions in the given category sorted by their category_weight.
*/
public getSortedMotionListInCategory(category: Category): ViewMotion[] {
return this.motionsInCategory(category).sort((a, b) => a.category_weight - b.category_weight);
}
/**
* Fetch the correct URL for a detail sort view
*
* @param viewCategory
*/
public getSortUrl(viewCategory: ViewCategory): string {
return `/motions/category/${viewCategory.id}`;
}
/**
* Set/reset the initial values and the referenced category of the update form
*
* @param category
*/
public setValues(category: ViewCategory): void {
this.editId = category.id;
this.updateForm.setValue({
prefix: category.prefix,
name: category.name
});
} }
} }

View File

@ -0,0 +1,27 @@
<os-head-bar
[editMode]="hasChanged"
(saveEvent)="sendUpdate()"
(mainEvent)="onCancel()"
[nav]="false">
<!-- Title -->
<div class="title-slot">
<h2 *ngIf="category" translate>Sort motions in {{ category.prefixedName }}</h2>
</div>
</os-head-bar>
<mat-card class="os-form-card">
<div translate>
Drag and drop motions to reorder the category. Then click the button to renumber.
</div>
<div *ngIf="isMultiSelect">
<span> {{ sortSelector.multiSelectedIndex.length }}&nbsp;</span>
<span translate>selected</span>
<button mat-button (click)="moveToPosition()"><span translate>move ...</span>
</button>
</div>
<os-sorting-list
(sortEvent)="onListUpdate($event)"
[input]="motionObservable"
#sorter
></os-sorting-list>
</mat-card>

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CategoryMotionsSortComponent } from './category-motions-sort.component';
import { E2EImportsModule } from 'e2e-imports.module';
describe('CategoryMotionsSortComponent', () => {
let component: CategoryMotionsSortComponent;
let fixture: ComponentFixture<CategoryMotionsSortComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [CategoryMotionsSortComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CategoryMotionsSortComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -21,11 +21,11 @@ import { ViewMotion } from 'app/site/motions/models/view-motion';
* as displayed in this view * as displayed in this view
*/ */
@Component({ @Component({
selector: 'os-category-sort', selector: 'os-category-motions-sort',
templateUrl: './category-sort.component.html', templateUrl: './category-motions-sort.component.html',
styleUrls: ['./category-sort.component.scss'] styleUrls: ['./category-motions-sort.component.scss']
}) })
export class CategorySortComponent extends BaseViewComponent implements OnInit, CanComponentDeactivate { export class CategoryMotionsSortComponent extends BaseViewComponent implements OnInit, CanComponentDeactivate {
/** /**
* The current category. Determined by the route * The current category. Determined by the route
*/ */
@ -68,16 +68,6 @@ export class CategorySortComponent extends BaseViewComponent implements OnInit,
return this.motionsSubject.asObservable(); return this.motionsSubject.asObservable();
} }
/**
* @returns the name and (if present) prefix of the category
*/
public get categoryName(): string {
if (!this.category) {
return '';
}
return this.category.prefix ? `${this.category.name} (${this.category.prefix})` : this.category.name;
}
/** /**
* The Sort Component * The Sort Component
*/ */
@ -140,24 +130,6 @@ export class CategorySortComponent extends BaseViewComponent implements OnInit,
this.motionsCopy = motions; this.motionsCopy = motions;
} }
/**
* Triggers a (re-)numbering of the motions after a configmarion dialog
*
* @param category
*/
public async onNumberMotions(): Promise<void> {
if (this.sortSelector) {
const title = this.translate.instant('Are you sure you want to renumber all motions of this category?');
const content = this.category.getTitle();
if (await this.promptService.open(title, content)) {
const sortedMotionIds = this.sortSelector.array.map(selectable => selectable.id);
await this.repo
.numberMotionsInCategory(this.category.category, sortedMotionIds)
.then(null, this.raiseError);
}
}
}
/** /**
* Listener for the sorting event in the `sorting-list`. * Listener for the sorting event in the `sorting-list`.
* *

View File

@ -1,49 +0,0 @@
<!-- TODO permission -->
<os-head-bar
[editMode]="hasChanged"
(saveEvent)="sendUpdate()"
(mainEvent)="onCancel()"
[nav]="false">
<!-- Title -->
<div class="title-slot"><h2 translate>Sort motions</h2></div>
</os-head-bar>
<mat-card class="os-form-card">
<h3>{{ categoryName }}</h3>
<br />
<span translate>
Drag and drop motions to reorder the category. Then click the button to renumber.
</span>
<br />
<button
mat-raised-button
color="primary"
(click)="onNumberMotions()"
class="spacer-top-10 spacer-bottom-10"
[disabled]="!motionsCount || hasChanged"
>
<span translate>Number motions</span>
</button>
<div *ngIf="isMultiSelect">
<span> {{ sortSelector.multiSelectedIndex.length }}&nbsp;</span>
<span translate>selected</span>
<button mat-button (click)="moveToPosition()"><span translate>move ...</span>
</button>
</div>
<os-sorting-list
(sortEvent)="onListUpdate($event)"
[input]="motionObservable"
#sorter
>
<!-- implicit motion references into the component using ng-template slot -->
<ng-template let-motion>
<span class="ellipsis-overflow small" *ngIf="motion.tags && motion.tags.length">
<span *ngFor="let tag of motion.tags">
<mat-icon inline>local_offer</mat-icon>
{{ tag.getTitle() }}
</span>
</span>
<mat-chip matTooltip="{{ 'Sequential number' | translate }}">{{ motion.id }}</mat-chip>
</ng-template>
</os-sorting-list>
</mat-card>

View File

@ -76,7 +76,7 @@
<ng-container matColumnDef="anchor"> <ng-container matColumnDef="anchor">
<mat-header-cell *matHeaderCellDef></mat-header-cell> <mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let motion"> <mat-cell *matCellDef="let motion">
<a [routerLink]="getMotionLink(motion)"></a> <a [routerLink]="motion.getDetailStateURL()"></a>
</mat-cell> </mat-cell>
</ng-container> </ng-container>

View File

@ -128,16 +128,6 @@ export class MotionBlockDetailComponent extends ListViewBaseComponent<ViewMotion
} }
} }
/**
* Return the URL to a selected motion
*
* @param motion the selected ViewMotion
* @returns a link as url
*/
public getMotionLink(motion: ViewMotion): string {
return `/motions/${motion.id}`;
}
/** /**
* Click handler to delete motion blocks * Click handler to delete motion blocks
*/ */

View File

@ -13,7 +13,7 @@
<mat-form-field> <mat-form-field>
<input formControlName="title" matInput placeholder="{{ 'Title' | translate }}" required /> <input formControlName="title" matInput placeholder="{{ 'Title' | translate }}" required />
<mat-error *ngIf="createBlockForm.get('title').hasError('required')" translate> <mat-error *ngIf="createBlockForm.get('title').hasError('required')" translate>
A name is required A title is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</p> </p>
@ -86,23 +86,6 @@
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<!-- menu -->
<ng-container matColumnDef="menu">
<mat-header-cell *matHeaderCellDef>
<span translate>Menu</span>
</mat-header-cell>
<mat-cell *matCellDef="let block">
<button
*ngIf="canEdit"
mat-icon-button
[matMenuTriggerFor]="singleItemMenu"
[matMenuTriggerData]="{ item: block }"
>
<mat-icon>more_vert</mat-icon>
</button>
</mat-cell>
</ng-container>
<!-- Anchor column to open the separate tab --> <!-- Anchor column to open the separate tab -->
<ng-container matColumnDef="anchor"> <ng-container matColumnDef="anchor">
<mat-header-cell *matHeaderCellDef></mat-header-cell> <mat-header-cell *matHeaderCellDef></mat-header-cell>
@ -115,13 +98,3 @@
<mat-row *matRowDef="let row; columns: getColumnDefinition()"> </mat-row> <mat-row *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
</table> </table>
</mat-card> </mat-card>
<mat-menu #singleItemMenu="matMenu">
<ng-template matMenuContent let-item="item">
<!-- Delete Button -->
<button mat-menu-item class="red-warning-text" (click)="onDelete(item)">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</ng-template>
</mat-menu>

View File

@ -14,7 +14,6 @@ import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { MotionBlockRepositoryService } from 'app/core/repositories/motions/motion-block-repository.service'; import { MotionBlockRepositoryService } from 'app/core/repositories/motions/motion-block-repository.service';
import { MotionBlockSortService } from 'app/site/motions/services/motion-block-sort.service'; import { MotionBlockSortService } from 'app/site/motions/services/motion-block-sort.service';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { StorageService } from 'app/core/core-services/storage.service'; import { StorageService } from 'app/core/core-services/storage.service';
import { ViewItem } from 'app/site/agenda/models/view-item'; import { ViewItem } from 'app/site/agenda/models/view-item';
import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block'; import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block';
@ -89,7 +88,6 @@ export class MotionBlockListComponent
private repo: MotionBlockRepositoryService, private repo: MotionBlockRepositoryService,
private agendaRepo: ItemRepositoryService, private agendaRepo: ItemRepositoryService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private promptService: PromptService,
private itemRepo: ItemRepositoryService, private itemRepo: ItemRepositoryService,
private operator: OperatorService, private operator: OperatorService,
sortService: MotionBlockSortService sortService: MotionBlockSortService
@ -124,9 +122,6 @@ export class MotionBlockListComponent
if (this.operator.hasPerms('core.can_manage_projector')) { if (this.operator.hasPerms('core.can_manage_projector')) {
columns = ['projector'].concat(columns); columns = ['projector'].concat(columns);
} }
if (this.operator.hasPerms('motions.can_manage')) {
columns = columns.concat(['menu']);
}
return columns; return columns;
} }
@ -140,19 +135,6 @@ export class MotionBlockListComponent
return this.repo.getMotionAmountByBlock(motionBlock); return this.repo.getMotionAmountByBlock(motionBlock);
} }
/**
* Click handler to delete motion blocks
*
* @param motionBlock the block to delete
*/
public async onDelete(motionBlock: ViewMotionBlock): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete this motion block?');
const content = motionBlock.title;
if (await this.promptService.open(title, content)) {
await this.repo.delete(motionBlock);
}
}
/** /**
* Helper function reset the form and set the default values * Helper function reset the form and set the default values
*/ */

View File

@ -206,6 +206,8 @@
<mat-icon>sort</mat-icon> <mat-icon>sort</mat-icon>
<span translate>Call list</span> <span translate>Call list</span>
</button> </button>
</div>
<div *ngIf="perms.isAllowed('manage') || categories.length">
<button mat-menu-item routerLink="category"> <button mat-menu-item routerLink="category">
<mat-icon>device_hub</mat-icon> <mat-icon>device_hub</mat-icon>
<span translate>Categories</span> <span translate>Categories</span>

View File

@ -0,0 +1,26 @@
# Generated by Django 2.2 on 2019-06-03 09:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("agenda", "0007_list_of_speakers_3")]
operations = [
migrations.AlterModelOptions(
name="item",
options={
"default_permissions": (),
"ordering": ["weight"],
"permissions": (
("can_see", "Can see agenda"),
("can_manage", "Can manage agenda"),
(
"can_see_internal_items",
"Can see internal items and time scheduling of agenda",
),
),
},
)
]

View File

@ -279,6 +279,7 @@ class Item(RESTModelMixin, models.Model):
), ),
) )
unique_together = ("content_type", "object_id") unique_together = ("content_type", "object_id")
ordering = ["weight"]
@property @property
def title_information(self): def title_information(self):

View File

@ -176,20 +176,6 @@ def get_config_variables():
subgroup="General", subgroup="General",
) )
yield ConfigVariable(
name="motions_category_sorting",
default_value="prefix",
input_type="choice",
label="Sort categories by",
choices=(
{"value": "prefix", "display_name": "Prefix"},
{"value": "name", "display_name": "Name"},
),
weight=335,
group="Motions",
subgroup="General",
)
yield ConfigVariable( yield ConfigVariable(
name="motions_motions_sorting", name="motions_motions_sorting",
default_value="identifier", default_value="identifier",

View File

@ -0,0 +1,32 @@
# Generated by Django 2.2 on 2019-06-03 09:32
from django.db import migrations, models
import openslides.utils.models
class Migration(migrations.Migration):
dependencies = [("motions", "0027_motion_block_internal")]
operations = [
migrations.AlterModelOptions(
name="category", options={"default_permissions": (), "ordering": ["weight"]}
),
migrations.AddField(
model_name="category",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE,
related_name="children",
to="motions.Category",
),
),
migrations.AddField(
model_name="category",
name="weight",
field=models.IntegerField(default=10000),
),
]

View File

@ -391,13 +391,14 @@ class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model):
""" """
if initial_increment: if initial_increment:
number += 1 number += 1
identifier = f"{prefix}{self.extend_identifier_number(number)}" identifier = f"{prefix}{Motion.extend_identifier_number(number)}"
while Motion.objects.filter(identifier=identifier).exists(): while Motion.objects.filter(identifier=identifier).exists():
number += 1 number += 1
identifier = f"{prefix}{self.extend_identifier_number(number)}" identifier = f"{prefix}{Motion.extend_identifier_number(number)}"
return number, identifier return number, identifier
def extend_identifier_number(self, number): @classmethod
def extend_identifier_number(cls, number):
""" """
Returns the number used in the set_identifier method with leading Returns the number used in the set_identifier method with leading
zero charaters according to the settings value zero charaters according to the settings value
@ -533,15 +534,6 @@ class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model):
""" """
return self.is_amendment() and self.amendment_paragraphs return self.is_amendment() and self.amendment_paragraphs
def get_amendments_deep(self):
"""
Generator that yields all amendments of this motion including all
amendment decendents.
. """
for amendment in self.amendments.all():
yield amendment
yield from amendment.get_amendments_deep()
def get_paragraph_based_amendments(self): def get_paragraph_based_amendments(self):
""" """
Returns a list of all paragraph-based amendments to this motion Returns a list of all paragraph-based amendments to this motion
@ -553,6 +545,16 @@ class Motion(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model):
) )
) )
@property
def amendment_level(self):
"""
Returns the amount of parent motions.
"""
if self.parent is None:
return 0
else:
return self.parent.amendment_level + 1
class MotionCommentSection(RESTModelMixin, models.Model): class MotionCommentSection(RESTModelMixin, models.Model):
""" """
@ -801,12 +803,39 @@ class Category(RESTModelMixin, models.Model):
Used to build the identifier of a motion. Used to build the identifier of a motion.
""" """
parent = models.ForeignKey(
"self",
on_delete=SET_NULL_AND_AUTOUPDATE,
null=True,
blank=True,
related_name="children",
)
weight = models.IntegerField(default=10000)
class Meta: class Meta:
default_permissions = () default_permissions = ()
ordering = ["prefix"] ordering = ["weight"]
def __str__(self): def __str__(self):
return self.name if self.prefix:
return f"{self.prefix} - {self.name}"
else:
return self.name
@property
def level(self):
"""
Returns the level in the tree of categories. Level 0 means this
item is a root item in the tree. Level 1 indicates that the parent is
a root item, level 2 that the parent's parent is a root item and so on.
Attention! This executes one query for each ancestor of the category.
"""
if self.parent is None:
return 0
else:
return self.parent.level + 1
class MotionBlockManager(models.Manager): class MotionBlockManager(models.Manager):

View File

@ -0,0 +1,277 @@
from collections import defaultdict
from typing import Any, Dict, List, Tuple
from django.conf import settings
from django.db import transaction
from django.db.models import Model, Prefetch
from ..core.config import config
from ..utils.rest_api import ValidationError
from .models import Category, Motion
__all__ = ["numbering"]
def numbering(main_category: Category) -> List[Model]:
"""
Given the _main category_ by params the numbering of all motions in this or
any subcategory is done. The numbering behaves as defined by the the following rules:
- The set of the main category with all child categories are _affected categories_.
- All motions in the affected categories are _affected motions_.
- All affected motions are numbered with respect to 'category_weight' ordering.
- Checks, if parents of all affected amendments, are also affected.
So, all parents of every affected amendment must also be affected. If not,
an error will be returned.
- If a category does not have a prefix, the prefix of the first parent with
one will be taken. This is just checked until the main category is reached.
So, if the main category does not have a prefix, no prefix will be used.
- If a motions should get a new identifier, that already a non-affected motion has,
an error will be returned. It is ensured, that all identifiers generated with
call will be unique.
- Identifier of non-amendments: <A><B><C>
<A>: Categories calculated prefix (see above; note: can be blank)
<B>: '' if blanks are disabled or <A> is blank, else ' '
<C>: Motion counter (unique existing counter for all affected motions)
- Amendments: An amendment will get the following identifier: <A><B><C>
<A>: Parent's _new_ identifier
<B>: '', if blanks are disabled, else ' '
<C>: Amendment prefix
<D>: Amendment counter (counter for amendments of one parent)
- Both counters may be filled with leading zeros according to `Motion.extend_identifier_number`
- On errors, ValidationErrors with appropriate content will be raised.
"""
# If MOTION_IDENTIFIER_WITHOUT_BLANKS is set, don't use blanks when building identifier.
without_blank = (
hasattr(settings, "MOTION_IDENTIFIER_WITHOUT_BLANKS")
and settings.MOTION_IDENTIFIER_WITHOUT_BLANKS
)
# Get child categories (to build affected categories) and precalculate all prefixes.
child_categories = get_child_categories(main_category)
category_prefix_mapping = get_category_prefix_mapping(
main_category, child_categories, without_blank
)
# put together to affected_categories
affected_categories = [main_category]
affected_categories.extend(child_categories)
# Get all affected motions
affected_motions = get_affected_motions(affected_categories)
# The affected_motion_ids is used for a fast lookup later.
affected_motion_ids = set([motion.id for motion in affected_motions])
# Assert, that we do have some motions.
if len(affected_motions) == 0:
raise ValidationError({"detail": "No motions were numbered"})
# To ensure, that amendments will get the _new_ identifier of the parent, the affected
# motions are split in disjoint lists (keep the ordering right) by their " amendment level"
# in the motion (amendment) tree. There are at most len(affected_motions) levels.
# In this step it is also ensures, that every parent of an amendment is an affected motion.
max_amendment_level, amendment_level_mapping = get_amendment_level_mapping(
affected_motions, affected_motion_ids, main_category
)
# Generate new identifiers.
new_identifier_mapping = generate_new_identifiers(
max_amendment_level,
amendment_level_mapping,
category_prefix_mapping,
without_blank,
)
# Check, if all new identifiers are not used in all non-afected motions.
check_new_identifiers_for_conflicts(new_identifier_mapping, affected_motion_ids)
# Change all identifiers
return update_identifiers(affected_motions, new_identifier_mapping)
def get_child_categories(main_category: Category) -> List[Category]:
# -> generate a mapping from a category id to all it's children with respect to `weight`:
category_children_mapping: Dict[int, List[Category]] = defaultdict(list)
# Optimize lookupqueries by prefetching all relations for motions
prefetched_queryset = Category.objects.prefetch_related(
Prefetch(
"motion_set",
queryset=Motion.objects.get_full_queryset()
.prefetch_related("parent")
.order_by("category_weight", "id"),
)
)
for category in prefetched_queryset.exclude(parent=None).order_by("weight").all():
category_children_mapping[category.parent_id].append(category)
# - collect child categories
child_categories = [] # they are ordered like a flat tree would be.
queue = category_children_mapping[main_category.id]
queue.reverse()
while len(queue) > 0:
category = queue.pop()
child_categories.append(category)
children = category_children_mapping[category.id]
if children:
children.reverse()
queue.extend(children)
return child_categories
def get_category_prefix_mapping(
main_category: Category, child_categories: List[Category], without_blank: bool
) -> Dict[int, str]:
# Precalculates all prefixes, e.g. traversing the category tree up, if a category
# does not have a prefix to search for a parent's one. Also the without_blank is
# respected, so the prefixes may will have blanks.
# Add main category as a lookup anchor.
category_prefix_mapping: Dict[int, str] = {}
if not main_category.prefix:
main_category_prefix = ""
elif without_blank:
main_category_prefix = main_category.prefix
else:
main_category_prefix = f"{main_category.prefix} "
category_prefix_mapping[main_category.id] = main_category_prefix
for category in child_categories:
# Update prefix map. It is ensured, that the parent does have a calculated prefix, because
# the child_categories is an ordered flat tree.
if category.prefix:
if without_blank:
prefix = category.prefix
else:
prefix = f"{category.prefix} "
category_prefix_mapping[category.id] = prefix
else:
category_prefix_mapping[category.id] = category_prefix_mapping[
category.parent_id
]
return category_prefix_mapping
def get_affected_motions(affected_categories) -> List[Motion]:
# Affected motions: A list of motions from all categories in the right category order
# and sorted with `category_weight` per category.
affected_motions = []
for category in affected_categories:
affected_motions.extend(
list(category.motion_set.all())
) # The motions should be ordered correctly by the prefetch statement in `get_child_categories`
return affected_motions
def get_amendment_level_mapping(
affected_motions, affected_motion_ids, main_category
) -> Tuple[int, Dict[int, List[Motion]]]:
amendment_level_mapping: Dict[int, List[Motion]] = defaultdict(list)
max_amendment_level = 0
for motion in affected_motions:
level = motion.amendment_level
amendment_level_mapping[level].append(motion)
if level > max_amendment_level:
max_amendment_level = level
if motion.parent_id is not None and motion.parent_id not in affected_motion_ids:
raise ValidationError(
{
"detail": f'Amendment "{motion}" cannot be numbered, because '
f"it's lead motion ({motion.parent}) is not in category "
f"{main_category} or any subcategory."
}
)
return max_amendment_level, amendment_level_mapping
def generate_new_identifiers(
max_amendment_level, amendment_level_mapping, category_prefix_mapping, without_blank
) -> Dict[int, Any]:
# Generate identifiers for all lead motions.
new_identifier_mapping = {}
for i, main_motion in enumerate(amendment_level_mapping[0]):
prefix = category_prefix_mapping[
main_motion.category_id
] # without_blank is precalculated.
number = i + 1
identifier = f"{prefix}{Motion.extend_identifier_number(number)}"
new_identifier_mapping[main_motion.id] = {
"identifier": identifier,
"number": number,
}
# - Generate new identifiers for all amendments. For this, they are travesed by level,
# so the parent's identifier is already set.
amendment_counter: Dict[int, int] = defaultdict(
lambda: 1
) # maps amendment parent ids to their counter values.
for level in range(1, max_amendment_level + 1):
for amendment in amendment_level_mapping[level]:
number = amendment_counter[amendment.parent_id]
amendment_counter[amendment.parent_id] += 1
parent_identifier = new_identifier_mapping[amendment.parent_id][
"identifier"
]
if without_blank:
prefix = f"{parent_identifier}{config['motions_amendments_prefix']}"
else:
prefix = f"{parent_identifier} {config['motions_amendments_prefix']} "
identifier = f"{prefix}{Motion.extend_identifier_number(number)}"
new_identifier_mapping[amendment.id] = {
"identifier": identifier,
"number": number,
}
return new_identifier_mapping
def check_new_identifiers_for_conflicts(
new_identifier_mapping, affected_motion_ids
) -> None:
all_new_identifiers = [
entry["identifier"] for entry in new_identifier_mapping.values()
]
# Check, if any new identifier exists in any non-affected motion
conflicting_motions = Motion.objects.exclude(id__in=affected_motion_ids).filter(
identifier__in=all_new_identifiers
)
if conflicting_motions.exists():
# We do have a conflict. Build a nice error message.
conflicting_motion = conflicting_motions.first()
if conflicting_motion.category:
error_message = (
"Numbering aborted because the motion identifier "
f'"{conflicting_motion.identifier}" already exists in '
f"category {conflicting_motion.category}."
)
else:
error_message = (
"Numbering aborted because the motion identifier "
f'"{conflicting_motion.identifier}" already exists.'
)
raise ValidationError({"detail": error_message})
def update_identifiers(affected_motions, new_identifier_mapping) -> List[Model]:
# Acutally update the identifiers now.
with transaction.atomic():
changed_instances = []
# Remove old identifiers, to avoid conflicts within the affected motions
for motion in affected_motions:
motion.identifier = None
# This line is to skip agenda item and list of speakers autoupdate.
# See agenda/signals.py.
motion.set_skip_autoupdate_agenda_item_and_list_of_speakers()
motion.save(skip_autoupdate=True)
# Set the indetifier
for motion in affected_motions:
motion.identifier = new_identifier_mapping[motion.id]["identifier"]
motion.identifier_number = new_identifier_mapping[motion.id]["number"]
motion.set_skip_autoupdate_agenda_item_and_list_of_speakers()
motion.save(skip_autoupdate=True)
changed_instances.append(motion)
if motion.agenda_item:
changed_instances.append(motion.agenda_item)
changed_instances.append(motion.list_of_speakers)
return changed_instances

View File

@ -60,7 +60,8 @@ class CategorySerializer(ModelSerializer):
class Meta: class Meta:
model = Category model = Category
fields = ("id", "name", "prefix") fields = ("id", "name", "prefix", "parent", "weight", "level")
read_only_fields = ("parent", "weight")
class MotionBlockSerializer(ModelSerializer): class MotionBlockSerializer(ModelSerializer):

View File

@ -1,11 +1,9 @@
import re
from typing import List, Set from typing import List, Set
import jsonschema import jsonschema
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction from django.db import transaction
from django.db.models.deletion import ProtectedError from django.db.models.deletion import ProtectedError
from django.http.request import QueryDict from django.http.request import QueryDict
from rest_framework import status from rest_framework import status
@ -50,6 +48,7 @@ from .models import (
Submitter, Submitter,
Workflow, Workflow,
) )
from .numbering import numbering
from .serializers import MotionPollSerializer, StateSerializer from .serializers import MotionPollSerializer, StateSerializer
@ -323,7 +322,7 @@ class MotionViewSet(TreeSortMixin, ModelViewSet):
def sort(self, request): def sort(self, request):
""" """
Sorts all motions represented in a tree of ids. The request data should be a list (the root) Sorts all motions represented in a tree of ids. The request data should be a list (the root)
of all main agenda items. Each node is a dict with an id and optional children: of all motions. Each node is a dict with an id and optional children:
{ {
id: <the id> id: <the id>
children: [ children: [
@ -1296,7 +1295,7 @@ class StatuteParagraphViewSet(ModelViewSet):
return result return result
class CategoryViewSet(ModelViewSet): class CategoryViewSet(TreeSortMixin, ModelViewSet):
""" """
API endpoint for categories. API endpoint for categories.
@ -1311,16 +1310,15 @@ class CategoryViewSet(ModelViewSet):
""" """
Returns True if the user has required permissions. Returns True if the user has required permissions.
""" """
if self.action in ("list", "retrieve"): if self.action in ("list", "retrieve", "metadata"):
result = self.get_access_permissions().check_permissions(self.request.user) result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == "metadata":
result = has_perm(self.request.user, "motions.can_see")
elif self.action in ( elif self.action in (
"create", "create",
"partial_update", "partial_update",
"update", "update",
"destroy", "destroy",
"sort", "sort_categories",
"sort_motions",
"numbering", "numbering",
): ):
result = has_perm(self.request.user, "motions.can_see") and has_perm( result = has_perm(self.request.user, "motions.can_see") and has_perm(
@ -1330,9 +1328,25 @@ class CategoryViewSet(ModelViewSet):
result = False result = False
return result return result
@list_route(methods=["post"])
def sort_categories(self, request):
"""
Sorts all categoreis represented in a tree of ids. The request data should be
a list (the root) of all categories. Each node is a dict with an id and optional
children:
{
id: <the id>
children: [
<children, optional>
]
}
Every id has to be given.
"""
return self.sort_tree(request, Category, "weight", "parent_id")
@detail_route(methods=["post"]) @detail_route(methods=["post"])
@transaction.atomic @transaction.atomic
def sort(self, request, pk=None): def sort_motions(self, request, pk=None):
""" """
Endpoint to sort all motions in the category. Endpoint to sort all motions in the category.
@ -1379,126 +1393,22 @@ class CategoryViewSet(ModelViewSet):
@detail_route(methods=["post"]) @detail_route(methods=["post"])
def numbering(self, request, pk=None): def numbering(self, request, pk=None):
""" """
Special view endpoint to number all motions in this category. Special view endpoint to number all motions in this category and all
subcategories. Only managers can use this view. For the actual numbering,
see `numbering.py`.
Only managers can use this view. Request args: None (implicit: the main category via URL)
Send POST {'motions': [<list of motion ids>]} to sort the given
motions in a special order. Ids of motions which do not belong to
the category are just ignored. Send just POST {} to sort all
motions in the category ordered by their category weight.
Amendments will get a new identifier prefix if the old prefix matches
the old parent motion identifier.
""" """
category = self.get_object() main_category = self.get_object()
number = 0 changed_instances = numbering(main_category)
instances = [] inform_changed_data(
changed_instances, information=["Number set"], user_id=request.user.pk
# If MOTION_IDENTIFIER_WITHOUT_BLANKS is set, don't use blanks when building identifier. )
without_blank = ( return Response(
hasattr(settings, "MOTION_IDENTIFIER_WITHOUT_BLANKS") {
and settings.MOTION_IDENTIFIER_WITHOUT_BLANKS "detail": f"All motions in category {main_category} numbered successfully."
}
) )
# Prepare ordered list of motions.
if not category.prefix:
category_prefix = ""
elif without_blank:
category_prefix = category.prefix
else:
category_prefix = f"{category.prefix} "
# get motions
motions = category.motion_set.order_by("category_weight")
motion_list = request.data.get("motions")
if motion_list:
motion_dict = {}
for motion in motions.filter(id__in=motion_list):
motion_dict[motion.pk] = motion
motions = [motion_dict[pk] for pk in motion_list]
# Change identifiers.
error_message = None
try:
with transaction.atomic():
# Collect old and new identifiers.
motions_to_be_sorted = []
for motion in motions:
prefix = category_prefix
# Change prefix for amendments
if motion.is_amendment():
parent_identifier = motion.parent.identifier or ""
if without_blank:
prefix = f"{parent_identifier}{config['motions_amendments_prefix']}"
else:
prefix = f"{parent_identifier} {config['motions_amendments_prefix']} "
else:
number += 1
new_identifier = (
f"{prefix}{motion.extend_identifier_number(number)}"
)
motions_to_be_sorted.append(
{
"motion": motion,
"old_identifier": motion.identifier,
"new_identifier": new_identifier,
"number": number,
}
)
# Remove old identifiers
for motion in motions:
motion.identifier = None
# This line is to skip agenda item and list of speakers autoupdate.
# See agenda/signals.py.
motion.set_skip_autoupdate_agenda_item_and_list_of_speakers()
motion.save(skip_autoupdate=True)
# Set new identifers and change identifiers of amendments.
for obj in motions_to_be_sorted:
if Motion.objects.filter(identifier=obj["new_identifier"]).exists():
# Set the error message and let the code run into an IntegrityError
new_identifier = obj["new_identifier"]
error_message = (
f'Numbering aborted because the motion identifier "{new_identifier}" '
"already exists outside of this category."
)
motion = obj["motion"]
motion.identifier = obj["new_identifier"]
motion.identifier_number = obj["number"]
motion.save(skip_autoupdate=True)
instances.append(motion)
instances.append(motion.agenda_item)
# Change identifiers of amendments.
for child in motion.get_amendments_deep():
if child.identifier and child.identifier.startswith(
obj["old_identifier"]
):
child.identifier = re.sub(
obj["old_identifier"],
obj["new_identifier"],
child.identifier,
count=1,
)
# This line is to skip agenda item and list of speakers autoupdate.
# See agenda/signals.py.
motion.set_skip_autoupdate_agenda_item_and_list_of_speakers()
child.save(skip_autoupdate=True)
instances.append(child)
instances.append(child.agenda_item)
except IntegrityError:
if error_message is None:
error_message = "Error: At least one identifier of this category does already exist in another category."
response = Response({"detail": error_message}, status=400)
else:
inform_changed_data(
instances, information=["Number set"], user_id=request.user.pk
)
message = f"All motions in category {category} numbered " "successfully."
response = Response({"detail": message})
return response
class MotionBlockViewSet(ModelViewSet): class MotionBlockViewSet(ModelViewSet):

View File

@ -1,6 +1,7 @@
import json import json
import pytest import pytest
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
@ -1764,107 +1765,168 @@ class UpdateMotionPoll(TestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
class NumberMotionsInCategory(TestCase): class NumberMotionsInCategories(TestCase):
""" """
Tests numbering motions in a category. Tests numbering motions in categories.
Testdata. All names (and prefixes) are prefixed with "test_". The
ordering is ensured with "category_weight".
Category tree (with motions M and amendments A):
A-A
<M1>
<M2-A2>
B
<M2-A1>
<M3>
C-C
<M2>
<M2-A1-A1>
""" """
def setUp(self): def setUp(self):
self.client = APIClient() self.client = APIClient()
self.client.login(username="admin", password="admin") self.client.login(username="admin", password="admin")
self.category = Category.objects.create( self.A = Category.objects.create(name="test_A", prefix="test_A")
name="test_cateogory_name_zah6Ahd4Ifofaeree6ai", self.B = Category.objects.create(name="test_B", parent=self.A)
prefix="test_prefix_ahz6tho2mooH8", self.C = Category.objects.create(name="test_C", prefix="test_C", parent=self.A)
)
self.motion = Motion( self.M1 = Motion(
title="test_title_Eeha8Haf6peulu8ooc0z", title="test_title_Eeha8Haf6peulu8ooc0z",
text="test_text_faghaZoov9ooV4Acaquk", text="test_text_faghaZoov9ooV4Acaquk",
category=self.category, category=self.A,
category_weight=1,
) )
self.motion.save() self.M1.save()
self.motion.identifier = "" self.M1.identifier = ""
self.motion.save() self.M1.save()
self.motion_2 = Motion(
self.M2 = Motion(
title="test_title_kuheih2eja2Saeshusha", title="test_title_kuheih2eja2Saeshusha",
text="test_text_Ha5ShaeraeSuthooP2Bu", text="test_text_Ha5ShaeraeSuthooP2Bu",
category=self.category, category=self.C,
category_weight=1,
) )
self.motion_2.save() self.M2.save()
self.motion_2.identifier = "" self.M2.identifier = ""
self.motion_2.save() self.M2.save()
self.M2_A1 = Motion(
title="test_title_av3ejIJvwon3jvnNVaie",
text="test_text_FJPiejfwdcoiwjvijao1",
category=self.B,
category_weight=1,
parent=self.M2,
)
self.M2_A1.save()
self.M2_A1.identifier = ""
self.M2_A1.save()
self.M2_A1_A1 = Motion(
title="test_title_ejvhwoxngixoqkxy.qfi",
text="test_text_euh2gfaiaqfu3.f(3hgf",
category=self.C,
category_weight=2,
parent=self.M2_A1,
)
self.M2_A1_A1.save()
self.M2_A1_A1.identifier = ""
self.M2_A1_A1.save()
self.M2_A2 = Motion(
title="test_title_xoerFiwebbpiUEeuvxMa",
text="test_text_zbwZWPefiisdISfwLKqN",
category=self.A,
category_weight=2,
parent=self.M2,
)
self.M2_A2.save()
self.M2_A2.identifier = ""
self.M2_A2.save()
self.M3 = Motion(
title="test_title_VWIVeiNVenudn(23J92§",
text="test_text_VEDno328hn8/TBbScVEb",
category=self.B,
category_weight=2,
)
self.M3.save()
self.M3.identifier = ""
self.M3.save()
def test_numbering(self): def test_numbering(self):
response = self.client.post( response = self.client.post(reverse("category-numbering", args=[self.A.pk]))
reverse("category-numbering", args=[self.category.pk])
)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Motion.objects.get(pk=self.M1.pk).identifier, "test_A 1")
self.assertEqual(Motion.objects.get(pk=self.M3.pk).identifier, "test_A 2")
self.assertEqual(Motion.objects.get(pk=self.M2.pk).identifier, "test_C 3")
self.assertEqual( self.assertEqual(
response.data, Motion.objects.get(pk=self.M2_A1.pk).identifier, "test_C 3 - 2"
{
"detail": "All motions in category test_cateogory_name_zah6Ahd4Ifofaeree6ai numbered successfully."
},
) )
self.assertEqual( self.assertEqual(
Motion.objects.get(pk=self.motion.pk).identifier, Motion.objects.get(pk=self.M2_A1_A1.pk).identifier, "test_C 3 - 2 - 1"
"test_prefix_ahz6tho2mooH8 1",
) )
self.assertEqual( self.assertEqual(
Motion.objects.get(pk=self.motion_2.pk).identifier, Motion.objects.get(pk=self.M2_A2.pk).identifier, "test_C 3 - 1"
"test_prefix_ahz6tho2mooH8 2",
) )
def test_numbering_existing_identifier(self): def test_with_blanks(self):
self.motion_2.identifier = "test_prefix_ahz6tho2mooH8 1" config["motions_amendments_prefix"] = "-X"
self.motion_2.save() settings.MOTION_IDENTIFIER_WITHOUT_BLANKS = True
response = self.client.post( settings.MOTION_IDENTIFIER_MIN_DIGITS = 3
reverse("category-numbering", args=[self.category.pk]) response = self.client.post(reverse("category-numbering", args=[self.A.pk]))
) settings.MOTION_IDENTIFIER_WITHOUT_BLANKS = False
settings.MOTION_IDENTIFIER_MIN_DIGITS = 1
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Motion.objects.get(pk=self.M1.pk).identifier, "test_A001")
self.assertEqual(Motion.objects.get(pk=self.M3.pk).identifier, "test_A002")
self.assertEqual(Motion.objects.get(pk=self.M2.pk).identifier, "test_C003")
self.assertEqual( self.assertEqual(
response.data, Motion.objects.get(pk=self.M2_A1.pk).identifier, "test_C003-X002"
{
"detail": "All motions in category test_cateogory_name_zah6Ahd4Ifofaeree6ai numbered successfully."
},
) )
self.assertEqual( self.assertEqual(
Motion.objects.get(pk=self.motion.pk).identifier, Motion.objects.get(pk=self.M2_A1_A1.pk).identifier, "test_C003-X002-X001"
"test_prefix_ahz6tho2mooH8 1",
) )
self.assertEqual( self.assertEqual(
Motion.objects.get(pk=self.motion_2.pk).identifier, Motion.objects.get(pk=self.M2_A2.pk).identifier, "test_C003-X001"
"test_prefix_ahz6tho2mooH8 2",
) )
def test_numbering_with_given_order(self): def test_existing_identifier_no_category(self):
self.motion_3 = Motion( conflicting_motion = Motion(
title="test_title_eeb0kua5ciike4su2auJ", title="test_title_al2=2k21fjv1lsck3ehlWExg",
text="test_text_ahshuGhaew3eim8yoht7", text="test_text_3omvpEhnfg082ejplk1m",
category=self.category,
) )
self.motion_3.save() conflicting_motion.save()
self.motion_3.identifier = "" conflicting_motion.identifier = "test_C 3 - 2 - 1"
self.motion_3.save() conflicting_motion.save()
response = self.client.post( response = self.client.post(reverse("category-numbering", args=[self.A.pk]))
reverse("category-numbering", args=[self.category.pk]), self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
{"motions": [3, 2]}, self.assertTrue("test_C 3 - 2 - 1" in response.data["detail"])
format="json",
def test_existing_identifier_with_category(self):
conflicting_category = Category.objects.create(
name="test_name_hpsodhakvjdbvkblwfjr"
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) conflicting_motion = Motion(
self.assertEqual( title="test_title_al2=2k21fjv1lsck3ehlWExg",
response.data, text="test_text_3omvpEhnfg082ejplk1m",
{ category=conflicting_category,
"detail": "All motions in category test_cateogory_name_zah6Ahd4Ifofaeree6ai numbered successfully."
},
)
self.assertEqual(Motion.objects.get(pk=self.motion.pk).identifier, None)
self.assertEqual(
Motion.objects.get(pk=self.motion_2.pk).identifier,
"test_prefix_ahz6tho2mooH8 2",
)
self.assertEqual(
Motion.objects.get(pk=self.motion_3.pk).identifier,
"test_prefix_ahz6tho2mooH8 1",
) )
conflicting_motion.save()
conflicting_motion.identifier = "test_C 3 - 2 - 1"
conflicting_motion.save()
response = self.client.post(reverse("category-numbering", args=[self.A.pk]))
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertTrue("test_C 3 - 2 - 1" in response.data["detail"])
self.assertTrue(conflicting_category.name in response.data["detail"])
def test_incomplete_amendment_tree(self):
self.M2_A1.category = None
self.M2_A1.save()
response = self.client.post(reverse("category-numbering", args=[self.A.pk]))
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertTrue(self.M2_A1_A1.title in response.data["detail"])
self.assertTrue(self.M2_A1.title in response.data["detail"])
class TestMotionBlock(TestCase): class TestMotionBlock(TestCase):