diff --git a/client/src/app/core/repositories/mediafiles/mediafile-repository.service.ts b/client/src/app/core/repositories/mediafiles/mediafile-repository.service.ts index 8a4f05404..184856e05 100644 --- a/client/src/app/core/repositories/mediafiles/mediafile-repository.service.ts +++ b/client/src/app/core/repositories/mediafiles/mediafile-repository.service.ts @@ -135,4 +135,15 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec directory_id: directoryId }); } + + /** + * Deletes many files. + * + * @param mediafiles The users to delete + */ + public async bulkDelete(mediafiles: ViewMediafile[]): Promise { + await this.httpService.post('/rest/mediafiles/mediafile/bulk_delete/', { + ids: mediafiles.map(mediafile => mediafile.id) + }); + } } diff --git a/client/src/app/shared/components/attachment-control/attachment-control.component.html b/client/src/app/shared/components/attachment-control/attachment-control.component.html index ab2ee77f2..8d0c05b39 100644 --- a/client/src/app/shared/components/attachment-control/attachment-control.component.html +++ b/client/src/app/shared/components/attachment-control/attachment-control.component.html @@ -11,7 +11,7 @@ type="button" mat-icon-button (click)="openUploadDialog(uploadDialog)" - *osPerms="'mediafiles.can_upload'" + *osPerms="'mediafiles.can_manage'" > cloud_upload diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html index 5f7ecd1ae..e50345a3b 100644 --- a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html @@ -1,4 +1,4 @@ - +

Files

@@ -6,35 +6,40 @@ - +
-
chevron_right
- - - {{ directory.title }} + {{ directory.filename }}
@@ -83,23 +89,29 @@ showHeader="false" vScrollAuto [dataSource]="dataSource" + matCheckboxSelection="selection" [columns]="columnSet" [hideColumns]="hiddenColumns" + (rowClick)="onSelectRow($event)" >
- - + + + + {{ mediafile.getIcon() }}
- - + + + +
- {{ mediafile.title }} + {{ mediafile.filename }}
{{ getDateFromTimestamp(mediafile.timestamp) }} ยท {{ mediafile.size }} @@ -135,6 +147,7 @@ mat-icon-button [matMenuTriggerFor]="singleMediafileMenu" [matMenuTriggerData]="{ mediafile: mediafile }" + [disabled]="isMultiSelect" *ngIf="showFileMenu(mediafile)" > more_vert @@ -191,7 +204,7 @@ edit Edit - @@ -204,7 +217,7 @@ - + @@ -310,7 +327,7 @@

Move into directory

-
+

Please select the directory:

diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.ts b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.ts index 9d9672efe..b93c20ba2 100644 --- a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.ts +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.ts @@ -15,8 +15,9 @@ import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { columnFactory, createDS, PblDataSource } from '@pebula/ngrid'; -import { BehaviorSubject, Subscription } from 'rxjs'; +import { columnFactory, createDS, PblColumnDefinition } from '@pebula/ngrid'; +import { PblNgridDataMatrixRow } from '@pebula/ngrid/target-events'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { OperatorService } from 'app/core/core-services/operator.service'; import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service'; @@ -26,7 +27,7 @@ import { PromptService } from 'app/core/ui-services/prompt.service'; import { ViewportService } from 'app/core/ui-services/viewport.service'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; -import { BaseViewComponent } from 'app/site/base/base-view'; +import { BaseListViewComponent } from 'app/site/base/base-list-view'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { ViewGroup } from 'app/site/users/models/view-group'; import { MediafilesSortListService } from '../../services/mediafiles-sort-list.service'; @@ -41,12 +42,7 @@ import { MediafilesSortListService } from '../../services/mediafiles-sort-list.s changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class MediafileListComponent extends BaseViewComponent implements OnInit, OnDestroy { - /** - * Data source for the files - */ - public dataSource: PblDataSource; - +export class MediafileListComponent extends BaseListViewComponent implements OnInit, OnDestroy { /** * Holds the actions for logos. Updated via an observable */ @@ -65,15 +61,11 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, public newDirectoryForm: FormGroup; public moveForm: FormGroup; public directoryBehaviorSubject: BehaviorSubject; + public filteredDirectoryBehaviorSubject: BehaviorSubject = new BehaviorSubject( + [] + ); public groupsBehaviorSubject: BehaviorSubject; - /** - * @returns true if the user can manage media files - */ - public get canUploadFiles(): boolean { - return this.operator.hasPerms('mediafiles.can_see') && this.operator.hasPerms('mediafiles.can_upload'); - } - /** * @return true if the user can manage media files */ @@ -113,6 +105,10 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, hidden.push('info'); } + if (!this.isMultiSelect) { + hidden.push('selection'); + } + if (!this.canAccessFileMenu) { hidden.push('menu'); } @@ -120,45 +116,55 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, return hidden; } + /** + * Define the column definition + */ + public tableColumnDefinition: PblColumnDefinition[] = [ + { + prop: 'selection', + width: '40px' + }, + { + prop: 'icon', + label: '', + width: '40px' + }, + { + prop: 'title', + width: 'auto', + minWidth: 60 + }, + { + prop: 'info', + width: '20%', + minWidth: 60 + }, + { + prop: 'indicator', + label: '', + width: '40px' + }, + { + prop: 'menu', + label: '', + width: '40px' + } + ]; + /** * Create the column set */ public columnSet = columnFactory() - .table( - { - prop: 'icon', - label: '', - width: '40px' - }, - { - prop: 'title', - width: 'auto', - minWidth: 60 - }, - { - prop: 'info', - width: '20%', - minWidth: 60 - }, - { - prop: 'indicator', - label: '', - width: '40px' - }, - { - prop: 'menu', - label: '', - width: '40px' - } - ) + .table(...this.tableColumnDefinition) .build(); - public isMultiselect = false; // TODO private folderSubscription: Subscription; private directorySubscription: Subscription; public directory: ViewMediafile | null; public directoryChain: ViewMediafile[]; + private directoryObservable: Observable = new Observable(); + /** * Constructs the component * @@ -194,6 +200,7 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, private cd: ChangeDetectorRef ) { super(titleService, translate, matSnackBar); + this.canMultiSelect = true; this.newDirectoryForm = this.formBuilder.group({ title: ['', Validators.required], @@ -226,6 +233,8 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, this.mediaManage.getFontActions().subscribe(action => { this.fontActions = action; }); + + this.createDataSource(); } public ngOnDestroy(): void { @@ -250,25 +259,38 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, return new Date(timestamp).toLocaleString(this.translate.currentLang); } + /** + * TODO: Swap logic to only create DS once and update on filder change + * @param mediafiles + */ + private createDataSource(): void { + this.dataSource = createDS() + .onTrigger(() => this.directoryObservable) + .create(); + + this.dataSource.selection.changed.subscribe(selection => { + this.selectedRows = selection.source.selected; + }); + } + public changeDirectory(directoryId: number | null): void { this.clearSubscriptions(); - this.folderSubscription = this.repo.getListObservableDirectory(directoryId).subscribe(mediafiles => { + this.directoryObservable = this.repo.getListObservableDirectory(directoryId); + this.folderSubscription = this.directoryObservable.subscribe(mediafiles => { if (mediafiles) { - this.dataSource = createDS() - .onTrigger(() => mediafiles) - .create(); - this.cd.detectChanges(); + this.dataSource.refresh(); + this.cd.markForCheck(); } }); if (directoryId) { - this.directorySubscription = this.repo.getViewModelObservable(directoryId).subscribe(d => { - this.directory = d; - if (d) { - this.directoryChain = d.getDirectoryChain(); + this.directorySubscription = this.repo.getViewModelObservable(directoryId).subscribe(newDirectory => { + this.directory = newDirectory; + if (newDirectory) { + this.directoryChain = newDirectory.getDirectoryChain(); // Update the URL. - this.router.navigate(['/mediafiles/files/' + d.path], { + this.router.navigate(['/mediafiles/files/' + newDirectory.path], { replaceUrl: true }); } else { @@ -287,8 +309,6 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, } } - /** - */ public onMainEvent(): void { const path = '/mediafiles/upload/' + (this.directory ? this.directory.path : ''); this.router.navigate([path]); @@ -300,20 +320,22 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, * @param file the selected file */ public onEditFile(file: ViewMediafile): void { - this.fileToEdit = file; + if (!this.isMultiSelect) { + this.fileToEdit = file; - this.fileEditForm = this.fb.group({ - title: [file.title, Validators.required], - access_groups_id: [file.access_groups_id] - }); + this.fileEditForm = this.fb.group({ + title: [file.filename, Validators.required], + access_groups_id: [file.access_groups_id] + }); - const dialogRef = this.dialog.open(this.fileEditDialog, infoDialogSettings); + const dialogRef = this.dialog.open(this.fileEditDialog, infoDialogSettings); - dialogRef.keydownEvents().subscribe((event: KeyboardEvent) => { - if (event.key === 'Enter' && event.shiftKey && this.fileEditForm.valid) { - this.onSaveEditedFile(this.fileEditForm.value); - } - }); + dialogRef.keydownEvents().subscribe((event: KeyboardEvent) => { + if (event.key === 'Enter' && event.shiftKey && this.fileEditForm.valid) { + this.onSaveEditedFile(this.fileEditForm.value); + } + }); + } } /** @@ -338,6 +360,14 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, } } + public async deleteSelected(): Promise { + const title = this.translate.instant('Are you sure you want to delete all selected files and folders?'); + if (await this.promptService.open(title)) { + await this.repo.bulkDelete(this.selectedRows); + this.deselectAll(); + } + } + /** * Returns the display name of an action * @@ -415,22 +445,50 @@ export class MediafileListComponent extends BaseViewComponent implements OnInit, parent_id: this.directory ? this.directory.id : null, is_directory: true }); - this.repo.create(mediafile).then(null, this.raiseError); + this.repo.create(mediafile).catch(this.raiseError); } }); } - public move(templateRef: TemplateRef, mediafile: ViewMediafile): void { - this.newDirectoryForm.reset(); + public move(templateRef: TemplateRef, mediafiles: ViewMediafile[]): void { + this.moveForm.reset(); + + if (mediafiles.some(file => file.is_directory)) { + this.filteredDirectoryBehaviorSubject.next( + this.directoryBehaviorSubject.value.filter( + dir => !mediafiles.some(file => dir.path.startsWith(file.path)) + ) + ); + } else { + this.filteredDirectoryBehaviorSubject.next(this.directoryBehaviorSubject.value); + } const dialogRef = this.dialog.open(templateRef, infoDialogSettings); dialogRef.afterClosed().subscribe(result => { if (result) { - this.repo.move([mediafile], this.moveForm.value.directory_id).then(null, this.raiseError); + this.repo.move(mediafiles, this.moveForm.value.directory_id).then(() => { + this.dataSource.selection.clear(); + this.cd.markForCheck(); + }, this.raiseError); } }); } + /** + * TODO: This is basically a duplicate of onSelectRow of ListViewTableComponent + */ + public onSelectRow(event: PblNgridDataMatrixRow): void { + if (this.isMultiSelect) { + const clickedModel: ViewMediafile = event.row; + const alreadySelected = this.dataSource.selection.isSelected(clickedModel); + if (alreadySelected) { + this.dataSource.selection.deselect(clickedModel); + } else { + this.dataSource.selection.select(clickedModel); + } + } + } + private clearSubscriptions(): void { if (this.folderSubscription) { this.folderSubscription.unsubscribe(); diff --git a/client/src/app/site/mediafiles/mediafiles-routing.module.ts b/client/src/app/site/mediafiles/mediafiles-routing.module.ts index a7fb99091..1d75a342b 100644 --- a/client/src/app/site/mediafiles/mediafiles-routing.module.ts +++ b/client/src/app/site/mediafiles/mediafiles-routing.module.ts @@ -17,7 +17,7 @@ const routes: Routes = [ }, { path: 'upload', - data: { basePerm: 'mediafiles.can_upload' }, + data: { basePerm: 'mediafiles.can_manage' }, children: [{ path: '**', component: MediaUploadComponent }], pathMatch: 'prefix' } diff --git a/client/src/app/site/mediafiles/models/view-mediafile.ts b/client/src/app/site/mediafiles/models/view-mediafile.ts index d606e6c52..a2766b77b 100644 --- a/client/src/app/site/mediafiles/models/view-mediafile.ts +++ b/client/src/app/site/mediafiles/models/view-mediafile.ts @@ -50,6 +50,14 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers } public get title(): string { + if (this.is_directory) { + return this.mediafile.path; + } else { + return this.mediafile.title; + } + } + + public get filename(): string { return this.mediafile.title; } diff --git a/openslides/mediafiles/views.py b/openslides/mediafiles/views.py index 157787c14..414050e5f 100644 --- a/openslides/mediafiles/views.py +++ b/openslides/mediafiles/views.py @@ -32,7 +32,14 @@ class MediafileViewSet(ModelViewSet): """ if self.action in ("list", "retrieve", "metadata"): result = self.get_access_permissions().check_permissions(self.request.user) - elif self.action in ("create", "partial_update", "update", "move", "destroy"): + elif self.action in ( + "create", + "partial_update", + "update", + "move", + "destroy", + "bulk_delete", + ): result = has_perm(self.request.user, "mediafiles.can_see") and has_perm( self.request.user, "mediafiles.can_manage" ) @@ -150,6 +157,49 @@ class MediafileViewSet(ModelViewSet): return Response() + @list_route(methods=["post"]) + def bulk_delete(self, request): + """ + Deletes mediafiles *from one directory*. Expected data: + { ids: [, , ...] } + It is checked, that every id belongs to the same directory. + """ + # Validate data: + if not isinstance(request.data, dict): + raise ValidationError({"detail": "The data must be a dict"}) + ids = request.data.get("ids") + if not isinstance(ids, list): + raise ValidationError({"detail": "The ids must be a list"}) + for id in ids: + if not isinstance(id, int): + raise ValidationError({"detail": "All ids must be an int"}) + + # Get mediafiles + mediafiles = [] + for id in ids: + try: + mediafiles.append(Mediafile.objects.get(pk=id)) + except Mediafile.DoesNotExist: + raise ValidationError( + {"detail": f"The mediafile with id {id} does not exist"} + ) + if not mediafiles: + return Response() + + # Validate, that they are in the same directory: + directory_id = mediafiles[0].parent_id + for mediafile in mediafiles: + if mediafile.parent_id != directory_id: + raise ValidationError( + {"detail": "All mediafiles must be in the same directory."} + ) + + with watch_and_update_configs(): + for mediafile in mediafiles: + mediafile.delete() + + return Response() + def get_mediafile(request, path): """