Merge pull request #4756 from FinnStutzenstein/subcategories
Subcategories
This commit is contained in:
commit
35c8dc97f5
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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({
|
||||||
|
@ -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 {}
|
||||||
|
@ -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>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
});
|
});
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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;
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 }} </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>
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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`.
|
||||||
*
|
*
|
@ -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 }} </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>
|
|
@ -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>
|
||||||
|
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
@ -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>
|
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
@ -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>
|
||||||
|
26
openslides/agenda/migrations/0008_default_ordering_item.py
Normal file
26
openslides/agenda/migrations/0008_default_ordering_item.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
@ -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):
|
||||||
|
@ -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",
|
||||||
|
32
openslides/motions/migrations/0028_subcategories.py
Normal file
32
openslides/motions/migrations/0028_subcategories.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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):
|
||||||
|
277
openslides/motions/numbering.py
Normal file
277
openslides/motions/numbering.py
Normal 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
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
Loading…
Reference in New Issue
Block a user