From 56c1da352e8dfadb53b94d4286510713ac803e94 Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Fri, 28 Jun 2019 07:24:28 +0200 Subject: [PATCH] Directories and access permissions for mediafiles --- .../core/core-services/data-store.service.ts | 24 +- .../base-has-content-object-repository.ts | 2 - .../app/core/repositories/base-repository.ts | 61 ++--- .../mediafile-repository.service.ts | 73 ++++- .../motions/motion-repository.service.ts | 4 +- .../motions/workflow-repository.service.ts | 2 +- .../users/group-repository.service.ts | 6 +- .../core/ui-services/media-manage.service.ts | 2 +- .../media-upload-content.component.html | 36 ++- .../media-upload-content.component.ts | 71 +++-- .../search-value-selector.component.html | 2 +- .../search-value-selector.component.ts | 5 +- .../app/shared/models/mediafiles/mediafile.ts | 23 +- .../list-of-speakers.component.ts | 2 +- .../assignment-detail.component.html | 2 +- .../assignment-list.component.ts | 2 +- .../media-upload/media-upload.component.html | 1 + .../media-upload/media-upload.component.ts | 16 +- .../mediafile-list.component.html | 259 ++++++++++++------ .../mediafile-list.component.scss | 12 - .../mediafile-list.component.ts | 210 ++++++++------ .../app/site/mediafiles/mediafile.config.ts | 2 +- .../mediafiles/mediafiles-routing.module.ts | 13 +- .../site/mediafiles/models/view-mediafile.ts | 142 +++++++--- .../services/mediafile-filter.service.ts | 62 ----- .../motion-detail/motion-detail.component.ts | 2 +- .../motion-export-dialog.component.ts | 2 +- .../services/motion-multiselect.service.ts | 20 +- .../motions/services/motion-pdf.service.ts | 2 +- .../topic-detail/topic-detail.component.html | 2 +- .../user-detail/user-detail.component.ts | 2 +- .../user-list/user-list.component.ts | 2 +- .../migrations/0006_auto_20190119_1425.py | 2 +- openslides/agenda/models.py | 4 +- .../migrations/0006_auto_20190119_1425.py | 2 +- openslides/assignments/models.py | 4 +- .../migrations/0012_auto_20190119_1425.py | 2 +- openslides/mediafiles/access_permissions.py | 24 +- openslides/mediafiles/apps.py | 10 + openslides/mediafiles/config.py | 54 ++++ .../0004_directories_and_permissions_1.py | 65 +++++ .../0005_directories_and_permissions_2.py | 46 ++++ .../0006_directories_and_permissions_3.py | 16 ++ openslides/mediafiles/models.py | 170 ++++++++++-- openslides/mediafiles/projector.py | 2 +- openslides/mediafiles/serializers.py | 71 ++++- openslides/mediafiles/views.py | 222 ++++++++++----- .../migrations/0020_auto_20190119_1425.py | 4 +- openslides/motions/models.py | 6 +- .../migrations/0010_auto_20190119_1447.py | 2 +- openslides/users/models.py | 4 +- openslides/users/signals.py | 4 - openslides/utils/models.py | 2 +- tests/integration/mediafiles/test_viewset.py | 194 ++++++++++++- tests/integration/users/test_viewset.py | 2 - 55 files changed, 1429 insertions(+), 549 deletions(-) delete mode 100644 client/src/app/site/mediafiles/services/mediafile-filter.service.ts create mode 100644 openslides/mediafiles/config.py create mode 100644 openslides/mediafiles/migrations/0004_directories_and_permissions_1.py create mode 100644 openslides/mediafiles/migrations/0005_directories_and_permissions_2.py create mode 100644 openslides/mediafiles/migrations/0006_directories_and_permissions_3.py diff --git a/client/src/app/core/core-services/data-store.service.ts b/client/src/app/core/core-services/data-store.service.ts index b36f09b6f..63da25038 100644 --- a/client/src/app/core/core-services/data-store.service.ts +++ b/client/src/app/core/core-services/data-store.service.ts @@ -5,6 +5,7 @@ import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model import { CollectionStringMapperService } from './collection-string-mapper.service'; import { Deferred } from '../deferred'; import { StorageService } from './storage.service'; +import { BaseRepository } from '../repositories/base-repository'; /** * Represents information about a deleted model. @@ -187,7 +188,7 @@ export class DataStoreUpdateManagerService { } /** - * Commits the given update slot. THis slot must be the current one. If there are requests + * Commits the given update slot. This slot must be the current one. If there are requests * for update slots queued, the next one will be served. * * Note: I added this param to make sure, that only the user of the slot @@ -203,18 +204,33 @@ export class DataStoreUpdateManagerService { // notify repositories in two phases const repositories = this.mapperService.getAllRepositories(); + // just commit the update in a repository, if something was changed. Save + // this information in this mapping. the boolean is not evaluated; if there is an + const affectedRepos: { [collection: string]: BaseRepository } = {}; // Phase 1: deleting and creating of view models (in this order) repositories.forEach(repo => { - repo.deleteModels(slot.getDeletedModelIdsForCollection(repo.collectionString)); - repo.changedModels(slot.getChangedModelIdsForCollection(repo.collectionString)); + const deletedModelIds = slot.getDeletedModelIdsForCollection(repo.collectionString); + repo.deleteModels(deletedModelIds); + const changedModelIds = slot.getChangedModelIdsForCollection(repo.collectionString); + repo.changedModels(changedModelIds); + + if (deletedModelIds.length || changedModelIds.length) { + affectedRepos[repo.collectionString] = repo; + } }); // Phase 2: updating dependencies repositories.forEach(repo => { - repo.updateDependencies(slot.getChangedModels()); + if (repo.updateDependencies(slot.getChangedModels())) { + affectedRepos[repo.collectionString] = repo; + } }); + // Phase 3: committing the update to all affected repos. This will trigger all + // list observables/subjects to emit the new list. + Object.values(affectedRepos).forEach(repo => repo.commitUpdate()); + slot.DS.triggerModifiedObservable(); // serve next slot request diff --git a/client/src/app/core/repositories/base-has-content-object-repository.ts b/client/src/app/core/repositories/base-has-content-object-repository.ts index 9e36e6b7d..467cf9ee4 100644 --- a/client/src/app/core/repositories/base-has-content-object-repository.ts +++ b/client/src/app/core/repositories/base-has-content-object-repository.ts @@ -52,7 +52,6 @@ export abstract class BaseHasContentObjectRepository< this.contentObjectMapping[contentObject.collection][contentObject.id] = v; this.updateViewModelObservable(id); }); - this.updateViewModelListObservable(); } /** @@ -70,6 +69,5 @@ export abstract class BaseHasContentObjectRepository< delete this.viewModelStore[id]; this.updateViewModelObservable(id); }); - this.updateViewModelListObservable(); } } diff --git a/client/src/app/core/repositories/base-repository.ts b/client/src/app/core/repositories/base-repository.ts index 09c4ecbf6..39264258f 100644 --- a/client/src/app/core/repositories/base-repository.ts +++ b/client/src/app/core/repositories/base-repository.ts @@ -30,7 +30,7 @@ export abstract class BaseRepository = new BehaviorSubject([]); + protected readonly unsafeViewModelListSubject: BehaviorSubject = new BehaviorSubject(null); /** * Observable subject for the sorted view model list. @@ -39,7 +39,7 @@ export abstract class BaseRepository = new BehaviorSubject([]); + protected readonly viewModelListSubject: BehaviorSubject = new BehaviorSubject([]); /** * Observable subject for any changes of view models. @@ -94,8 +94,10 @@ export abstract class BaseRepository { - this.sortedViewModelListSubject.next(models.sort(this.viewModelSortFn)); + this.unsafeViewModelListSubject.pipe(auditTime(1)).subscribe(models => { + if (models) { + this.viewModelListSubject.next(models.sort(this.viewModelSortFn)); + } }); this.languageCollator = new Intl.Collator(this.translate.currentLang); @@ -105,27 +107,15 @@ export abstract class BaseRepository this.clear()); this.translate.onLangChange.subscribe(change => { this.languageCollator = new Intl.Collator(change.lang); - this.updateViewModelListObservable(); - }); - - this.loadInitialFromDS(); - } - - protected loadInitialFromDS(): void { - // Populate the local viewModelStore with ViewModel Objects. - this.DS.getAll(this.baseModelCtor).forEach((model: M) => { - this.viewModelStore[model.id] = this.createViewModelWithTitles(model); - }); - - // Update the list and then all models on their own - this.updateViewModelListObservable(); - this.DS.getAll(this.baseModelCtor).forEach((model: M) => { - this.updateViewModelObservable(model.id); + if (this.unsafeViewModelListSubject.value) { + this.viewModelListSubject.next(this.unsafeViewModelListSubject.value.sort(this.viewModelSortFn)); + } }); } /** - * Deletes all models from the repository (internally, no requests). Informs all subjects. + * Deletes all models from the repository (internally, no requests). Changes need + * to be committed via `commitUpdate()`. * * @param ids All model ids */ @@ -134,12 +124,11 @@ export abstract class BaseRepository { this.updateViewModelObservable(ownViewModel.id); }); - this.updateViewModelListObservable(); } + return somethingUpdated; } public getListTitle: (titleInformation: T) => string = (titleInformation: T) => { @@ -292,7 +281,7 @@ export abstract class BaseRepository number): void { this.viewModelSortFn = fn; - this.updateViewModelListObservable(); + this.commitUpdate(); } /** @@ -316,7 +305,7 @@ export abstract class BaseRepository { - return this.sortedViewModelListSubject.asObservable(); + return this.viewModelListSubject.asObservable(); } /** @@ -343,7 +332,7 @@ export abstract class BaseRepository { - return this.sortedViewModelListSubject; + return this.viewModelListSubject; } /** @@ -366,15 +355,7 @@ export abstract class BaseRepository { + private directoryBehaviorSubject: BehaviorSubject; + /** * Constructor for the mediafile repository * @param DS Data store @@ -42,8 +46,17 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec dataSend: DataSendService, private httpService: HttpService ) { - super(DS, dataSend, mapperService, viewModelStoreService, translate, Mediafile, [User]); - this.initSorting(); + super(DS, dataSend, mapperService, viewModelStoreService, translate, Mediafile, [Mediafile, Group]); + this.directoryBehaviorSubject = new BehaviorSubject([]); + this.getViewModelListObservable().subscribe(mediafiles => { + if (mediafiles) { + this.directoryBehaviorSubject.next(mediafiles.filter(mediafile => mediafile.is_directory)); + } + }); + + this.viewModelSortFn = (a: ViewMediafile, b: ViewMediafile) => { + return this.languageCollator.compare(a.title, b.title); + }; } public getTitle = (titleInformation: MediafileTitleInformation) => { @@ -62,8 +75,40 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec */ public createViewModel(file: Mediafile): ViewMediafile { const listOfSpeakers = this.viewModelStoreService.get(ViewListOfSpeakers, file.list_of_speakers_id); - const uploader = this.viewModelStoreService.get(ViewUser, file.uploader_id); - return new ViewMediafile(file, listOfSpeakers, uploader); + const parent = this.viewModelStoreService.get(ViewMediafile, file.parent_id); + const accessGroups = this.viewModelStoreService.getMany(ViewGroup, file.access_groups_id); + let inheritedAccessGroups; + if (file.has_inherited_access_groups) { + inheritedAccessGroups = this.viewModelStoreService.getMany(ViewGroup, ( + file.inherited_access_groups_id + )); + } + return new ViewMediafile(file, listOfSpeakers, parent, accessGroups, inheritedAccessGroups); + } + + public async getDirectoryIdByPath(pathSegments: string[]): Promise { + let parentId = null; + + const mediafiles = await this.unsafeViewModelListSubject.pipe(first(x => !!x)).toPromise(); + + pathSegments.forEach(segment => { + const mediafile = mediafiles.find(m => m.is_directory && m.title === segment && m.parent_id === parentId); + if (!mediafile) { + parentId = null; + return; + } else { + parentId = mediafile.id; + } + }); + return parentId; + } + + public getListObservableDirectory(parentId: number | null): Observable { + return this.getViewModelListObservable().pipe( + map(mediafiles => { + return mediafiles.filter(mediafile => mediafile.parent_id === parentId); + }) + ); } /** @@ -74,17 +119,19 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec * @param file created UploadData, containing a file * @returns the promise to a new mediafile. */ - public async uploadFile(file: FormData): Promise { + public async uploadFile(file: any): Promise { const emptyHeader = new HttpHeaders(); return this.httpService.post('/rest/mediafiles/mediafile/', file, {}, emptyHeader); } - /** - * Sets the default sorting (e.g. in dropdowns and for new users) to 'title' - */ - private initSorting(): void { - this.setSortFunction((a: ViewMediafile, b: ViewMediafile) => { - return this.languageCollator.compare(a.title, b.title); + public getDirectoryBehaviorSubject(): BehaviorSubject { + return this.directoryBehaviorSubject; + } + + public async move(mediafiles: ViewMediafile[], directoryId: number | null): Promise { + return await this.httpService.post('/rest/mediafiles/mediafile/move/', { + ids: mediafiles.map(mediafile => mediafile.id), + directory_id: directoryId }); } } diff --git a/client/src/app/core/repositories/motions/motion-repository.service.ts b/client/src/app/core/repositories/motions/motion-repository.service.ts index becd50772..909d30128 100644 --- a/client/src/app/core/repositories/motions/motion-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-repository.service.ts @@ -264,7 +264,7 @@ export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersCo * Special handling of updating personal notes. * @override */ - public updateDependencies(changedModels: CollectionIds): void { + public updateDependencies(changedModels: CollectionIds): boolean { if (!this.depsModelCtors || this.depsModelCtors.length === 0) { return; } @@ -302,8 +302,8 @@ export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersCo viewModels.forEach(ownViewModel => { this.updateViewModelObservable(ownViewModel.id); }); - this.updateViewModelListObservable(); } + return somethingUpdated; } /** diff --git a/client/src/app/core/repositories/motions/workflow-repository.service.ts b/client/src/app/core/repositories/motions/workflow-repository.service.ts index 435929c99..9dab25d7f 100644 --- a/client/src/app/core/repositories/motions/workflow-repository.service.ts +++ b/client/src/app/core/repositories/motions/workflow-repository.service.ts @@ -51,7 +51,7 @@ export class WorkflowRepositoryService extends BaseRepository { + this.viewModelListSubject.subscribe(models => { if (models && models.length > 0) { this.initSorting(models); } diff --git a/client/src/app/core/repositories/users/group-repository.service.ts b/client/src/app/core/repositories/users/group-repository.service.ts index 71a7140a2..11b90a81d 100644 --- a/client/src/app/core/repositories/users/group-repository.service.ts +++ b/client/src/app/core/repositories/users/group-repository.service.ts @@ -47,7 +47,7 @@ export class GroupRepositoryService extends BaseRepository('permissions').subscribe(perms => { + this.constantsService.get('permissions').subscribe(perms => { let pluginCounter = 0; for (const perm of perms) { // extract the apps name diff --git a/client/src/app/core/ui-services/media-manage.service.ts b/client/src/app/core/ui-services/media-manage.service.ts index ce5871e56..9114703c6 100644 --- a/client/src/app/core/ui-services/media-manage.service.ts +++ b/client/src/app/core/ui-services/media-manage.service.ts @@ -63,7 +63,7 @@ export class MediaManageService { const restPath = `/rest/core/config/${action}/`; const config = this.getMediaConfig(action); - const path = config.path !== file.downloadUrl ? file.downloadUrl : ''; + const path = config.path !== file.url ? file.url : ''; // Create the payload that the server requires to manage a mediafile const payload: ManagementPayload = { diff --git a/client/src/app/shared/components/media-upload-content/media-upload-content.component.html b/client/src/app/shared/components/media-upload-content/media-upload-content.component.html index 3da4e5b6a..37500b58c 100644 --- a/client/src/app/shared/components/media-upload-content/media-upload-content.component.html +++ b/client/src/app/shared/components/media-upload-content/media-upload-content.component.html @@ -12,6 +12,25 @@ + +
+ +
+ +
+ Upload to: + Base folder + {{ getDirectory(selectedDirectoryId).title }} +
+
@@ -44,14 +63,17 @@ - - - + + + diff --git a/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts b/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts index 52933c761..536c79c67 100644 --- a/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts +++ b/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts @@ -1,10 +1,14 @@ import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core'; import { MatTableDataSource, MatTable } from '@angular/material/table'; +import { FormBuilder, FormGroup } from '@angular/forms'; import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop'; +import { BehaviorSubject } from 'rxjs'; -import { OperatorService } from 'app/core/core-services/operator.service'; import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service'; +import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; +import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; +import { ViewGroup } from 'app/site/users/models/view-group'; /** * To hold the structure of files to upload @@ -13,8 +17,7 @@ interface FileData { mediafile: File; filename: string; title: string; - uploader_id: number; - hidden: boolean; + form: FormGroup; } @Component({ @@ -26,7 +29,7 @@ export class MediaUploadContentComponent implements OnInit { /** * Columns to display in the upload-table */ - public displayedColumns: string[] = ['title', 'filename', 'information', 'hidden', 'remove']; + public displayedColumns: string[] = ['title', 'filename', 'information', 'access_groups', 'remove']; /** * Determine wether to show the progress bar @@ -50,6 +53,9 @@ export class MediaUploadContentComponent implements OnInit { @Input() public parallel = true; + @Input() + public directoryId: number | null | undefined; + /** * Set if an error was detected to prevent automatic navigation */ @@ -73,13 +79,40 @@ export class MediaUploadContentComponent implements OnInit { @Output() public errorEvent = new EventEmitter(); + public directoryBehaviorSubject: BehaviorSubject; + public groupsBehaviorSubject: BehaviorSubject; + + public directorySelectionForm: FormGroup; + + public get showDirectorySelector(): boolean { + return this.directoryId === undefined; + } + + public get selectedDirectoryId(): number | null { + if (this.showDirectorySelector) { + return this.directorySelectionForm.controls.parent_id.value; + } else { + return this.directoryId; + } + } + /** * Constructor for the media upload page * * @param repo the mediafile repository * @param op the operator, to check who was the uploader */ - public constructor(private repo: MediafileRepositoryService, private op: OperatorService) {} + public constructor( + private repo: MediafileRepositoryService, + private formBuilder: FormBuilder, + private groupRepo: GroupRepositoryService + ) { + this.directoryBehaviorSubject = this.repo.getDirectoryBehaviorSubject(); + this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject(); + this.directorySelectionForm = this.formBuilder.group({ + parent_id: [] + }); + } /** * Init @@ -89,6 +122,10 @@ export class MediaUploadContentComponent implements OnInit { this.uploadList = new MatTableDataSource(); } + public getDirectory(directoryId: number): ViewMediafile { + return this.repo.getViewModel(directoryId); + } + /** * Converts given FileData into FormData format and hands it over to the repository * to upload @@ -99,8 +136,13 @@ export class MediaUploadContentComponent implements OnInit { const input = new FormData(); input.set('mediafile', fileData.mediafile); input.set('title', fileData.title); - input.set('uploader_id', '' + fileData.uploader_id); - input.set('hidden', '' + fileData.hidden); + const access_groups_id = fileData.form.value.access_groups_id || []; + if (access_groups_id.length > 0) { + input.set('access_groups_id', '' + access_groups_id); + } + if (this.selectedDirectoryId) { + input.set('parent_id', '' + this.selectedDirectoryId); + } // raiseError will automatically ignore existing files await this.repo.uploadFile(input).then( @@ -127,16 +169,6 @@ export class MediaUploadContentComponent implements OnInit { return `${bytes} ${['B', 'kB', 'MB', 'GB', 'TB'][unitLevel]}`; } - /** - * Change event to set a file to hidden or not - * - * @param hidden whether the file should be hidden - * @param file the given file - */ - public onChangeHidden(hidden: boolean, file: FileData): void { - file.hidden = hidden; - } - /** * Change event to adjust the title * @@ -157,8 +189,9 @@ export class MediaUploadContentComponent implements OnInit { mediafile: file, filename: file.name, title: file.name, - uploader_id: this.op.user.id, - hidden: false + form: this.formBuilder.group({ + access_groups_id: [[]] + }) }; this.uploadList.data.push(newFile); diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.html b/client/src/app/shared/components/search-value-selector/search-value-selector.component.html index fc77383ee..f6852b993 100644 --- a/client/src/app/shared/components/search-value-selector/search-value-selector.component.html +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.html @@ -3,7 +3,7 @@
- + {{ noneTitle | translate }}
diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts b/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts index 451701c36..06404d6f5 100644 --- a/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts @@ -11,7 +11,7 @@ import { Selectable } from '../selectable'; /** * Reusable Searchable Value Selector * - * Use `multiple="true"`, `[InputListValues]=myValues`,`[formControl]="myformcontrol"`, `[form]="myform_name"` and `placeholder={{listname}}` to pass the Values and Listname + * Use `multiple="true"`, `[InputListValues]=myValues`,`[formControl]="myformcontrol"` and `placeholder={{listname}}` to pass the Values and Listname * * ## Examples: * @@ -64,6 +64,9 @@ export class SearchValueSelectorComponent implements OnDestroy { @Input() public includeNone = false; + @Input() + public noneTitle = '–'; + /** * Boolean, whether the component should be rendered with full width. */ diff --git a/client/src/app/shared/models/mediafiles/mediafile.ts b/client/src/app/shared/models/mediafiles/mediafile.ts index 1483fd280..39077343d 100644 --- a/client/src/app/shared/models/mediafiles/mediafile.ts +++ b/client/src/app/shared/models/mediafiles/mediafile.ts @@ -17,15 +17,24 @@ export class Mediafile extends BaseModelWithListOfSpeakers { public static COLLECTIONSTRING = 'mediafiles/mediafile'; public id: number; public title: string; - public mediafile: FileMetadata; + public mediafile?: FileMetadata; public media_url_prefix: string; - public uploader_id: number; public filesize: string; - public hidden: boolean; - public timestamp: string; + public access_groups_id: number[]; + public create_timestamp: string; + public parent_id: number | null; + public is_directory: boolean; + public path: string; + public inherited_access_groups_id: boolean | number[]; + + public get has_inherited_access_groups(): boolean { + return typeof this.inherited_access_groups_id !== 'boolean'; + } public constructor(input?: any) { - super(Mediafile.COLLECTIONSTRING, input); + super(Mediafile.COLLECTIONSTRING); + // Do not change null to undefined... + this.deserialize(input); } /** @@ -33,7 +42,7 @@ export class Mediafile extends BaseModelWithListOfSpeakers { * * @returns the download URL for the specific file as string */ - public get downloadUrl(): string { - return `${this.media_url_prefix}${this.mediafile.name}`; + public get url(): string { + return `${this.media_url_prefix}${this.path}`; } } diff --git a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts index 57fa55bc5..09ae4f011 100644 --- a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts +++ b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts @@ -163,7 +163,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit : true; if (this.isCurrentListOfSpeakers) { - this.projectors = this.projectorRepo.getSortedViewModelList(); + this.projectors = this.projectorRepo.getViewModelList(); this.updateClosProjector(); this.projectorRepo.getViewModelListObservable().subscribe(newProjectors => { this.projectors = newProjectors; diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html index c7d337989..3b18effee 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html @@ -108,7 +108,7 @@

Election documents

- {{ file.getTitle() }} + {{ file.getTitle() }} diff --git a/client/src/app/site/assignments/components/assignment-list/assignment-list.component.ts b/client/src/app/site/assignments/components/assignment-list/assignment-list.component.ts index bb7714ba2..650999d31 100644 --- a/client/src/app/site/assignments/components/assignment-list/assignment-list.component.ts +++ b/client/src/app/site/assignments/components/assignment-list/assignment-list.component.ts @@ -110,7 +110,7 @@ export class AssignmentListComponent extends BaseListViewComponent diff --git a/client/src/app/site/mediafiles/components/media-upload/media-upload.component.ts b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.ts index 246cc53cb..d0499a273 100644 --- a/client/src/app/site/mediafiles/components/media-upload/media-upload.component.ts +++ b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -6,6 +6,7 @@ import { TranslateService } from '@ngx-translate/core'; import { BaseViewComponent } from 'app/site/base/base-view'; import { Router, ActivatedRoute } from '@angular/router'; +import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service'; /** * Handle file uploads from user @@ -15,13 +16,15 @@ import { Router, ActivatedRoute } from '@angular/router'; templateUrl: './media-upload.component.html', styleUrls: ['./media-upload.component.scss'] }) -export class MediaUploadComponent extends BaseViewComponent { +export class MediaUploadComponent extends BaseViewComponent implements OnInit { /** * Determine if uploading should happen parallel or synchronously. * Synchronous uploading might be necessary if we see that stuff breaks */ public parallel = true; + public directoryId: number | null = null; + /** * Constructor for the media upload page * @@ -36,11 +39,18 @@ export class MediaUploadComponent extends BaseViewComponent { translate: TranslateService, matSnackBar: MatSnackBar, private router: Router, - private route: ActivatedRoute + private route: ActivatedRoute, + private repo: MediafileRepositoryService ) { super(titleService, translate, matSnackBar); } + public ngOnInit(): void { + this.repo.getDirectoryIdByPath(this.route.snapshot.url.map(x => x.path)).then(directoryId => { + this.directoryId = directoryId; + }); + } + /** * Handler for successful uploads */ 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 eecfa0738..32323405e 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,110 +6,151 @@ -
+ - - -
- - - lock -   - -
- {{ file.title }} -
-
+ - -
-
- {{ file.type }} - {{ file.size }} -
-
- - -
-
- text_fields - insert_photo -
-
- - -
- + + chevron_right + -
-
+ + + + +
+ +
+ Visibility of this directory: + No one + + {{ directory.inherited_access_groups }} + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + - - - - + + -
+
- +
-
+
- +
- + - - - + @@ -117,7 +158,7 @@ - +

{{ 'Edit details for' | translate }}

@@ -163,12 +204,13 @@ Required - - - Hidden - Visible - - +
@@ -186,3 +228,58 @@
+ + + +

+ Create new directory +

+
+

Please enter a name for the new directory:

+ + + + +
+
+ + +
+
+ + + +

+ Move to directory +

+
+

Please select the directory to move to:

+ +
+
+ + +
+
diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.scss b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.scss index a9c296c83..4550cc250 100644 --- a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.scss +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.scss @@ -4,15 +4,3 @@ ::ng-deep .mat-tooltip { white-space: pre-line !important; } - -// duplicate. Put into own file -.file-info-cell { - display: grid; - margin: 0; - - span { - .mat-icon { - font-size: 130%; - } - } -} 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 2ff73216c..705b120ba 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 @@ -1,24 +1,25 @@ -import { Component, OnInit, ViewChild, TemplateRef } from '@angular/core'; +import { Component, OnInit, ViewChild, TemplateRef, OnDestroy } from '@angular/core'; import { FormGroup, Validators, FormBuilder } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTableDataSource } from '@angular/material'; import { Router, ActivatedRoute } from '@angular/router'; import { Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; -import { PblColumnDefinition } from '@pebula/ngrid'; +import { BehaviorSubject, Subscription } from 'rxjs'; -import { ColumnRestriction } from 'app/shared/components/list-view-table/list-view-table.component'; -import { BaseListViewComponent } from 'app/site/base/base-list-view'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service'; import { MediaManageService } from 'app/core/ui-services/media-manage.service'; -import { MediafileFilterListService } from '../../services/mediafile-filter.service'; import { MediafilesSortListService } from '../../services/mediafiles-sort-list.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 { ViewportService } from 'app/core/ui-services/viewport.service'; +import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; +import { ViewGroup } from 'app/site/users/models/view-group'; +import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; +import { BaseViewComponent } from 'app/site/base/base-view'; /** * Lists all the uploaded files. @@ -28,7 +29,9 @@ import { ViewportService } from 'app/core/ui-services/viewport.service'; templateUrl: './mediafile-list.component.html', styleUrls: ['./mediafile-list.component.scss'] }) -export class MediafileListComponent extends BaseListViewComponent implements OnInit { +export class MediafileListComponent extends BaseViewComponent implements OnInit, OnDestroy { + public readonly dataSource: MatTableDataSource = new MatTableDataSource(); + /** * Holds the actions for logos. Updated via an observable */ @@ -39,16 +42,18 @@ export class MediafileListComponent extends BaseListViewComponent */ public fontActions: string[]; - /** - * Show or hide the edit mode - */ - public editFile = false; - /** * Holds the file to edit */ public fileToEdit: ViewMediafile; + public newDirectoryForm: FormGroup; + + public moveForm: FormGroup; + + public directoryBehaviorSubject: BehaviorSubject; + public groupsBehaviorSubject: BehaviorSubject; + /** * @returns true if the user can manage media files */ @@ -75,46 +80,14 @@ export class MediafileListComponent extends BaseListViewComponent @ViewChild('fileEditDialog', { static: true }) public fileEditDialog: TemplateRef; - /** - * Define the columns to show - */ - public tableColumnDefinition: PblColumnDefinition[] = [ - { - prop: 'title', - width: 'auto' - }, - { - prop: 'info', - width: '20%' - }, - { - prop: 'indicator', - width: this.singleButtonWidth - }, - { - prop: 'menu', - width: this.singleButtonWidth - } - ]; + public displayedColumns = ['projector', 'icon', 'title', 'info', 'indicator', 'menu']; - /** - * Restricted Columns - */ - public restrictedColumns: ColumnRestriction[] = [ - { - columnName: 'indicator', - permission: 'mediafiles.can_manage' - }, - { - columnName: 'menu', - permission: 'mediafiles.can_manage' - } - ]; + public isMultiselect = false; // TODO - /** - * Define extra filter properties - */ - public filterProps = ['title', 'type']; + private folderSubscription: Subscription; + private directorySubscription: Subscription; + public directory: ViewMediafile | null; + public directoryChain: ViewMediafile[]; /** * Constructs the component @@ -137,20 +110,29 @@ export class MediafileListComponent extends BaseListViewComponent protected translate: TranslateService, matSnackBar: MatSnackBar, private route: ActivatedRoute, - storage: StorageService, private router: Router, public repo: MediafileRepositoryService, private mediaManage: MediaManageService, private promptService: PromptService, public vp: ViewportService, - public filterService: MediafileFilterListService, public sortService: MediafilesSortListService, private operator: OperatorService, private dialog: MatDialog, - private fb: FormBuilder + private fb: FormBuilder, + private formBuilder: FormBuilder, + private groupRepo: GroupRepositoryService ) { - super(titleService, translate, matSnackBar, storage); - this.canMultiSelect = true; + super(titleService, translate, matSnackBar); + + this.newDirectoryForm = this.formBuilder.group({ + title: ['', Validators.required], + access_groups_id: [] + }); + this.moveForm = this.formBuilder.group({ + directory_id: [] + }); + this.directoryBehaviorSubject = this.repo.getDirectoryBehaviorSubject(); + this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject(); } /** @@ -160,6 +142,10 @@ export class MediafileListComponent extends BaseListViewComponent public ngOnInit(): void { super.setTitle('Files'); + this.repo.getDirectoryIdByPath(this.route.snapshot.url.map(x => x.path)).then(directoryId => { + this.changeDirectory(directoryId); + }); + // Observe the logo actions this.mediaManage.getLogoActions().subscribe(action => { this.logoActions = action; @@ -171,17 +157,44 @@ export class MediafileListComponent extends BaseListViewComponent }); } + public changeDirectory(directoryId: number | null): void { + this.clearSubscriptions(); + + this.folderSubscription = this.repo.getListObservableDirectory(directoryId).subscribe(mediafiles => { + this.dataSource.data = []; + this.dataSource.data = mediafiles; + }); + + if (directoryId) { + this.directorySubscription = this.repo.getViewModelObservable(directoryId).subscribe(d => { + this.directory = d; + if (d) { + this.directoryChain = d.getDirectoryChain(); + // Update the URL. + this.router.navigate(['/mediafiles/files/' + d.path], { + replaceUrl: true + }); + } else { + this.directoryChain = []; + this.router.navigate(['/mediafiles/files/'], { + replaceUrl: true + }); + } + }); + } else { + this.directory = null; + this.directoryChain = []; + this.router.navigate(['/mediafiles/files/'], { + replaceUrl: true + }); + } + } + /** - * Handler for the main Event. - * In edit mode, this abandons the changes - * Without edit mode, this will navigate to the upload page */ public onMainEvent(): void { - if (!this.editFile) { - this.router.navigate(['./upload'], { relativeTo: this.route }); - } else { - this.editFile = false; - } + const path = '/mediafiles/upload/' + (this.directory ? this.directory.path : ''); + this.router.navigate([path]); } /** @@ -194,7 +207,7 @@ export class MediafileListComponent extends BaseListViewComponent this.fileEditForm = this.fb.group({ title: [file.title, Validators.required], - hidden: [file.hidden] + access_groups_id: [file.access_groups_id] }); const dialogRef = this.dialog.open(this.fileEditDialog, { @@ -214,7 +227,7 @@ export class MediafileListComponent extends BaseListViewComponent /** * Click on the save button in edit mode */ - public onSaveEditedFile(value: { title: string; hidden: any }): void { + public onSaveEditedFile(value: Partial): void { this.repo.update(value, this.fileToEdit).then(() => { this.dialog.closeAll(); }, this.raiseError); @@ -233,19 +246,6 @@ export class MediafileListComponent extends BaseListViewComponent } } - /** - * Handler to delete several files at once. Requires data in selectedRows, which - * will be made available in multiSelect mode - */ - public async deleteSelected(): Promise { - const title = this.translate.instant('Are you sure you want to delete all selected files?'); - if (await this.promptService.open(title)) { - for (const mediafile of this.selectedRows) { - await this.repo.delete(mediafile); - } - } - } - /** * Returns the display name of an action * @@ -278,7 +278,7 @@ export class MediafileListComponent extends BaseListViewComponent */ public isUsedAs(file: ViewMediafile, mediaFileAction: string): boolean { const config = this.mediaManage.getMediaConfig(mediaFileAction); - return config ? config.path === file.downloadUrl : false; + return config ? config.path === file.url : false; } /** @@ -312,14 +312,50 @@ export class MediafileListComponent extends BaseListViewComponent this.mediaManage.setAs(file, action); } - /** - * Clicking escape while in editFileForm should deactivate edit mode. - * - * @param event The key that was pressed - */ - public keyDownFunction(event: KeyboardEvent): void { - if (event.key === 'Escape') { - this.editFile = false; + public createNewFolder(templateRef: TemplateRef): void { + this.newDirectoryForm.reset(); + const dialogRef = this.dialog.open(templateRef, { + width: '400px' + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const mediafile = new Mediafile({ + ...this.newDirectoryForm.value, + parent_id: this.directory ? this.directory.id : null, + is_directory: true + }); + this.repo.create(mediafile).then(null, this.raiseError); + } + }); + } + + public move(templateRef: TemplateRef, mediafile: ViewMediafile): void { + this.newDirectoryForm.reset(); + const dialogRef = this.dialog.open(templateRef, { + width: '400px' + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.repo.move([mediafile], this.moveForm.value.directory_id).then(null, this.raiseError); + } + }); + } + + private clearSubscriptions(): void { + if (this.folderSubscription) { + this.folderSubscription.unsubscribe(); + this.folderSubscription = null; + } + if (this.directorySubscription) { + this.directorySubscription.unsubscribe(); + this.directorySubscription = null; } } + + public ngOnDestroy(): void { + super.ngOnDestroy(); + this.clearSubscriptions(); + } } diff --git a/client/src/app/site/mediafiles/mediafile.config.ts b/client/src/app/site/mediafiles/mediafile.config.ts index ef10de611..1fc8597cc 100644 --- a/client/src/app/site/mediafiles/mediafile.config.ts +++ b/client/src/app/site/mediafiles/mediafile.config.ts @@ -17,7 +17,7 @@ export const MediafileAppConfig: AppConfig = { ], mainMenuEntries: [ { - route: '/mediafiles', + route: '/mediafiles/files', displayName: 'Files', icon: 'attach_file', weight: 600, diff --git a/client/src/app/site/mediafiles/mediafiles-routing.module.ts b/client/src/app/site/mediafiles/mediafiles-routing.module.ts index fdec2aa93..a50fb1f0a 100644 --- a/client/src/app/site/mediafiles/mediafiles-routing.module.ts +++ b/client/src/app/site/mediafiles/mediafiles-routing.module.ts @@ -4,8 +4,17 @@ import { MediafileListComponent } from './components/mediafile-list/mediafile-li import { MediaUploadComponent } from './components/media-upload/media-upload.component'; const routes: Routes = [ - { path: '', component: MediafileListComponent, pathMatch: 'full' }, - { path: 'upload', component: MediaUploadComponent, data: { basePerm: 'mediafiles.can_upload' } } + { + path: 'files', + children: [{ path: '**', component: MediafileListComponent }], + pathMatch: 'prefix' + }, + { + path: 'upload', + data: { basePerm: 'mediafiles.can_upload' }, + children: [{ path: '**', component: MediaUploadComponent }], + pathMatch: 'prefix' + } ]; @NgModule({ diff --git a/client/src/app/site/mediafiles/models/view-mediafile.ts b/client/src/app/site/mediafiles/models/view-mediafile.ts index 51a9cf5ae..22c590f84 100644 --- a/client/src/app/site/mediafiles/models/view-mediafile.ts +++ b/client/src/app/site/mediafiles/models/view-mediafile.ts @@ -2,10 +2,10 @@ import { BaseViewModel } from '../../base/base-view-model'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { Searchable } from 'app/site/base/searchable'; import { SearchRepresentation } from 'app/core/ui-services/search.service'; -import { ViewUser } from 'app/site/users/models/view-user'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { BaseViewModelWithListOfSpeakers } from 'app/site/base/base-view-model-with-list-of-speakers'; import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { ViewGroup } from 'app/site/users/models/view-group'; export const IMAGE_MIMETYPES = ['image/png', 'image/jpeg', 'image/gif']; export const FONT_MIMETYPES = ['font/ttf', 'font/woff', 'application/font-woff', 'application/font-sfnt']; @@ -19,76 +19,97 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers implements MediafileTitleInformation, Searchable { public static COLLECTIONSTRING = Mediafile.COLLECTIONSTRING; - private _uploader: ViewUser; + private _parent?: ViewMediafile; + private _access_groups?: ViewGroup[]; + private _inherited_access_groups?: ViewGroup[]; public get mediafile(): Mediafile { return this._model; } - public get uploader(): ViewUser { - return this._uploader; + public get parent(): ViewMediafile | null { + return this._parent; } - public get uploader_id(): number { - return this.mediafile.uploader_id; + public get access_groups(): ViewGroup[] { + return this._access_groups || []; + } + + public get access_groups_id(): number[] { + return this.mediafile.access_groups_id; + } + + public get inherited_access_groups(): ViewGroup[] | null { + return this._inherited_access_groups; + } + + public get inherited_access_groups_id(): boolean | number[] { + return this.mediafile.inherited_access_groups_id; + } + + public get has_inherited_access_groups(): boolean { + return this.mediafile.has_inherited_access_groups; } public get title(): string { return this.mediafile.title; } - public get size(): string { - return this.mediafile.filesize; + public get path(): string { + return this.mediafile.path; } - public get type(): string { - return this.mediafile.mediafile.type; + public get parent_id(): number { + return this.mediafile.parent_id; + } + + public get is_directory(): boolean { + return this.mediafile.is_directory; + } + + public get is_file(): boolean { + return !this.is_directory; + } + + public get size(): string { + return this.mediafile.filesize; } public get prefix(): string { return this.mediafile.media_url_prefix; } - public get hidden(): boolean { - return this.mediafile.hidden; + public get url(): string { + return this.mediafile.url; } - public get fileName(): string { - return this.mediafile.mediafile.name; - } - - public get downloadUrl(): string { - return this.mediafile.downloadUrl; + public get type(): string { + return this.mediafile.mediafile ? this.mediafile.mediafile.type : ''; } public get pages(): number | null { - return this.mediafile.mediafile.pages; + return this.mediafile.mediafile ? this.mediafile.mediafile.pages : null; } - /** - * Determines if the file has the 'hidden' attribute - * @returns the hidden attribute, also 'hidden' if there is no file - * TODO Which is the expected behavior for 'no file'? - */ - public get is_hidden(): boolean { - return this.mediafile.hidden; - } - - public constructor(mediafile: Mediafile, listOfSpeakers?: ViewListOfSpeakers, uploader?: ViewUser) { + public constructor( + mediafile: Mediafile, + listOfSpeakers?: ViewListOfSpeakers, + parent?: ViewMediafile, + access_groups?: ViewGroup[], + inherited_access_groups?: ViewGroup[] + ) { super(Mediafile.COLLECTIONSTRING, mediafile, listOfSpeakers); - this._uploader = uploader; + this._parent = parent; + this._access_groups = access_groups; + this._inherited_access_groups = inherited_access_groups; } public formatForSearch(): SearchRepresentation { - const searchValues = [this.title]; - if (this.uploader) { - searchValues.push(this.uploader.full_name); - } - return searchValues; + return [this.title, this.path]; } public getDetailStateURL(): string { - return this.downloadUrl; + return this.url; } public getSlide(): ProjectorElementBuildDeskriptor { @@ -104,6 +125,12 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers }; } + public getDirectoryChain(): ViewMediafile[] { + const parentChain = this.parent ? this.parent.getDirectoryChain() : []; + parentChain.push(this); + return parentChain; + } + public isProjectable(): boolean { return this.isImage() || this.isPdf(); } @@ -156,19 +183,44 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers ].includes(this.type); } - /** - * Determine if the file is presentable - * - * @returns true or false - */ - public isPresentable(): boolean { - return this.isPdf() || this.isImage() || this.isVideo(); + public getIcon(): string { + if (this.is_directory) { + return 'folder'; + } else if (this.isPdf()) { + return 'picture_as_pdf'; + } else if (this.isImage()) { + return 'insert_photo'; + } else if (this.isFont()) { + return 'text_fields'; + } else if (this.isVideo()) { + return 'movie'; + } else { + return 'insert_drive_file'; + } } public updateDependencies(update: BaseViewModel): void { super.updateDependencies(update); - if (update instanceof ViewUser && this.uploader_id === update.id) { - this._uploader = update; + if (update instanceof ViewMediafile && update.id === this.parent_id) { + this._parent = update; + } else if (update instanceof ViewGroup) { + if (this.access_groups_id.includes(update.id)) { + const groupIndex = this.access_groups.findIndex(group => group.id === update.id); + if (groupIndex < 0) { + this.access_groups.push(update); + } else { + this.access_groups[groupIndex] = update; + } + } + + if (this.has_inherited_access_groups && (this.inherited_access_groups_id).includes(update.id)) { + const groupIndex = this.inherited_access_groups.findIndex(group => group.id === update.id); + if (groupIndex < 0) { + this.inherited_access_groups.push(update); + } else { + this.inherited_access_groups[groupIndex] = update; + } + } } } } diff --git a/client/src/app/site/mediafiles/services/mediafile-filter.service.ts b/client/src/app/site/mediafiles/services/mediafile-filter.service.ts deleted file mode 100644 index 32abd5442..000000000 --- a/client/src/app/site/mediafiles/services/mediafile-filter.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { BaseFilterListService, OsFilter } from 'app/core/ui-services/base-filter-list.service'; -import { OperatorService } from 'app/core/core-services/operator.service'; -import { StorageService } from 'app/core/core-services/storage.service'; -import { TranslateService } from '@ngx-translate/core'; -import { ViewMediafile } from '../models/view-mediafile'; - -/** - * Filter service for media files - */ -@Injectable({ - providedIn: 'root' -}) -export class MediafileFilterListService extends BaseFilterListService { - /** - * Constructor. - * Sets the filter options according to permissions - * - * @param store - * @param operator - * @param translate - */ - public constructor(store: StorageService, private operator: OperatorService, private translate: TranslateService) { - super('Mediafiles', store); - - this.operator.getUserObservable().subscribe(() => { - this.setFilterDefinitions(); - }); - } - - /** - * @returns the filter definition - */ - protected getFilterDefinitions(): OsFilter[] { - const pdfOption: OsFilter = { - property: 'type', - label: 'PDF', - options: [ - { - condition: 'application/pdf', - label: this.translate.instant('Is PDF file') - }, - { - condition: null, - label: this.translate.instant('Is no PDF file') - } - ] - }; - - const hiddenOptions: OsFilter = { - property: 'is_hidden', - label: this.translate.instant('Visibility'), - options: [ - { condition: true, label: this.translate.instant('is hidden') }, - { condition: false, label: this.translate.instant('is not hidden') } - ] - }; - - return this.operator.hasPerms('mediafiles.can_see_hidden') ? [hiddenOptions, pdfOption] : [pdfOption]; - } -} diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts index ac2c772ad..bdfbdbe72 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.ts @@ -1361,7 +1361,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit, * @param attachment the selected file */ public onClickAttachment(attachment: Mediafile): void { - window.open(attachment.downloadUrl); + window.open(attachment.url); } /** diff --git a/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.ts b/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.ts index f8c99b70a..401ce806a 100644 --- a/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.ts +++ b/client/src/app/site/motions/modules/motion-list/components/motion-export-dialog/motion-export-dialog.component.ts @@ -82,7 +82,7 @@ export class MotionExportDialogComponent implements OnInit { * @returns a list of availavble commentSections */ public get commentsToExport(): ViewMotionCommentSection[] { - return this.commentRepo.getSortedViewModelList(); + return this.commentRepo.getViewModelList(); } /** diff --git a/client/src/app/site/motions/services/motion-multiselect.service.ts b/client/src/app/site/motions/services/motion-multiselect.service.ts index d21be2174..a8a2de746 100644 --- a/client/src/app/site/motions/services/motion-multiselect.service.ts +++ b/client/src/app/site/motions/services/motion-multiselect.service.ts @@ -93,7 +93,7 @@ export class MotionMultiselectService { */ public async moveToItem(motions: ViewMotion[]): Promise { const title = this.translate.instant('This will move all selected motions as childs to:'); - const choices: (Displayable & Identifiable)[] = this.agendaRepo.getSortedViewModelList(); + const choices: (Displayable & Identifiable)[] = this.agendaRepo.getViewModelList(); const selectedChoice = await this.choiceService.open(title, choices); if (selectedChoice) { const requestData = { @@ -173,7 +173,7 @@ export class MotionMultiselectService { const clearChoice = this.translate.instant('No category'); const selectedChoice = await this.choiceService.open( title, - this.categoryRepo.getSortedViewModelList(), + this.categoryRepo.getViewModelList(), false, null, clearChoice @@ -199,12 +199,7 @@ export class MotionMultiselectService { 'This will add or remove the following submitters for all selected motions:' ); const choices = [this.translate.instant('Add'), this.translate.instant('Remove')]; - const selectedChoice = await this.choiceService.open( - title, - this.userRepo.getSortedViewModelList(), - true, - choices - ); + const selectedChoice = await this.choiceService.open(title, this.userRepo.getViewModelList(), true, choices); if (selectedChoice) { let requestData = null; if (selectedChoice.action === choices[0]) { @@ -247,12 +242,7 @@ export class MotionMultiselectService { this.translate.instant('Remove'), this.translate.instant('Clear tags') ]; - const selectedChoice = await this.choiceService.open( - title, - this.tagRepo.getSortedViewModelList(), - true, - choices - ); + const selectedChoice = await this.choiceService.open(title, this.tagRepo.getViewModelList(), true, choices); if (selectedChoice) { let requestData = null; if (selectedChoice.action === choices[0]) { @@ -301,7 +291,7 @@ export class MotionMultiselectService { const clearChoice = this.translate.instant('Clear motion block'); const selectedChoice = await this.choiceService.open( title, - this.motionBlockRepo.getSortedViewModelList(), + this.motionBlockRepo.getViewModelList(), false, null, clearChoice diff --git a/client/src/app/site/motions/services/motion-pdf.service.ts b/client/src/app/site/motions/services/motion-pdf.service.ts index 9569e8fd4..cb86a9ae1 100644 --- a/client/src/app/site/motions/services/motion-pdf.service.ts +++ b/client/src/app/site/motions/services/motion-pdf.service.ts @@ -154,7 +154,7 @@ export class MotionPdfService { } if (infoToExport && infoToExport.includes('allcomments')) { - commentsToExport = this.commentRepo.getSortedViewModelList().map(vm => vm.id); + commentsToExport = this.commentRepo.getViewModelList().map(vm => vm.id); } if (commentsToExport) { motionPdfContent.push(this.createComments(motion, commentsToExport)); diff --git a/client/src/app/site/topics/components/topic-detail/topic-detail.component.html b/client/src/app/site/topics/components/topic-detail/topic-detail.component.html index df4230628..6cbfe98f3 100644 --- a/client/src/app/site/topics/components/topic-detail/topic-detail.component.html +++ b/client/src/app/site/topics/components/topic-detail/topic-detail.component.html @@ -40,7 +40,7 @@ Attachments: - {{ file.getTitle() }} + {{ file.getTitle() }} diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.ts b/client/src/app/site/users/components/user-detail/user-detail.component.ts index dec6d0c95..e138feb0c 100644 --- a/client/src/app/site/users/components/user-detail/user-detail.component.ts +++ b/client/src/app/site/users/components/user-detail/user-detail.component.ts @@ -141,7 +141,7 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit { } this.createForm(); - this.groups = this.groupRepo.getSortedViewModelList().filter(group => group.id !== 1); + this.groups = this.groupRepo.getViewModelList().filter(group => group.id !== 1); this.groupRepo .getViewModelListObservable() .subscribe(groups => (this.groups = groups.filter(group => group.id !== 1))); diff --git a/client/src/app/site/users/components/user-list/user-list.component.ts b/client/src/app/site/users/components/user-list/user-list.component.ts index 23099e0f8..17d0be7a5 100644 --- a/client/src/app/site/users/components/user-list/user-list.component.ts +++ b/client/src/app/site/users/components/user-list/user-list.component.ts @@ -186,7 +186,7 @@ export class UserListComponent extends BaseListViewComponent implement super.setTitle('Participants'); // Initialize the groups - this.groups = this.groupRepo.getSortedViewModelList().filter(group => group.id !== 1); + this.groups = this.groupRepo.getViewModelList().filter(group => group.id !== 1); this.groupRepo .getViewModelListObservable() .subscribe(groups => (this.groups = groups.filter(group => group.id !== 1))); diff --git a/openslides/agenda/migrations/0006_auto_20190119_1425.py b/openslides/agenda/migrations/0006_auto_20190119_1425.py index 0166eb741..c4de2a12f 100644 --- a/openslides/agenda/migrations/0006_auto_20190119_1425.py +++ b/openslides/agenda/migrations/0006_auto_20190119_1425.py @@ -26,7 +26,7 @@ class Migration(migrations.Migration): model_name="speaker", name="user", field=models.ForeignKey( - on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, to=settings.AUTH_USER_MODEL, ), ), diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index 9d3f516cd..d8f992401 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -15,7 +15,7 @@ from openslides.utils.exceptions import OpenSlidesError from openslides.utils.models import RESTModelMixin from openslides.utils.utils import to_roman -from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE +from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE from .access_permissions import ItemAccessPermissions, ListOfSpeakersAccessPermissions @@ -445,7 +445,7 @@ class Speaker(RESTModelMixin, models.Model): objects = SpeakerManager() - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUPDATE) """ ForeinKey to the user who speaks. """ diff --git a/openslides/assignments/migrations/0006_auto_20190119_1425.py b/openslides/assignments/migrations/0006_auto_20190119_1425.py index 6840a54fa..4bfc8747d 100644 --- a/openslides/assignments/migrations/0006_auto_20190119_1425.py +++ b/openslides/assignments/migrations/0006_auto_20190119_1425.py @@ -24,7 +24,7 @@ class Migration(migrations.Migration): model_name="assignmentrelateduser", name="user", field=models.ForeignKey( - on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, to=settings.AUTH_USER_MODEL, ), ), diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 65735d29b..062246909 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -22,7 +22,7 @@ from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError from openslides.utils.models import RESTModelMixin -from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE +from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE from .access_permissions import AssignmentAccessPermissions @@ -38,7 +38,7 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model): ForeinKey to the assignment. """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUPDATE) """ ForeinKey to the user who is related to the assignment. """ diff --git a/openslides/core/migrations/0012_auto_20190119_1425.py b/openslides/core/migrations/0012_auto_20190119_1425.py index 3102e48b6..4adb5f0cd 100644 --- a/openslides/core/migrations/0012_auto_20190119_1425.py +++ b/openslides/core/migrations/0012_auto_20190119_1425.py @@ -16,7 +16,7 @@ class Migration(migrations.Migration): model_name="chatmessage", name="user", field=models.ForeignKey( - on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, to=settings.AUTH_USER_MODEL, ), ), diff --git a/openslides/mediafiles/access_permissions.py b/openslides/mediafiles/access_permissions.py index b74e7800e..45485d46e 100644 --- a/openslides/mediafiles/access_permissions.py +++ b/openslides/mediafiles/access_permissions.py @@ -1,7 +1,7 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, cast from ..utils.access_permissions import BaseAccessPermissions -from ..utils.auth import async_has_perm +from ..utils.auth import async_has_perm, async_in_some_groups class MediafileAccessPermissions(BaseAccessPermissions): @@ -18,15 +18,15 @@ class MediafileAccessPermissions(BaseAccessPermissions): Returns the restricted serialized data for the instance prepared for the user. Removes hidden mediafiles for some users. """ - # Parse data. - if await async_has_perm(user_id, "mediafiles.can_see") and await async_has_perm( - user_id, "mediafiles.can_see_hidden" - ): - data = full_data - elif await async_has_perm(user_id, "mediafiles.can_see"): - # Exclude hidden mediafiles. - data = [full for full in full_data if not full["hidden"]] - else: - data = [] + if not await async_has_perm(user_id, "mediafiles.can_see"): + return [] + + data = [] + for full in full_data: + access_groups = full["inherited_access_groups_id"] + if ( + isinstance(access_groups, bool) and access_groups + ) or await async_in_some_groups(user_id, cast(List[int], access_groups)): + data.append(full) return data diff --git a/openslides/mediafiles/apps.py b/openslides/mediafiles/apps.py index 7e9479830..59d7caefc 100644 --- a/openslides/mediafiles/apps.py +++ b/openslides/mediafiles/apps.py @@ -1,6 +1,8 @@ from typing import Any, Dict, Set from django.apps import AppConfig +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured class MediafilesAppConfig(AppConfig): @@ -17,6 +19,14 @@ class MediafilesAppConfig(AppConfig): from . import serializers # noqa from ..utils.access_permissions import required_user + # Validate, that the media_url is correct formatted: + # Must begin and end with a slash. It has to be at least "/". + media_url = settings.MEDIA_URL + if not media_url.startswith("/") or not media_url.endswith("/"): + raise ImproperlyConfigured( + "The MEDIA_URL setting must start and end with a slash" + ) + # Define projector elements. register_projector_slides() diff --git a/openslides/mediafiles/config.py b/openslides/mediafiles/config.py new file mode 100644 index 000000000..83f727904 --- /dev/null +++ b/openslides/mediafiles/config.py @@ -0,0 +1,54 @@ +from contextlib import contextmanager + +from ..core.config import config +from .models import Mediafile + + +@contextmanager +def watch_and_update_configs(): + """ + Watches each font and logo config for changes. If some mediafiles were updated + (also their parents, so some path changes) or were deleted, all affected configs + are updated. + """ + # 1) map logo and font config keys to mediafile ids + mediafiles = Mediafile.objects.get_full_queryset().all() + logos = build_mapping("logos_available", mediafiles) + fonts = build_mapping("fonts_available", mediafiles) + yield + # 2) update changed paths/urls + mediafiles = Mediafile.objects.get_full_queryset().all() + update_mapping(logos, mediafiles) + update_mapping(fonts, mediafiles) + + +def build_mapping(base_config_key, mediafiles): + """ Returns a map of config keys to medaifile ids """ + logos = {} + for key in config[base_config_key]: + url = config[key]["path"] + + for mediafile in mediafiles: + if mediafile.url == url: + logos[key] = mediafile.id + break + return logos + + +def update_mapping(mapping, mediafiles): + """ + Tries to get the mediafile from the id for a specific config field. + If the file was found and the path changed, the config is updated. If the + mediafile cound not be found, the config is cleared (mediafile deleted). + """ + for key, id in mapping.items(): + try: + mediafile = mediafiles.filter(pk=id)[0] + print(config[key]["path"], mediafile.url) + if config[key]["path"] != mediafile.url: + config[key] = { + "display_name": config[key]["display_name"], + "path": mediafile.url, + } + except IndexError: + config[key] = {"display_name": config[key]["display_name"], "path": ""} diff --git a/openslides/mediafiles/migrations/0004_directories_and_permissions_1.py b/openslides/mediafiles/migrations/0004_directories_and_permissions_1.py new file mode 100644 index 000000000..f5c54ff2c --- /dev/null +++ b/openslides/mediafiles/migrations/0004_directories_and_permissions_1.py @@ -0,0 +1,65 @@ +# Generated by Django 2.2.2 on 2019-06-28 06:06 + +from django.db import migrations, models + +import openslides.mediafiles.models +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [("mediafiles", "0003_auto_20190119_1425")] + + operations = [ + migrations.AlterModelOptions( + name="mediafile", + options={ + "default_permissions": (), + "ordering": ("title",), + "permissions": ( + ("can_see", "Can see the list of files"), + ("can_manage", "Can manage files"), + ), + }, + ), + migrations.RenameField( + model_name="mediafile", old_name="timestamp", new_name="create_timestamp" + ), + migrations.AddField( + model_name="mediafile", + name="access_groups", + field=models.ManyToManyField(blank=True, to="users.Group"), + ), + migrations.AddField( + model_name="mediafile", + name="is_directory", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="mediafile", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.SET_NULL, + related_name="children", + to="mediafiles.Mediafile", + ), + ), + migrations.AddField( + model_name="mediafile", + name="original_filename", + field=models.CharField(default="", max_length=255), + preserve_default=False, + ), + migrations.AlterField( + model_name="mediafile", + name="mediafile", + field=models.FileField( + null=True, upload_to=openslides.mediafiles.models.get_file_path + ), + ), + migrations.AlterField( + model_name="mediafile", name="title", field=models.CharField(max_length=255) + ), + ] diff --git a/openslides/mediafiles/migrations/0005_directories_and_permissions_2.py b/openslides/mediafiles/migrations/0005_directories_and_permissions_2.py new file mode 100644 index 000000000..71d70c0ca --- /dev/null +++ b/openslides/mediafiles/migrations/0005_directories_and_permissions_2.py @@ -0,0 +1,46 @@ +# Generated by Django 2.2.2 on 2019-06-28 06:09 + +import os.path + +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.db import migrations + + +def copy_filename(apps, schema_editor): + Mediafile = apps.get_model("mediafiles", "Mediafile") + for mediafile in Mediafile.objects.all(): + filename = os.path.basename(mediafile.mediafile.name) + mediafile.original_filename = filename + mediafile.save(skip_autoupdate=True) + + +def set_groups_and_delete_old_permissions(apps, schema_editor): + Mediafile = apps.get_model("mediafiles", "Mediafile") + mediafile_content_type = ContentType.objects.get(model="mediafile") + try: + can_see_hidden = Permission.objects.get( + codename="can_see_hidden", content_type=mediafile_content_type + ) + group_ids = [group.id for group in can_see_hidden.group_set.all()] + for mediafile in Mediafile.objects.filter(hidden=True): + mediafile.access_groups.set(group_ids) + mediafile.save(skip_autoupdate=True) + + # Delete permissions + can_see_hidden.delete() + Permission.objects.filter( + codename="can_upload", content_type=mediafile_content_type + ).delete() + except Permission.DoesNotExist: + pass + + +class Migration(migrations.Migration): + + dependencies = [("mediafiles", "0004_directories_and_permissions_1")] + + operations = [ + migrations.RunPython(copy_filename), + migrations.RunPython(set_groups_and_delete_old_permissions), + ] diff --git a/openslides/mediafiles/migrations/0006_directories_and_permissions_3.py b/openslides/mediafiles/migrations/0006_directories_and_permissions_3.py new file mode 100644 index 000000000..59629c51e --- /dev/null +++ b/openslides/mediafiles/migrations/0006_directories_and_permissions_3.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2.2 on 2019-06-28 06:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0010_auto_20190119_1447"), + ("mediafiles", "0005_directories_and_permissions_2"), + ] + + operations = [ + migrations.RemoveField(model_name="mediafile", name="hidden"), + migrations.RemoveField(model_name="mediafile", name="uploader"), + ] diff --git a/openslides/mediafiles/models.py b/openslides/mediafiles/models.py index 4f8972ffe..53956a696 100644 --- a/openslides/mediafiles/models.py +++ b/openslides/mediafiles/models.py @@ -1,10 +1,14 @@ +import os +import uuid +from typing import List, cast + from django.conf import settings from django.db import models from ..agenda.mixins import ListOfSpeakersMixin from ..core.config import config -from ..utils.autoupdate import inform_changed_data -from ..utils.models import SET_NULL_AND_AUTOUPDATE, RESTModelMixin +from ..utils.models import RESTModelMixin +from ..utils.rest_api import ValidationError from .access_permissions import MediafileAccessPermissions @@ -18,7 +22,21 @@ class MediafileManager(models.Manager): Returns the normal queryset with all mediafiles. In the background all related list of speakers are prefetched from the database. """ - return self.get_queryset().prefetch_related("lists_of_speakers") + return self.get_queryset().prefetch_related( + "lists_of_speakers", "parent", "access_groups" + ) + + def delete(self, *args, **kwargs): + raise RuntimeError( + "Do not use the querysets delete function. Please delete every mediafile on it's own." + ) + + +def get_file_path(mediafile, filename): + mediafile.original_filename = filename + ext = filename.split(".")[-1] + filename = "%s.%s" % (uuid.uuid4(), ext) + return os.path.join("file", filename) class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model): @@ -30,55 +48,140 @@ class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model): access_permissions = MediafileAccessPermissions() can_see_permission = "mediafiles.can_see" - mediafile = models.FileField(upload_to="file") + mediafile = models.FileField(upload_to=get_file_path, null=True) """ See https://docs.djangoproject.com/en/dev/ref/models/fields/#filefield for more information. """ - title = models.CharField(max_length=255, unique=True) + title = models.CharField(max_length=255) """A string representing the title of the file.""" - uploader = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True - ) - """A user – the uploader of a file.""" + original_filename = models.CharField(max_length=255) - hidden = models.BooleanField(default=False) - """Whether or not this mediafile should be marked as hidden""" - - timestamp = models.DateTimeField(auto_now_add=True) + create_timestamp = models.DateTimeField(auto_now_add=True) """A DateTimeField to save the upload date and time.""" + is_directory = models.BooleanField(default=False) + + parent = models.ForeignKey( + "self", + # The on_delete should be CASCADE_AND_AUTOUPDATE, but we do + # have to delete the actual file from every mediafile to ensure + # cleaning up the server files. This is ensured by the custom delete + # method of every mediafile. Do not use the delete method of the + # mediafile manager. + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="children", + ) + + access_groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True) + class Meta: """ Meta class for the mediafile model. """ - ordering = ["title"] + ordering = ("title",) default_permissions = () permissions = ( ("can_see", "Can see the list of files"), - ("can_see_hidden", "Can see hidden files"), - ("can_upload", "Can upload files"), ("can_manage", "Can manage files"), ) + def create(self, *args, **kwargs): + self.validate_unique() + return super().create(*args, **kwargs) + + def save(self, *args, **kwargs): + self.validate_unique() + return super().save(*args, **kwargs) + + def validate_unique(self): + """ + `unique_together` is not working with foreign keys with possible `null` values. + So we do need to check this here. + """ + if ( + Mediafile.objects.exclude(pk=self.pk) + .filter(title=self.title, parent=self.parent) + .exists() + ): + raise ValidationError( + {"detail": "A mediafile with this title already exists in this folder."} + ) + def __str__(self): """ Method for representation. """ return self.title - def save(self, *args, **kwargs): + def delete(self, skip_autoupdate=False): + mediafiles_to_delete = self.get_children_deep() + mediafiles_to_delete.append(self) + for mediafile in mediafiles_to_delete: + if mediafile.is_file: + # To avoid Django calling save() and triggering autoupdate we do not + # use the builtin method mediafile.mediafile.delete() but call + # mediafile.mediafile.storage.delete(...) directly. This may have + # unattended side effects so be careful especially when accessing files + # on server via Django methods (file, open(), save(), ...). + mediafile.mediafile.storage.delete(mediafile.mediafile.name) + mediafile._db_delete(skip_autoupdate=skip_autoupdate) + + def _db_delete(self, *args, **kwargs): + """ Captures the original .delete() method. """ + return super().delete(*args, **kwargs) + + def get_children_deep(self): + """ Returns all children and all children of childrens and so forth. """ + children = [] + for child in self.children.all(): + children.append(child) + children.extend(child.get_children_deep()) + return children + + @property + def path(self): + name = (self.title + "/") if self.is_directory else self.original_filename + if self.parent: + return self.parent.path + name + else: + return name + + @property + def url(self): + return settings.MEDIA_URL + self.path + + @property + def inherited_access_groups_id(self): """ - Saves mediafile (mainly on create and update requests). + True: all groups + False: no permissions + List[int]: Groups with permissions """ - result = super().save(*args, **kwargs) - # Send uploader via autoupdate because users without permission - # to see users may not have it but can get it now. - inform_changed_data(self.uploader) - return result + own_access_groups = [group.id for group in self.access_groups.all()] + if not self.parent: + return own_access_groups or True # either some groups or all + + access_groups = self.parent.inherited_access_groups_id + if len(own_access_groups) > 0: + if isinstance(access_groups, bool) and access_groups: + return own_access_groups + elif isinstance(access_groups, bool) and not access_groups: + return False + else: # List[int] + access_groups = [ + id + for id in cast(List[int], access_groups) + if id in own_access_groups + ] + return access_groups or False + else: + return access_groups # We do not have restrictions, copy from parent. def get_filesize(self): """ @@ -89,6 +192,9 @@ class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model): size = self.mediafile.size except OSError: size_string = "unknown" + except ValueError: + # happens, if this is a directory and no file exists + return None else: if size < 1024: size_string = "< 1 kB" @@ -100,17 +206,31 @@ class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model): size_string = "%d kB" % kB return size_string + @property def is_logo(self): + if self.is_directory: + return False for key in config["logos_available"]: - if config[key]["path"] == self.mediafile.url: + if config[key]["path"] == self.url: return True return False + @property def is_font(self): + if self.is_directory: + return False for key in config["fonts_available"]: - if config[key]["path"] == self.mediafile.url: + if config[key]["path"] == self.url: return True return False + @property + def is_special_file(self): + return self.is_logo or self.is_font + + @property + def is_file(self): + return not self.is_directory + def get_list_of_speakers_title_information(self): return {"title": self.title} diff --git a/openslides/mediafiles/projector.py b/openslides/mediafiles/projector.py index 8f8b2af97..e2b0adb73 100644 --- a/openslides/mediafiles/projector.py +++ b/openslides/mediafiles/projector.py @@ -31,7 +31,7 @@ async def mediafile_slide( ) return { - "path": mediafile["mediafile"]["name"], + "path": mediafile["path"], "type": mediafile["mediafile"]["type"], "media_url_prefix": mediafile["media_url_prefix"], } diff --git a/openslides/mediafiles/serializers.py b/openslides/mediafiles/serializers.py index 66c0108ca..d1f98295e 100644 --- a/openslides/mediafiles/serializers.py +++ b/openslides/mediafiles/serializers.py @@ -5,7 +5,14 @@ from django.db import models as dbmodels from PyPDF2 import PdfFileReader from PyPDF2.utils import PdfReadError -from ..utils.rest_api import FileField, ModelSerializer, SerializerMethodField +from ..utils.auth import get_group_model +from ..utils.rest_api import ( + FileField, + IdPrimaryKeyRelatedField, + ModelSerializer, + SerializerMethodField, + ValidationError, +) from .models import Mediafile @@ -16,13 +23,22 @@ class AngularCompatibleFileField(FileField): return super(AngularCompatibleFileField, self).to_internal_value(data) def to_representation(self, value): - if value is None: + if value is None or value.name is None: return None - filetype = mimetypes.guess_type(value.path)[0] + filetype = mimetypes.guess_type(value.name)[0] result = {"name": value.name, "type": filetype} if filetype == "application/pdf": try: - result["pages"] = PdfFileReader(open(value.path, "rb")).getNumPages() + if ( + settings.DEFAULT_FILE_STORAGE + == "storages.backends.sftpstorage.SFTPStorage" + ): + remote_path = value.storage._remote_path(value.name) + file_handle = value.storage.sftp.open(remote_path, mode="rb") + else: + file_handle = open(value.path, "rb") + + result["pages"] = PdfFileReader(file_handle).getNumPages() except FileNotFoundError: # File was deleted from server. Set 'pages' to 0. result["pages"] = 0 @@ -40,6 +56,9 @@ class MediafileSerializer(ModelSerializer): media_url_prefix = SerializerMethodField() filesize = SerializerMethodField() + access_groups = IdPrimaryKeyRelatedField( + many=True, required=False, queryset=get_group_model().objects.all() + ) def __init__(self, *args, **kwargs): """ @@ -48,6 +67,8 @@ class MediafileSerializer(ModelSerializer): """ super(MediafileSerializer, self).__init__(*args, **kwargs) self.serializer_field_mapping[dbmodels.FileField] = AngularCompatibleFileField + + # Make some fields read-oinly for updates (not creation) if self.instance is not None: self.fields["mediafile"].read_only = True @@ -58,13 +79,49 @@ class MediafileSerializer(ModelSerializer): "title", "mediafile", "media_url_prefix", - "uploader", "filesize", - "hidden", - "timestamp", + "access_groups", + "create_timestamp", + "is_directory", + "path", + "parent", "list_of_speakers_id", + "inherited_access_groups_id", ) + read_only_fields = ("path",) + + def validate(self, data): + title = data.get("title") + if title is not None and not title: + raise ValidationError({"detail": "The title must not be empty"}) + + parent = data.get("parent") + if parent and not parent.is_directory: + raise ValidationError({"detail": "parent must be a directory."}) + + if data.get("is_directory") and "/" in data.get("title", ""): + raise ValidationError( + {"detail": 'The name contains invalid characters: "/"'} + ) + + return super().validate(data) + + def create(self, validated_data): + access_groups = validated_data.pop("access_groups", []) + mediafile = super().create(validated_data) + mediafile.access_groups.set(access_groups) + mediafile.save() + return mediafile + + def update(self, instance, validated_data): + # remove is_directory, create_timestamp and parent from validated_data + # to prevent updating them (mediafile is ensured in the constructor) + validated_data.pop("is_directory", None) + validated_data.pop("create_timestamp", None) + validated_data.pop("parent", None) + return super().update(instance, validated_data) + def get_filesize(self, mediafile): return mediafile.get_filesize() diff --git a/openslides/mediafiles/views.py b/openslides/mediafiles/views.py index f7a88c492..157787c14 100644 --- a/openslides/mediafiles/views.py +++ b/openslides/mediafiles/views.py @@ -1,10 +1,14 @@ from django.http import HttpResponseForbidden, HttpResponseNotFound +from django.http.request import QueryDict from django.views.static import serve -from ..core.config import config -from ..utils.auth import has_perm -from ..utils.rest_api import ModelViewSet, ValidationError +from openslides.core.models import Projector + +from ..utils.auth import has_perm, in_some_groups +from ..utils.autoupdate import inform_changed_data +from ..utils.rest_api import ModelViewSet, Response, ValidationError, list_route from .access_permissions import MediafileAccessPermissions +from .config import watch_and_update_configs from .models import Mediafile @@ -26,21 +30,9 @@ class MediafileViewSet(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, "mediafiles.can_see") - elif self.action == "create": - result = has_perm(self.request.user, "mediafiles.can_see") and has_perm( - self.request.user, "mediafiles.can_upload" - ) - elif self.action in ("partial_update", "update"): - result = ( - has_perm(self.request.user, "mediafiles.can_see") - and has_perm(self.request.user, "mediafiles.can_upload") - and has_perm(self.request.user, "mediafiles.can_manage") - ) - elif self.action == "destroy": + elif self.action in ("create", "partial_update", "update", "move", "destroy"): result = has_perm(self.request.user, "mediafiles.can_see") and has_perm( self.request.user, "mediafiles.can_manage" ) @@ -52,62 +44,166 @@ class MediafileViewSet(ModelViewSet): """ Customized view endpoint to upload a new file. """ - # Check permission to check if the uploader has to be changed. - uploader_id = self.request.data.get("uploader_id") - if ( - uploader_id - and not has_perm(request.user, "mediafiles.can_manage") - and str(self.request.user.pk) != str(uploader_id) - ): - self.permission_denied(request) - if not self.request.data.get("mediafile"): + # The form data may send the groups_id + if isinstance(request.data, QueryDict): + request.data._mutable = True + + # convert formdata string ", id>" to a list of numbers. + if "access_groups_id" in request.data and isinstance(request.data, QueryDict): + access_groups_id = request.data.get("access_groups_id") + if access_groups_id: + request.data.setlist( + "access_groups_id", [int(x) for x in access_groups_id.split(", ")] + ) + else: + del request.data["access_groups_id"] + + is_directory = bool(request.data.get("is_directory", False)) + if is_directory and request.data.get("mediafile"): + raise ValidationError( + {"detail": "Either create a path or a file, but not both"} + ) + if not request.data.get("mediafile") and not is_directory: raise ValidationError({"detail": "You forgot to provide a file."}) + return super().create(request, *args, **kwargs) - def destroy(self, request, *args, **kwargs): - """ - Customized view endpoint to delete uploaded files. + def destroy(self, *args, **kwargs): + with watch_and_update_configs(): + response = super().destroy(*args, **kwargs) + return response - Does also delete the file from filesystem. - """ - # To avoid Django calling save() and triggering autoupdate we do not - # use the builtin method mediafile.mediafile.delete() but call - # mediafile.mediafile.storage.delete(...) directly. This may have - # unattended side effects so be careful especially when accessing files - # on server via Django methods (file, open(), save(), ...). - mediafile = self.get_object() - mediafile.mediafile.storage.delete(mediafile.mediafile.name) + def update(self, *args, **kwargs): + with watch_and_update_configs(): + response = super().update(*args, **kwargs) + inform_changed_data(self.get_object().get_children_deep()) + return response - # check if the file was used as a logo or font - for logo in config["logos_available"]: - if config[logo]["path"] == mediafile.mediafile.url: - config[logo] = { - "display_name": config[logo]["display_name"], - "path": "", - } - for font in config["fonts_available"]: - if config[font]["path"] == mediafile.mediafile.url: - config[font] = { - "display_name": config[font]["display_name"], - "default": config[font]["default"], - "path": "", - } - return super().destroy(request, *args, **kwargs) + @list_route(methods=["post"]) + def move(self, request): + """ + { + ids: [, , ...], + directory_id: + } + Move to the given directory_id. This will raise an error, if + the move would be recursive. + """ + + # 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"}) + directory_id = request.data.get("directory_id") + if directory_id is not None and not isinstance(directory_id, int): + raise ValidationError({"detail": "The directory_id must be an int"}) + if directory_id is None: + directory = None + else: + try: + directory = Mediafile.objects.get(pk=directory_id, is_directory=True) + except Mediafile.DoesNotExist: + raise ValidationError({"detail": "The directory does not exist"}) + + ids_set = set(ids) # keep them in a set for fast lookup + ids = list(ids_set) # make ids unique + + 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"} + ) + + # Search for valid parents (None is not included, but also safe!) + if directory is not None: + valid_parent_ids = set() + + queue = list(Mediafile.objects.filter(parent=None, is_directory=True)) + for mediafile in queue: + if mediafile.pk in ids_set: + continue # not valid, because this is in the input data + valid_parent_ids.add(mediafile.pk) + queue.extend( + list(Mediafile.objects.filter(parent=mediafile, is_directory=True)) + ) + + if directory_id not in valid_parent_ids: + raise ValidationError({"detail": "The directory is not valid"}) + + # Ok, update all mediafiles + with watch_and_update_configs(): + for mediafile in mediafiles: + mediafile.parent = directory + mediafile.save(skip_autoupdate=True) + if directory is None: + inform_changed_data(Mediafile.objects.all()) + else: + inform_changed_data(directory.get_children_deep()) + + return Response() + + +def get_mediafile(request, path): + """ + returnes the mediafile for the requested path and checks, if the user is + valid to retrieve the mediafile. If not, None will be returned. + A user must have all access permissions for all folders the the file itself, + or the file is a special file (logo or font), then it is always returned. + + If the mediafile cannot be found, a Mediafile.DoesNotExist will be raised. + """ + if not path: + raise Mediafile.DoesNotExist() + parts = path.split("/") + parent = None + can_see = has_perm(request.user, "mediafiles.can_see") + for i, part in enumerate(parts): + is_directory = i < len(parts) - 1 + if is_directory: + mediafile = Mediafile.objects.get( + parent=parent, is_directory=is_directory, title=part + ) + else: + mediafile = Mediafile.objects.get( + parent=parent, is_directory=is_directory, original_filename=part + ) + if mediafile.access_groups.exists() and not in_some_groups( + request.user.id, [group.id for group in mediafile.access_groups.all()] + ): + can_see = False + parent = mediafile + + # Check, if this file is projected + is_projected = False + for projector in Projector.objects.all(): + for element in projector.elements: + name = element.get("name") + id = element.get("id") + if name == "mediafiles/mediafile" and id == mediafile.id: + is_projected = True + break + + if not can_see and not mediafile.is_special_file and not is_projected: + mediafile = None + + return mediafile def protected_serve(request, path, document_root=None, show_indexes=False): try: - mediafile = Mediafile.objects.get(mediafile=path) + mediafile = get_mediafile(request, path) except Mediafile.DoesNotExist: return HttpResponseNotFound(content="Not found.") - can_see = has_perm(request.user, "mediafiles.can_see") - is_special_file = mediafile.is_logo() or mediafile.is_font() - is_hidden_but_no_perms = mediafile.hidden and not has_perm( - request.user, "mediafiles.can_see_hidden" - ) - - if not is_special_file and (not can_see or is_hidden_but_no_perms): - return HttpResponseForbidden(content="Forbidden.") + if mediafile: + return serve(request, mediafile.mediafile.name, document_root, show_indexes) else: - return serve(request, path, document_root, show_indexes) + return HttpResponseForbidden(content="Forbidden.") diff --git a/openslides/motions/migrations/0020_auto_20190119_1425.py b/openslides/motions/migrations/0020_auto_20190119_1425.py index cc57844ec..7da9d1929 100644 --- a/openslides/motions/migrations/0020_auto_20190119_1425.py +++ b/openslides/motions/migrations/0020_auto_20190119_1425.py @@ -88,7 +88,7 @@ class Migration(migrations.Migration): model_name="motionchangerecommendation", name="motion", field=models.ForeignKey( - on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, related_name="change_recommendations", to="motions.Motion", ), @@ -106,7 +106,7 @@ class Migration(migrations.Migration): model_name="submitter", name="user", field=models.ForeignKey( - on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, to=settings.AUTH_USER_MODEL, ), ), diff --git a/openslides/motions/models.py b/openslides/motions/models.py index dc9327d88..73be51e51 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -19,7 +19,7 @@ from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError from openslides.utils.models import RESTModelMixin -from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE +from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE from .access_permissions import ( CategoryAccessPermissions, MotionAccessPermissions, @@ -657,7 +657,7 @@ class Submitter(RESTModelMixin, models.Model): Use custom Manager. """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUPDATE) """ ForeignKey to the user who is the submitter. """ @@ -707,7 +707,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model): objects = MotionChangeRecommendationManager() motion = models.ForeignKey( - Motion, on_delete=CASCADE_AND_AUTOUODATE, related_name="change_recommendations" + Motion, on_delete=CASCADE_AND_AUTOUPDATE, related_name="change_recommendations" ) """The motion to which the change recommendation belongs.""" diff --git a/openslides/users/migrations/0010_auto_20190119_1447.py b/openslides/users/migrations/0010_auto_20190119_1447.py index c44a78480..b9ddea704 100644 --- a/openslides/users/migrations/0010_auto_20190119_1447.py +++ b/openslides/users/migrations/0010_auto_20190119_1447.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): model_name="personalnote", name="user", field=models.OneToOneField( - on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE, to=settings.AUTH_USER_MODEL, ), ) diff --git a/openslides/users/models.py b/openslides/users/models.py index f63374181..275277304 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -20,7 +20,7 @@ from jsonfield import JSONField from ..core.config import config from ..utils.auth import GROUP_ADMIN_PK -from ..utils.models import CASCADE_AND_AUTOUODATE, RESTModelMixin +from ..utils.models import CASCADE_AND_AUTOUPDATE, RESTModelMixin from .access_permissions import ( GroupAccessPermissions, PersonalNoteAccessPermissions, @@ -351,7 +351,7 @@ class PersonalNote(RESTModelMixin, models.Model): objects = PersonalNoteManager() - user = models.OneToOneField(User, on_delete=CASCADE_AND_AUTOUODATE) + user = models.OneToOneField(User, on_delete=CASCADE_AND_AUTOUPDATE) notes = JSONField() class Meta: diff --git a/openslides/users/signals.py b/openslides/users/signals.py index 953af6c0e..adb94e344 100644 --- a/openslides/users/signals.py +++ b/openslides/users/signals.py @@ -52,8 +52,6 @@ def create_builtin_groups_and_admin(**kwargs): "core.can_see_projector", "mediafiles.can_manage", "mediafiles.can_see", - "mediafiles.can_see_hidden", - "mediafiles.can_upload", "motions.can_create", "motions.can_create_amendments", "motions.can_manage", @@ -145,8 +143,6 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict["core.can_manage_tags"], permission_dict["mediafiles.can_see"], permission_dict["mediafiles.can_manage"], - permission_dict["mediafiles.can_upload"], - permission_dict["mediafiles.can_see_hidden"], permission_dict["motions.can_see"], permission_dict["motions.can_see_internal"], permission_dict["motions.can_create"], diff --git a/openslides/utils/models.py b/openslides/utils/models.py index f23c147fb..c447ccbfc 100644 --- a/openslides/utils/models.py +++ b/openslides/utils/models.py @@ -191,7 +191,7 @@ def SET_NULL_AND_AUTOUPDATE( models.SET_NULL(collector, field, sub_objs, using) -def CASCADE_AND_AUTOUODATE( +def CASCADE_AND_AUTOUPDATE( collector: Any, field: Any, sub_objs: Any, using: Any ) -> None: """ diff --git a/tests/integration/mediafiles/test_viewset.py b/tests/integration/mediafiles/test_viewset.py index 902116863..4d5617745 100644 --- a/tests/integration/mediafiles/test_viewset.py +++ b/tests/integration/mediafiles/test_viewset.py @@ -1,7 +1,11 @@ import pytest from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient from openslides.mediafiles.models import Mediafile +from openslides.utils.test import TestCase from ..helpers import count_queries @@ -12,6 +16,8 @@ def test_mediafiles_db_queries(): Tests that only the following db queries are done: * 1 requests to get the list of all files * 1 request to get all lists of speakers. + * 1 request to get all groups + * 1 request to prefetch parents """ for index in range(10): Mediafile.objects.create( @@ -19,4 +25,190 @@ def test_mediafiles_db_queries(): mediafile=SimpleUploadedFile(f"some_file{index}", b"some content."), ) - assert count_queries(Mediafile.get_elements) == 2 + assert count_queries(Mediafile.get_elements) == 4 + + +class TestCreation(TestCase): + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.file = SimpleUploadedFile("some_file.ext", b"some content.") + + def test_simple_file(self): + response = self.client.post( + reverse("mediafile-list"), + {"title": "test_title_ahyo1uifoo9Aiph2av5a", "mediafile": self.file}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + mediafile = Mediafile.objects.get() + self.assertEqual(mediafile.title, "test_title_ahyo1uifoo9Aiph2av5a") + self.assertFalse(mediafile.is_directory) + self.assertTrue(mediafile.mediafile.name) + self.assertEqual(mediafile.path, mediafile.original_filename) + + def test_simple_directory(self): + response = self.client.post( + reverse("mediafile-list"), + {"title": "test_title_ahyo1uifoo9Aiph2av5a", "is_directory": True}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + mediafile = Mediafile.objects.get() + self.assertEqual(mediafile.title, "test_title_ahyo1uifoo9Aiph2av5a") + self.assertTrue(mediafile.is_directory) + self.assertEqual(mediafile.mediafile.name, "") + self.assertEqual(mediafile.path, mediafile.title + "/") + + def test_file_and_directory(self): + response = self.client.post( + reverse("mediafile-list"), + { + "title": "test_title_ahyo1uifoo9Aiph2av5a", + "is_directory": True, + "mediafile": self.file, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Mediafile.objects.exists()) + + def test_mediafile_twice(self): + title = "test_title_kFJq83fjmqo2babfqk3f" + Mediafile.objects.create(is_directory=True, title=title) + response = self.client.post( + reverse("mediafile-list"), {"title": title, "is_directory": True} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Mediafile.objects.count(), 1) + + def test_without_mediafile(self): + response = self.client.post(reverse("mediafile-list"), {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Mediafile.objects.exists()) + + def test_without_title(self): + response = self.client.post(reverse("mediafile-list"), {"is_directory": True}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Mediafile.objects.exists()) + + def test_with_empty_title(self): + response = self.client.post( + reverse("mediafile-list"), {"is_directory": True, "title": ""} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Mediafile.objects.exists()) + + def test_directory_with_slash(self): + response = self.client.post( + reverse("mediafile-list"), + {"title": "test_title_with_/", "is_directory": True}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Mediafile.objects.exists()) + + def test_with_parent(self): + parent_title = "test_title_3q0cqghZRFewocjwferT" + title = "test_title_gF3if8jmvrbnwdksg4je" + Mediafile.objects.create(is_directory=True, title=parent_title) + response = self.client.post( + reverse("mediafile-list"), + {"title": title, "is_directory": True, "parent_id": 1}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Mediafile.objects.count(), 2) + mediafile = Mediafile.objects.get(title="test_title_gF3if8jmvrbnwdksg4je") + self.assertEqual(mediafile.parent.title, "test_title_3q0cqghZRFewocjwferT") + self.assertEqual(mediafile.path, parent_title + "/" + title + "/") + + def test_with_file_as_parent(self): + Mediafile.objects.create( + title="test_title_qejOVM84gw8ghwpKnqeg", mediafile=self.file + ) + response = self.client.post( + reverse("mediafile-list"), + { + "title": "test_title_poejvvlmmorsgeroemr9", + "is_directory": True, + "parent_id": 1, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Mediafile.objects.count(), 1) + + def test_with_access_groups(self): + response = self.client.post( + reverse("mediafile-list"), + { + "title": "test_title_dggjwevBnUngelkdviom", + "is_directory": True, + # This is the format, if it would be provided by JS `FormData`. + "access_groups_id": "2, 4", + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(Mediafile.objects.exists()) + mediafile = Mediafile.objects.get() + self.assertEqual( + sorted([group.id for group in mediafile.access_groups.all()]), [2, 4] + ) + + +# TODO: List and retrieve + + +class TestUpdate(TestCase): + """ + Tree: + -dir + -mediafileA + -mediafileB + """ + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + self.dir = Mediafile.objects.create(title="dir", is_directory=True) + self.fileA = SimpleUploadedFile("some_fileA.ext", b"some content.") + self.mediafileA = Mediafile.objects.create( + title="mediafileA", mediafile=self.fileA, parent=self.dir + ) + self.fileB = SimpleUploadedFile("some_fileB.ext", b"some content.") + self.mediafileB = Mediafile.objects.create( + title="mediafileB", mediafile=self.fileB + ) + + def test_update(self): + response = self.client.put( + reverse("mediafile-detail", args=[self.mediafileA.pk]), + {"title": "test_title_gpasgrmg*miGUM)EAyGO", "access_groups_id": [2, 4]}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + mediafile = Mediafile.objects.get(pk=self.mediafileA.pk) + self.assertEqual(mediafile.title, "test_title_gpasgrmg*miGUM)EAyGO") + self.assertEqual(mediafile.path, "dir/some_fileA.ext") + self.assertEqual( + sorted([group.id for group in mediafile.access_groups.all()]), [2, 4] + ) + + def test_update_directory(self): + response = self.client.put( + reverse("mediafile-detail", args=[self.dir.pk]), + {"title": "test_title_seklMOIGGihdjJBNaflkklnlg"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + dir = Mediafile.objects.get(pk=self.dir.pk) + self.assertEqual(dir.title, "test_title_seklMOIGGihdjJBNaflkklnlg") + mediafile = Mediafile.objects.get(pk=self.mediafileA.pk) + self.assertEqual( + mediafile.path, "test_title_seklMOIGGihdjJBNaflkklnlg/some_fileA.ext" + ) + + def test_update_parent_id(self): + """ Assert, that the parent id does not change """ + response = self.client.put( + reverse("mediafile-detail", args=[self.mediafileA.pk]), + {"title": self.mediafileA.title, "parent_id": None}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + mediafile = Mediafile.objects.get(pk=self.mediafileA.pk) + self.assertTrue(mediafile.parent) + self.assertEqual(mediafile.parent.pk, self.dir.pk) diff --git a/tests/integration/users/test_viewset.py b/tests/integration/users/test_viewset.py index 08edaa9af..1d20c005c 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -514,8 +514,6 @@ class GroupUpdate(TestCase): "core.can_see_projector", "mediafiles.can_manage", "mediafiles.can_see", - "mediafiles.can_see_hidden", - "mediafiles.can_upload", "motions.can_create", "motions.can_manage", "motions.can_see",
HiddenAccess permissions - + + + + {{ mediafile.getIcon() }} + + + {{ mediafile.title }} + + + {{ mediafile.title }} + + + {{ mediafile.size }} + {{ mediafile.access_groups }} + +
+ text_fields + insert_photo +
+
+ +