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].
|
||||
- Added timestampes for motions [#4134].
|
||||
- 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:
|
||||
- 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 { Category } from 'app/shared/models/motions/category';
|
||||
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 { DataStoreService } from '../../core-services/data-store.service';
|
||||
import { HttpService } from '../../core-services/http.service';
|
||||
import { ViewCategory, CategoryTitleInformation } from 'app/site/motions/models/view-category';
|
||||
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
|
||||
|
||||
type SortProperty = 'prefix' | 'name';
|
||||
import { Motion } from 'app/shared/models/motions/motion';
|
||||
import { TreeIdNode } from 'app/core/ui-services/tree.service';
|
||||
|
||||
/**
|
||||
* Repository Services for Categories
|
||||
@ -28,8 +27,6 @@ type SortProperty = 'prefix' | 'name';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CategoryRepositoryService extends BaseRepository<ViewCategory, Category, CategoryTitleInformation> {
|
||||
private sortProperty: SortProperty;
|
||||
|
||||
/**
|
||||
* Creates a CategoryRepository
|
||||
* Converts existing and incoming category to ViewCategories
|
||||
@ -48,16 +45,11 @@ export class CategoryRepositoryService extends BaseRepository<ViewCategory, Cate
|
||||
mapperService: CollectionStringMapperService,
|
||||
viewModelStoreService: ViewModelStoreService,
|
||||
translate: TranslateService,
|
||||
private httpService: HttpService,
|
||||
private configService: ConfigService
|
||||
private httpService: HttpService
|
||||
) {
|
||||
super(DS, dataSend, mapperService, viewModelStoreService, translate, Category);
|
||||
super(DS, dataSend, mapperService, viewModelStoreService, translate, Category, [Category]);
|
||||
|
||||
this.sortProperty = this.configService.instant('motions_category_sorting');
|
||||
this.configService.get<SortProperty>('motions_category_sorting').subscribe(conf => {
|
||||
this.sortProperty = conf;
|
||||
this.setConfigSortFn();
|
||||
});
|
||||
this.setSortFunction((a, b) => a.weight - b.weight);
|
||||
}
|
||||
|
||||
public getTitle = (titleInformation: CategoryTitleInformation) => {
|
||||
@ -78,10 +70,9 @@ export class CategoryRepositoryService extends BaseRepository<ViewCategory, Cate
|
||||
* Updates a categories numbering.
|
||||
*
|
||||
* @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> {
|
||||
await this.httpService.post(`/rest/motions/category/${category.id}/numbering/`, { motions: motionIds });
|
||||
public async numberMotionsInCategory(category: ViewCategory): Promise<void> {
|
||||
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
|
||||
*/
|
||||
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 {
|
||||
this.setSortFunction((a: ViewCategory, b: ViewCategory) => {
|
||||
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) {
|
||||
return 1;
|
||||
} else if (b.prefix) {
|
||||
return -1;
|
||||
} else {
|
||||
return this.languageCollator.compare(a.name, b.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
public async sortCategories(data: TreeIdNode[]): Promise<void> {
|
||||
await this.httpService.post('/rest/motions/category/sort_categories/', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the DataStore by Motions and returns the amount of motions in the given category
|
||||
*
|
||||
* @param category the category
|
||||
* @returns the number of motions inside the category
|
||||
*/
|
||||
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 name: string;
|
||||
public prefix: string;
|
||||
public parent_id?: number;
|
||||
public weight: number;
|
||||
public level: number;
|
||||
|
||||
public constructor(input?: any) {
|
||||
super(Category.COLLECTIONSTRING, input);
|
||||
|
@ -18,10 +18,16 @@ export interface CategoryTitleInformation {
|
||||
export class ViewCategory extends BaseViewModel<Category> implements CategoryTitleInformation, Searchable {
|
||||
public static COLLECTIONSTRING = Category.COLLECTIONSTRING;
|
||||
|
||||
private _parent?: ViewCategory;
|
||||
|
||||
public get category(): Category {
|
||||
return this._model;
|
||||
}
|
||||
|
||||
public get parent(): ViewCategory | null {
|
||||
return this._parent;
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return this.category.name;
|
||||
}
|
||||
@ -30,26 +36,39 @@ export class ViewCategory extends BaseViewModel<Category> implements CategoryTit
|
||||
return this.category.prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Where is this used? Try to avoid this.
|
||||
*/
|
||||
public set prefix(prefix: string) {
|
||||
this._model.prefix = prefix;
|
||||
public get weight(): number {
|
||||
return this.category.weight;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}*/
|
||||
|
||||
public get prefixedName(): string {
|
||||
return this.prefix ? this.prefix + ' - ' + this.name : this.name;
|
||||
}
|
||||
|
||||
public constructor(category: Category) {
|
||||
public constructor(category: Category, parent?: ViewCategory) {
|
||||
super(Category.COLLECTIONSTRING, category);
|
||||
this._parent = parent;
|
||||
}
|
||||
|
||||
public formatForSearch(): SearchRepresentation {
|
||||
@ -64,5 +83,9 @@ export class ViewCategory extends BaseViewModel<Category> implements CategoryTit
|
||||
* Updates the local objects if required
|
||||
* @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 { Routes, RouterModule } from '@angular/router';
|
||||
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 { CategoryDetailComponent } from './components/category-detail/category-detail.component';
|
||||
import { CategoriesSortComponent } from './components/categories-sort/categories-sort.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ 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({
|
||||
|
@ -4,10 +4,17 @@ import { CommonModule } from '@angular/common';
|
||||
import { CategoryRoutingModule } from './category-routing.module';
|
||||
import { SharedModule } from 'app/shared/shared.module';
|
||||
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({
|
||||
declarations: [CategoryListComponent, CategorySortComponent],
|
||||
declarations: [
|
||||
CategoryListComponent,
|
||||
CategoryDetailComponent,
|
||||
CategoryMotionsSortComponent,
|
||||
CategoriesSortComponent
|
||||
],
|
||||
imports: [CommonModule, CategoryRoutingModule, SharedModule]
|
||||
})
|
||||
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 { CategorySortComponent } from './category-sort.component';
|
||||
import { E2EImportsModule } from 'e2e-imports.module';
|
||||
import { CategoriesSortComponent } from './categories-sort.component';
|
||||
|
||||
describe('CategorySortComponent', () => {
|
||||
let component: CategorySortComponent;
|
||||
let fixture: ComponentFixture<CategorySortComponent>;
|
||||
describe('CategoriesSortComponent', () => {
|
||||
let component: CategoriesSortComponent;
|
||||
let fixture: ComponentFixture<CategoriesSortComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [E2EImportsModule],
|
||||
declarations: [CategorySortComponent]
|
||||
declarations: [CategoriesSortComponent]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CategorySortComponent);
|
||||
fixture = TestBed.createComponent(CategoriesSortComponent);
|
||||
component = fixture.componentInstance;
|
||||
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 -->
|
||||
<div class="title-slot">
|
||||
<h2 translate>Categories</h2>
|
||||
</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>
|
||||
|
||||
<div class="spacer-top-20"></div>
|
||||
<mat-card class="os-card" *ngIf="categoryToCreate">
|
||||
<mat-card-title translate>Create new category</mat-card-title>
|
||||
<!-- Creating a new motion block -->
|
||||
<mat-card class="os-card" *ngIf="isCreatingNewCategory">
|
||||
<mat-card-title translate>New motion block</mat-card-title>
|
||||
<mat-card-content>
|
||||
<form
|
||||
class="full-width-form flex-spaced"
|
||||
id="createForm"
|
||||
[formGroup]="createForm"
|
||||
(keydown)="keyDownFunction($event)"
|
||||
>
|
||||
<!-- prefix input -->
|
||||
<mat-form-field class="short-input">
|
||||
<input formControlName="prefix" matInput placeholder="{{ 'Prefix' | translate }}" />
|
||||
</mat-form-field>
|
||||
<!-- name input -->
|
||||
<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>
|
||||
<form [formGroup]="createForm" (keydown)="onKeyDown($event)">
|
||||
<!-- Prefix -->
|
||||
<p>
|
||||
<mat-form-field>
|
||||
<input formControlName="prefix" matInput placeholder="{{ 'Prefix' | translate }}" />
|
||||
</mat-form-field>
|
||||
</p>
|
||||
|
||||
<!-- Name -->
|
||||
<p>
|
||||
<mat-form-field>
|
||||
<input formControlName="name" matInput placeholder="{{ 'Name' | translate }}" required />
|
||||
<mat-error *ngIf="!createForm.controls.name.valuid" translate>
|
||||
A name is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</p>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
|
||||
<!-- Save and Cancel buttons -->
|
||||
<mat-card-actions>
|
||||
<button mat-button (click)="onCreateButton()">
|
||||
<button mat-button [disabled]="!createForm.valid" (click)="onCreate()">
|
||||
<span translate>Save</span>
|
||||
</button>
|
||||
<button mat-button (click)="categoryToCreate = null">
|
||||
<button mat-button (click)="onCancel()">
|
||||
<span translate>Cancel</span>
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
|
||||
<!-- Table -->
|
||||
<mat-card class="os-card">
|
||||
<mat-accordion displayMode="flat">
|
||||
<ng-container *ngFor="let category of categories">
|
||||
<mat-expansion-panel
|
||||
class="os-card-expandion-panel"
|
||||
(opened)="setValues(category)"
|
||||
[expanded]="editId === category.id"
|
||||
(closed)="onCancelButton()"
|
||||
>
|
||||
<!-- Header shows Prefix and name -->
|
||||
<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>
|
||||
<table class="os-headed-listview-table on-transition-fade" mat-table [dataSource]="dataSource">
|
||||
<!-- title column -->
|
||||
<ng-container matColumnDef="title">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span translate>Title</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let category">
|
||||
<div [style.margin-left]="getMargin(category)">{{ category.prefixedName }}</div>
|
||||
</mat-cell>
|
||||
</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-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 {
|
||||
display: grid;
|
||||
grid-template-rows: auto;
|
||||
grid-template-columns: 75% 25%;
|
||||
width: 100%;
|
||||
@import '~assets/styles/tables.scss';
|
||||
|
||||
> div {
|
||||
grid-row-start: 1;
|
||||
grid-row-end: span 1;
|
||||
grid-column-end: span 2;
|
||||
.os-headed-listview-table {
|
||||
// Title
|
||||
.mat-column-title {
|
||||
flex: 9 0 0;
|
||||
}
|
||||
|
||||
.header-name {
|
||||
grid-column-start: 1;
|
||||
color: lightslategray;
|
||||
// Amount
|
||||
.mat-column-amount {
|
||||
flex: 1 0 60px;
|
||||
}
|
||||
|
||||
.header-size {
|
||||
grid-column-start: 2;
|
||||
// Menu
|
||||
.mat-column-menu {
|
||||
flex: 0 0 40px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-expansion-panel {
|
||||
max-width: 770px;
|
||||
margin: auto;
|
||||
::ng-deep .mat-form-field {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.full-width-form {
|
||||
display: flex;
|
||||
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;
|
||||
.mat-cell > div {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CategoryListComponent } from './category-list.component';
|
||||
import { E2EImportsModule } from 'e2e-imports.module';
|
||||
import { CategoryListComponent } from './category-list.component';
|
||||
|
||||
describe('CategoryListComponent', () => {
|
||||
let component: CategoryListComponent;
|
||||
|
@ -1,226 +1,156 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
|
||||
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 { 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 { ViewMotion } from 'app/site/motions/models/view-motion';
|
||||
|
||||
/**
|
||||
* List view for the categories.
|
||||
* Table for categories
|
||||
*/
|
||||
@Component({
|
||||
selector: 'os-category-list',
|
||||
templateUrl: './category-list.component.html',
|
||||
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
|
||||
*/
|
||||
public categoryToCreate: Category | null;
|
||||
|
||||
/**
|
||||
* Determine which category is opened
|
||||
*/
|
||||
public editId: number | null;
|
||||
|
||||
/**
|
||||
* Source of the data
|
||||
*/
|
||||
public categories: ViewCategory[];
|
||||
|
||||
/**
|
||||
* For new categories
|
||||
* Holds the create form
|
||||
*/
|
||||
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
|
||||
* @param titleService
|
||||
* @param translate
|
||||
* @param matSnackBar
|
||||
* @param route
|
||||
* @param storage
|
||||
* @param repo
|
||||
* @param formBuilder
|
||||
* @param promptService
|
||||
* @param operator
|
||||
*/
|
||||
public constructor(
|
||||
titleService: Title,
|
||||
protected translate: TranslateService, // protected required for ng-translate-extract
|
||||
translate: TranslateService,
|
||||
matSnackBar: MatSnackBar,
|
||||
route: ActivatedRoute,
|
||||
storage: StorageService,
|
||||
private repo: CategoryRepositoryService,
|
||||
private motionRepo: MotionRepositoryService,
|
||||
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({
|
||||
prefix: [''],
|
||||
name: ['', Validators.required]
|
||||
});
|
||||
|
||||
this.updateForm = this.formBuilder.group({
|
||||
prefix: [''],
|
||||
name: ['', Validators.required]
|
||||
name: ['', Validators.required],
|
||||
parent_id: ['']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Event on key-down in form. Submits the current form if the 'enter' button is pressed
|
||||
*
|
||||
* @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
|
||||
* Observe the agendaItems for changes.
|
||||
*/
|
||||
public ngOnInit(): void {
|
||||
super.setTitle('Categories');
|
||||
this.repo.getViewModelListObservable().subscribe(newViewCategories => {
|
||||
this.categories = newViewCategories;
|
||||
});
|
||||
this.initTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (!this.categoryToCreate) {
|
||||
this.categoryToCreate = new Category();
|
||||
if (!this.isCreatingNewCategory) {
|
||||
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) {
|
||||
const cat: Partial<Category> = { name: this.createForm.get('name').value };
|
||||
if (this.createForm.get('prefix').value) {
|
||||
cat.prefix = this.createForm.get('prefix').value;
|
||||
try {
|
||||
this.repo.create(this.createForm.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
|
||||
* @param viewCategory individual cat
|
||||
* Cancels the current form action
|
||||
*/
|
||||
public onEditButton(viewCategory: ViewCategory): void {
|
||||
this.editId = viewCategory.id;
|
||||
this.updateForm.reset();
|
||||
this.updateForm.patchValue({
|
||||
prefix: viewCategory.category.prefix,
|
||||
name: viewCategory.name
|
||||
});
|
||||
public onCancel(): void {
|
||||
this.isCreatingNewCategory = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a category
|
||||
* 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
|
||||
});
|
||||
public getMargin(category: ViewCategory): string {
|
||||
return `${category.level * 20}px`;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
@Component({
|
||||
selector: 'os-category-sort',
|
||||
templateUrl: './category-sort.component.html',
|
||||
styleUrls: ['./category-sort.component.scss']
|
||||
selector: 'os-category-motions-sort',
|
||||
templateUrl: './category-motions-sort.component.html',
|
||||
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
|
||||
*/
|
||||
@ -68,16 +68,6 @@ export class CategorySortComponent extends BaseViewComponent implements OnInit,
|
||||
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
|
||||
*/
|
||||
@ -140,24 +130,6 @@ export class CategorySortComponent extends BaseViewComponent implements OnInit,
|
||||
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`.
|
||||
*
|
@ -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">
|
||||
<mat-header-cell *matHeaderCellDef></mat-header-cell>
|
||||
<mat-cell *matCellDef="let motion">
|
||||
<a [routerLink]="getMotionLink(motion)"></a>
|
||||
<a [routerLink]="motion.getDetailStateURL()"></a>
|
||||
</mat-cell>
|
||||
</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
|
||||
*/
|
||||
|
@ -13,7 +13,7 @@
|
||||
<mat-form-field>
|
||||
<input formControlName="title" matInput placeholder="{{ 'Title' | translate }}" required />
|
||||
<mat-error *ngIf="createBlockForm.get('title').hasError('required')" translate>
|
||||
A name is required
|
||||
A title is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</p>
|
||||
@ -86,23 +86,6 @@
|
||||
</mat-cell>
|
||||
</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 -->
|
||||
<ng-container matColumnDef="anchor">
|
||||
<mat-header-cell *matHeaderCellDef></mat-header-cell>
|
||||
@ -115,13 +98,3 @@
|
||||
<mat-row *matRowDef="let row; columns: getColumnDefinition()"> </mat-row>
|
||||
</table>
|
||||
</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 { MotionBlockSortService } from 'app/site/motions/services/motion-block-sort.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 { ViewItem } from 'app/site/agenda/models/view-item';
|
||||
import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block';
|
||||
@ -89,7 +88,6 @@ export class MotionBlockListComponent
|
||||
private repo: MotionBlockRepositoryService,
|
||||
private agendaRepo: ItemRepositoryService,
|
||||
private formBuilder: FormBuilder,
|
||||
private promptService: PromptService,
|
||||
private itemRepo: ItemRepositoryService,
|
||||
private operator: OperatorService,
|
||||
sortService: MotionBlockSortService
|
||||
@ -124,9 +122,6 @@ export class MotionBlockListComponent
|
||||
if (this.operator.hasPerms('core.can_manage_projector')) {
|
||||
columns = ['projector'].concat(columns);
|
||||
}
|
||||
if (this.operator.hasPerms('motions.can_manage')) {
|
||||
columns = columns.concat(['menu']);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
@ -140,19 +135,6 @@ export class MotionBlockListComponent
|
||||
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
|
||||
*/
|
||||
|
@ -206,6 +206,8 @@
|
||||
<mat-icon>sort</mat-icon>
|
||||
<span translate>Call list</span>
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="perms.isAllowed('manage') || categories.length">
|
||||
<button mat-menu-item routerLink="category">
|
||||
<mat-icon>device_hub</mat-icon>
|
||||
<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")
|
||||
ordering = ["weight"]
|
||||
|
||||
@property
|
||||
def title_information(self):
|
||||
|
@ -176,20 +176,6 @@ def get_config_variables():
|
||||
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(
|
||||
name="motions_motions_sorting",
|
||||
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:
|
||||
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():
|
||||
number += 1
|
||||
identifier = f"{prefix}{self.extend_identifier_number(number)}"
|
||||
identifier = f"{prefix}{Motion.extend_identifier_number(number)}"
|
||||
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
|
||||
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
|
||||
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
@ -801,12 +803,39 @@ class Category(RESTModelMixin, models.Model):
|
||||
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:
|
||||
default_permissions = ()
|
||||
ordering = ["prefix"]
|
||||
ordering = ["weight"]
|
||||
|
||||
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):
|
||||
|
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:
|
||||
model = Category
|
||||
fields = ("id", "name", "prefix")
|
||||
fields = ("id", "name", "prefix", "parent", "weight", "level")
|
||||
read_only_fields = ("parent", "weight")
|
||||
|
||||
|
||||
class MotionBlockSerializer(ModelSerializer):
|
||||
|
@ -1,11 +1,9 @@
|
||||
import re
|
||||
from typing import List, Set
|
||||
|
||||
import jsonschema
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
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.http.request import QueryDict
|
||||
from rest_framework import status
|
||||
@ -50,6 +48,7 @@ from .models import (
|
||||
Submitter,
|
||||
Workflow,
|
||||
)
|
||||
from .numbering import numbering
|
||||
from .serializers import MotionPollSerializer, StateSerializer
|
||||
|
||||
|
||||
@ -323,7 +322,7 @@ class MotionViewSet(TreeSortMixin, ModelViewSet):
|
||||
def sort(self, request):
|
||||
"""
|
||||
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>
|
||||
children: [
|
||||
@ -1296,7 +1295,7 @@ class StatuteParagraphViewSet(ModelViewSet):
|
||||
return result
|
||||
|
||||
|
||||
class CategoryViewSet(ModelViewSet):
|
||||
class CategoryViewSet(TreeSortMixin, ModelViewSet):
|
||||
"""
|
||||
API endpoint for categories.
|
||||
|
||||
@ -1311,16 +1310,15 @@ class CategoryViewSet(ModelViewSet):
|
||||
"""
|
||||
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)
|
||||
elif self.action == "metadata":
|
||||
result = has_perm(self.request.user, "motions.can_see")
|
||||
elif self.action in (
|
||||
"create",
|
||||
"partial_update",
|
||||
"update",
|
||||
"destroy",
|
||||
"sort",
|
||||
"sort_categories",
|
||||
"sort_motions",
|
||||
"numbering",
|
||||
):
|
||||
result = has_perm(self.request.user, "motions.can_see") and has_perm(
|
||||
@ -1330,9 +1328,25 @@ class CategoryViewSet(ModelViewSet):
|
||||
result = False
|
||||
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"])
|
||||
@transaction.atomic
|
||||
def sort(self, request, pk=None):
|
||||
def sort_motions(self, request, pk=None):
|
||||
"""
|
||||
Endpoint to sort all motions in the category.
|
||||
|
||||
@ -1379,126 +1393,22 @@ class CategoryViewSet(ModelViewSet):
|
||||
@detail_route(methods=["post"])
|
||||
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.
|
||||
|
||||
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.
|
||||
Request args: None (implicit: the main category via URL)
|
||||
"""
|
||||
category = self.get_object()
|
||||
number = 0
|
||||
instances = []
|
||||
|
||||
# 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
|
||||
main_category = self.get_object()
|
||||
changed_instances = numbering(main_category)
|
||||
inform_changed_data(
|
||||
changed_instances, information=["Number set"], user_id=request.user.pk
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"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):
|
||||
|
@ -1,6 +1,7 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
@ -1764,107 +1765,168 @@ class UpdateMotionPoll(TestCase):
|
||||
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):
|
||||
self.client = APIClient()
|
||||
self.client.login(username="admin", password="admin")
|
||||
self.category = Category.objects.create(
|
||||
name="test_cateogory_name_zah6Ahd4Ifofaeree6ai",
|
||||
prefix="test_prefix_ahz6tho2mooH8",
|
||||
)
|
||||
self.motion = Motion(
|
||||
self.A = Category.objects.create(name="test_A", prefix="test_A")
|
||||
self.B = Category.objects.create(name="test_B", parent=self.A)
|
||||
self.C = Category.objects.create(name="test_C", prefix="test_C", parent=self.A)
|
||||
|
||||
self.M1 = Motion(
|
||||
title="test_title_Eeha8Haf6peulu8ooc0z",
|
||||
text="test_text_faghaZoov9ooV4Acaquk",
|
||||
category=self.category,
|
||||
category=self.A,
|
||||
category_weight=1,
|
||||
)
|
||||
self.motion.save()
|
||||
self.motion.identifier = ""
|
||||
self.motion.save()
|
||||
self.motion_2 = Motion(
|
||||
self.M1.save()
|
||||
self.M1.identifier = ""
|
||||
self.M1.save()
|
||||
|
||||
self.M2 = Motion(
|
||||
title="test_title_kuheih2eja2Saeshusha",
|
||||
text="test_text_Ha5ShaeraeSuthooP2Bu",
|
||||
category=self.category,
|
||||
category=self.C,
|
||||
category_weight=1,
|
||||
)
|
||||
self.motion_2.save()
|
||||
self.motion_2.identifier = ""
|
||||
self.motion_2.save()
|
||||
self.M2.save()
|
||||
self.M2.identifier = ""
|
||||
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):
|
||||
response = self.client.post(
|
||||
reverse("category-numbering", args=[self.category.pk])
|
||||
)
|
||||
response = self.client.post(reverse("category-numbering", args=[self.A.pk]))
|
||||
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(
|
||||
response.data,
|
||||
{
|
||||
"detail": "All motions in category test_cateogory_name_zah6Ahd4Ifofaeree6ai numbered successfully."
|
||||
},
|
||||
Motion.objects.get(pk=self.M2_A1.pk).identifier, "test_C 3 - 2"
|
||||
)
|
||||
self.assertEqual(
|
||||
Motion.objects.get(pk=self.motion.pk).identifier,
|
||||
"test_prefix_ahz6tho2mooH8 1",
|
||||
Motion.objects.get(pk=self.M2_A1_A1.pk).identifier, "test_C 3 - 2 - 1"
|
||||
)
|
||||
self.assertEqual(
|
||||
Motion.objects.get(pk=self.motion_2.pk).identifier,
|
||||
"test_prefix_ahz6tho2mooH8 2",
|
||||
Motion.objects.get(pk=self.M2_A2.pk).identifier, "test_C 3 - 1"
|
||||
)
|
||||
|
||||
def test_numbering_existing_identifier(self):
|
||||
self.motion_2.identifier = "test_prefix_ahz6tho2mooH8 1"
|
||||
self.motion_2.save()
|
||||
response = self.client.post(
|
||||
reverse("category-numbering", args=[self.category.pk])
|
||||
)
|
||||
def test_with_blanks(self):
|
||||
config["motions_amendments_prefix"] = "-X"
|
||||
settings.MOTION_IDENTIFIER_WITHOUT_BLANKS = True
|
||||
settings.MOTION_IDENTIFIER_MIN_DIGITS = 3
|
||||
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(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(
|
||||
response.data,
|
||||
{
|
||||
"detail": "All motions in category test_cateogory_name_zah6Ahd4Ifofaeree6ai numbered successfully."
|
||||
},
|
||||
Motion.objects.get(pk=self.M2_A1.pk).identifier, "test_C003-X002"
|
||||
)
|
||||
self.assertEqual(
|
||||
Motion.objects.get(pk=self.motion.pk).identifier,
|
||||
"test_prefix_ahz6tho2mooH8 1",
|
||||
Motion.objects.get(pk=self.M2_A1_A1.pk).identifier, "test_C003-X002-X001"
|
||||
)
|
||||
self.assertEqual(
|
||||
Motion.objects.get(pk=self.motion_2.pk).identifier,
|
||||
"test_prefix_ahz6tho2mooH8 2",
|
||||
Motion.objects.get(pk=self.M2_A2.pk).identifier, "test_C003-X001"
|
||||
)
|
||||
|
||||
def test_numbering_with_given_order(self):
|
||||
self.motion_3 = Motion(
|
||||
title="test_title_eeb0kua5ciike4su2auJ",
|
||||
text="test_text_ahshuGhaew3eim8yoht7",
|
||||
category=self.category,
|
||||
def test_existing_identifier_no_category(self):
|
||||
conflicting_motion = Motion(
|
||||
title="test_title_al2=2k21fjv1lsck3ehlWExg",
|
||||
text="test_text_3omvpEhnfg082ejplk1m",
|
||||
)
|
||||
self.motion_3.save()
|
||||
self.motion_3.identifier = ""
|
||||
self.motion_3.save()
|
||||
response = self.client.post(
|
||||
reverse("category-numbering", args=[self.category.pk]),
|
||||
{"motions": [3, 2]},
|
||||
format="json",
|
||||
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"])
|
||||
|
||||
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)
|
||||
self.assertEqual(
|
||||
response.data,
|
||||
{
|
||||
"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 = Motion(
|
||||
title="test_title_al2=2k21fjv1lsck3ehlWExg",
|
||||
text="test_text_3omvpEhnfg082ejplk1m",
|
||||
category=conflicting_category,
|
||||
)
|
||||
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):
|
||||
|
Loading…
Reference in New Issue
Block a user