Merge pull request #4821 from FinnStutzenstein/directories
Directories and access permissions for mediafiles
This commit is contained in:
commit
3fd519e0d4
@ -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<any, any, any> } = {};
|
||||
|
||||
// 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
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
*
|
||||
* It's used to debounce messages on the sortedViewModelListSubject
|
||||
*/
|
||||
private readonly viewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]);
|
||||
protected readonly unsafeViewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>(null);
|
||||
|
||||
/**
|
||||
* Observable subject for the sorted view model list.
|
||||
@ -39,7 +39,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
* updates, if e.g. an autoupdate with a lot motions come in. The result is just one
|
||||
* update of the new list instead of many unnecessary updates.
|
||||
*/
|
||||
protected readonly sortedViewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]);
|
||||
protected readonly viewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]);
|
||||
|
||||
/**
|
||||
* Observable subject for any changes of view models.
|
||||
@ -94,8 +94,10 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
// All data is piped through an auditTime of 1ms. This is to prevent massive
|
||||
// updates, if e.g. an autoupdate with a lot motions come in. The result is just one
|
||||
// update of the new list instead of many unnecessary updates.
|
||||
this.viewModelListSubject.pipe(auditTime(1)).subscribe(models => {
|
||||
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<V extends BaseViewModel & T, M extends Base
|
||||
this.DS.clearObservable.subscribe(() => this.clear());
|
||||
this.translate.onLangChange.subscribe(change => {
|
||||
this.languageCollator = new Intl.Collator(change.lang);
|
||||
this.updateViewModelListObservable();
|
||||
});
|
||||
|
||||
this.loadInitialFromDS();
|
||||
if (this.unsafeViewModelListSubject.value) {
|
||||
this.viewModelListSubject.next(this.unsafeViewModelListSubject.value.sort(this.viewModelSortFn));
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<V extends BaseViewModel & T, M extends Base
|
||||
delete this.viewModelStore[id];
|
||||
this.updateViewModelObservable(id);
|
||||
});
|
||||
this.updateViewModelListObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or creates all given models in the repository (internally, no requests).
|
||||
* Informs all subjects.
|
||||
* Changes need to be committed via `commitUpdate()`.
|
||||
*
|
||||
* @param ids All model ids.
|
||||
*/
|
||||
@ -148,15 +137,15 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
this.viewModelStore[id] = this.createViewModelWithTitles(this.DS.get(this.collectionString, id));
|
||||
this.updateViewModelObservable(id);
|
||||
});
|
||||
this.updateViewModelListObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all models in this repository with all changed models.
|
||||
*
|
||||
* @param changedModels A mapping of collections to ids of all changed models.
|
||||
* @returns if at least one model was affected.
|
||||
*/
|
||||
public updateDependencies(changedModels: CollectionIds): void {
|
||||
public updateDependencies(changedModels: CollectionIds): boolean {
|
||||
if (!this.depsModelCtors || this.depsModelCtors.length === 0) {
|
||||
return;
|
||||
}
|
||||
@ -184,8 +173,8 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
viewModels.forEach(ownViewModel => {
|
||||
this.updateViewModelObservable(ownViewModel.id);
|
||||
});
|
||||
this.updateViewModelListObservable();
|
||||
}
|
||||
return somethingUpdated;
|
||||
}
|
||||
|
||||
public getListTitle: (titleInformation: T) => string = (titleInformation: T) => {
|
||||
@ -292,7 +281,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
*/
|
||||
public setSortFunction(fn: (a: V, b: V) => number): void {
|
||||
this.viewModelSortFn = fn;
|
||||
this.updateViewModelListObservable();
|
||||
this.commitUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -316,7 +305,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
* @returns all sorted view models stored in this repository.
|
||||
*/
|
||||
public getSortedViewModelList(): V[] {
|
||||
return this.sortedViewModelListSubject.getValue();
|
||||
return this.viewModelListSubject.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -333,7 +322,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
* @returns the (sorted) Observable of the whole store.
|
||||
*/
|
||||
public getViewModelListObservable(): Observable<V[]> {
|
||||
return this.sortedViewModelListSubject.asObservable();
|
||||
return this.viewModelListSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -343,7 +332,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
* @returns A subject that holds the model list
|
||||
*/
|
||||
public getViewModelListBehaviorSubject(): BehaviorSubject<V[]> {
|
||||
return this.sortedViewModelListSubject;
|
||||
return this.viewModelListSubject;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -366,15 +355,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
|
||||
/**
|
||||
* update the observable of the list. Also updates the sorting of the view model list.
|
||||
*/
|
||||
protected updateViewModelListObservable(): void {
|
||||
this.viewModelListSubject.next(this.getViewModelList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers both the observable update routines
|
||||
*/
|
||||
protected updateAllObservables(id: number): void {
|
||||
this.updateViewModelListObservable();
|
||||
this.updateViewModelObservable(id);
|
||||
public commitUpdate(): void {
|
||||
this.unsafeViewModelListSubject.next(this.getViewModelList());
|
||||
}
|
||||
}
|
||||
|
@ -2,19 +2,21 @@ import { Injectable } from '@angular/core';
|
||||
import { HttpHeaders } from '@angular/common/http';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { map, first } from 'rxjs/operators';
|
||||
import { Observable, BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { ViewMediafile, MediafileTitleInformation } from 'app/site/mediafiles/models/view-mediafile';
|
||||
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
|
||||
import { User } from 'app/shared/models/users/user';
|
||||
import { DataStoreService } from '../../core-services/data-store.service';
|
||||
import { Identifiable } from 'app/shared/models/base/identifiable';
|
||||
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
|
||||
import { DataSendService } from 'app/core/core-services/data-send.service';
|
||||
import { HttpService } from 'app/core/core-services/http.service';
|
||||
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
|
||||
import { ViewUser } from 'app/site/users/models/view-user';
|
||||
import { BaseIsListOfSpeakersContentObjectRepository } from '../base-is-list-of-speakers-content-object-repository';
|
||||
import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers';
|
||||
import { ViewGroup } from 'app/site/users/models/view-group';
|
||||
import { Group } from 'app/shared/models/users/group';
|
||||
|
||||
/**
|
||||
* Repository for MediaFiles
|
||||
@ -27,6 +29,8 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec
|
||||
Mediafile,
|
||||
MediafileTitleInformation
|
||||
> {
|
||||
private directoryBehaviorSubject: BehaviorSubject<ViewMediafile[]>;
|
||||
|
||||
/**
|
||||
* 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, <number[]>(
|
||||
file.inherited_access_groups_id
|
||||
));
|
||||
}
|
||||
return new ViewMediafile(file, listOfSpeakers, parent, accessGroups, inheritedAccessGroups);
|
||||
}
|
||||
|
||||
public async getDirectoryIdByPath(pathSegments: string[]): Promise<number | null> {
|
||||
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<ViewMediafile[]> {
|
||||
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<Identifiable> {
|
||||
public async uploadFile(file: any): Promise<Identifiable> {
|
||||
const emptyHeader = new HttpHeaders();
|
||||
return this.httpService.post<Identifiable>('/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<ViewMediafile[]> {
|
||||
return this.directoryBehaviorSubject;
|
||||
}
|
||||
|
||||
public async move(mediafiles: ViewMediafile[], directoryId: number | null): Promise<void> {
|
||||
return await this.httpService.post('/rest/mediafiles/mediafile/move/', {
|
||||
ids: mediafiles.map(mediafile => mediafile.id),
|
||||
directory_id: directoryId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,7 +51,7 @@ export class WorkflowRepositoryService extends BaseRepository<ViewWorkflow, Work
|
||||
) {
|
||||
super(DS, dataSend, mapperService, viewModelStoreService, translate, Workflow);
|
||||
|
||||
this.sortedViewModelListSubject.subscribe(models => {
|
||||
this.viewModelListSubject.subscribe(models => {
|
||||
if (models && models.length > 0) {
|
||||
this.initSorting(models);
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
|
||||
* @param DS The DataStore
|
||||
* @param mapperService Maps collection strings to classes
|
||||
* @param dataSend sending changed objects
|
||||
* @param constants reading out the OpenSlides constants
|
||||
* @param constantsService reading out the OpenSlides constants
|
||||
*/
|
||||
public constructor(
|
||||
DS: DataStoreService,
|
||||
@ -55,7 +55,7 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
|
||||
mapperService: CollectionStringMapperService,
|
||||
viewModelStoreService: ViewModelStoreService,
|
||||
translate: TranslateService,
|
||||
private constants: ConstantsService,
|
||||
private constantsService: ConstantsService,
|
||||
private http: HttpService
|
||||
) {
|
||||
super(DS, dataSend, mapperService, viewModelStoreService, translate, Group);
|
||||
@ -109,7 +109,7 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
|
||||
* read the constants, add them to an array of apps
|
||||
*/
|
||||
private sortPermsPerApp(): void {
|
||||
this.constants.get<any>('permissions').subscribe(perms => {
|
||||
this.constantsService.get<any>('permissions').subscribe(perms => {
|
||||
let pluginCounter = 0;
|
||||
for (const perm of perms) {
|
||||
// extract the apps name
|
||||
|
@ -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 = {
|
||||
|
@ -12,6 +12,25 @@
|
||||
</ngx-file-drop>
|
||||
</div>
|
||||
|
||||
<!-- Directory selector, if no external directory is provided -->
|
||||
<div *ngIf="showDirectorySelector">
|
||||
<os-search-value-selector
|
||||
ngDefaultControl
|
||||
[formControl]="directorySelectionForm.get('parent_id')"
|
||||
[multiple]="false"
|
||||
[includeNone]="true"
|
||||
[noneTitle]="'Base folder'"
|
||||
listname="{{ 'Parent directory' | translate }}"
|
||||
[InputListValues]="directoryBehaviorSubject"
|
||||
></os-search-value-selector>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Upload to:
|
||||
<span *ngIf="selectedDirectoryId === null" translate>Base folder</span>
|
||||
<span *ngIf="selectedDirectoryId !== null">{{ getDirectory(selectedDirectoryId).title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="table-container" *ngIf="uploadList.data.length > 0">
|
||||
<table mat-table [dataSource]="uploadList" class="mat-elevation-z8">
|
||||
<!-- Title -->
|
||||
@ -44,14 +63,17 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Hidden -->
|
||||
<ng-container matColumnDef="hidden">
|
||||
<th mat-header-cell *matHeaderCellDef><span translate>Hidden</span></th>
|
||||
<!-- Access groups -->
|
||||
<ng-container matColumnDef="access_groups">
|
||||
<th mat-header-cell *matHeaderCellDef><span translate>Access permissions</span></th>
|
||||
<td mat-cell *matCellDef="let file">
|
||||
<mat-checkbox
|
||||
[checked]="file.hidden"
|
||||
(change)="onChangeHidden($event.checked, file)"
|
||||
></mat-checkbox>
|
||||
<os-search-value-selector
|
||||
ngDefaultControl
|
||||
[formControl]="file.form.get('access_groups_id')"
|
||||
[multiple]="true"
|
||||
listname="{{ 'Access groups' | translate }}"
|
||||
[InputListValues]="groupsBehaviorSubject"
|
||||
></os-search-value-selector>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
@ -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<string>();
|
||||
|
||||
public directoryBehaviorSubject: BehaviorSubject<ViewMediafile[]>;
|
||||
public groupsBehaviorSubject: BehaviorSubject<ViewGroup[]>;
|
||||
|
||||
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<FileData>();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
<ngx-mat-select-search ngModel (ngModelChange)="onSearch($event)"></ngx-mat-select-search>
|
||||
<div *ngIf="!multiple && includeNone">
|
||||
<mat-option [value]="null">
|
||||
<span>–</span>
|
||||
{{ noneTitle | translate }}
|
||||
</mat-option>
|
||||
<mat-divider></mat-divider>
|
||||
</div>
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -17,15 +17,24 @@ export class Mediafile extends BaseModelWithListOfSpeakers<Mediafile> {
|
||||
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<Mediafile> {
|
||||
*
|
||||
* @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}`;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -108,7 +108,7 @@
|
||||
<h4 translate>Election documents</h4>
|
||||
<mat-list dense class="election-document-list">
|
||||
<mat-list-item *ngFor="let file of assignment.attachments">
|
||||
<a [routerLink]="file.downloadUrl" target="_blank">{{ file.getTitle() }}</a>
|
||||
<a [routerLink]="file.url" target="_blank">{{ file.getTitle() }}</a>
|
||||
</mat-list-item>
|
||||
</mat-list>
|
||||
</div>
|
||||
|
@ -110,7 +110,7 @@ export class AssignmentListComponent extends BaseListViewComponent<ViewAssignmen
|
||||
* otherwise the whole list of assignments is exported.
|
||||
*/
|
||||
public downloadAssignmentButton(assignments?: ViewAssignment[]): void {
|
||||
this.pdfService.exportMultipleAssignments(assignments ? assignments : this.repo.getSortedViewModelList());
|
||||
this.pdfService.exportMultipleAssignments(assignments ? assignments : this.repo.getViewModelList());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,4 +1,4 @@
|
||||
<os-head-bar [nav]="false">
|
||||
<os-head-bar [nav]="false" [goBack]="true">
|
||||
<!-- Title -->
|
||||
<div class="title-slot"><h2 translate>Upload files</h2></div>
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
<mat-card class="os-card">
|
||||
<os-media-upload-content
|
||||
[parallel]="parallel"
|
||||
[directoryId]="directoryId"
|
||||
(uploadSuccessEvent)="uploadSuccess()"
|
||||
(errorEvent)="showError($event)"
|
||||
></os-media-upload-content>
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { BaseViewComponent } from 'app/site/base/base-view';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service';
|
||||
|
||||
/**
|
||||
* Handle file uploads from user
|
||||
@ -15,13 +17,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
|
||||
*
|
||||
@ -35,17 +39,24 @@ export class MediaUploadComponent extends BaseViewComponent {
|
||||
titleService: Title,
|
||||
translate: TranslateService,
|
||||
matSnackBar: MatSnackBar,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute
|
||||
private location: Location,
|
||||
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
|
||||
*/
|
||||
public uploadSuccess(): void {
|
||||
this.router.navigate(['../'], { relativeTo: this.route });
|
||||
this.location.back();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,4 +1,4 @@
|
||||
<os-head-bar [mainButton]="canUploadFiles" [multiSelectMode]="isMultiSelect" (mainEvent)="onMainEvent()">
|
||||
<os-head-bar [mainButton]="canUploadFiles" [multiSelectMode]="false" (mainEvent)="onMainEvent()">
|
||||
<!-- Title -->
|
||||
<div class="title-slot">
|
||||
<h2 translate>Files</h2>
|
||||
@ -6,110 +6,165 @@
|
||||
|
||||
<!-- Menu -->
|
||||
<div class="menu-slot" *osPerms="'mediafiles.can_manage'">
|
||||
<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
<button type="button" mat-icon-button (click)="createNewFolder(newFolderDialog)">
|
||||
<mat-icon>create_new_folder</mat-icon>
|
||||
</button>
|
||||
<!--<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>-->
|
||||
</div>
|
||||
|
||||
<!-- Multiselect info -->
|
||||
<div *ngIf="this.isMultiSelect" class="central-info-slot">
|
||||
<!--<div *ngIf="this.isMultiSelect" class="central-info-slot">
|
||||
<button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
|
||||
<span>{{ selectedRows.length }} </span><span translate>selected</span>
|
||||
</div>
|
||||
</div>-->
|
||||
</os-head-bar>
|
||||
|
||||
<os-list-view-table
|
||||
[repo]="repo"
|
||||
[filterService]="filterService"
|
||||
[sortService]="sortService"
|
||||
[columns]="tableColumnDefinition"
|
||||
[multiSelect]="isMultiSelect"
|
||||
[restricted]="restrictedColumns"
|
||||
[filterProps]="filterProps"
|
||||
scrollKey="user"
|
||||
[(selectedRows)]="selectedRows"
|
||||
(dataSourceChange)="onDataSourceChange($event)"
|
||||
>
|
||||
<!-- File title column -->
|
||||
<div *pblNgridCellDef="'title'; row as file" class="cell-slot fill">
|
||||
<a class="detail-link" [routerLink]="file.downloadUrl" target="_blank" *ngIf="!isMultiSelect"></a>
|
||||
<span *ngIf="file.is_hidden">
|
||||
<mat-icon matTooltip="{{ 'is hidden' | translate }}">lock</mat-icon>
|
||||
|
||||
</span>
|
||||
<!-- Folder navigation bar -->
|
||||
<div>
|
||||
{{ file.title }}
|
||||
<div class="folder-nav-bar">
|
||||
<button class="folder" mat-button (click)="changeDirectory(null)">
|
||||
<mat-icon class="file-icon">home</mat-icon>
|
||||
</button>
|
||||
<span *ngFor="let directory of directoryChain; let last = last">
|
||||
<div class="arrow">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</div>
|
||||
|
||||
<button class="folder" mat-button (click)="changeDirectory(directory.id)" *ngIf="!last">
|
||||
<span class="folder-text">
|
||||
{{ directory.title }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="folder"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="singleMediafileMenu"
|
||||
[matMenuTriggerData]="{ mediafile: directory }"
|
||||
*ngIf="last"
|
||||
>
|
||||
<os-icon-container icon="arrow_drop_down" swap="true" size="large">
|
||||
{{ directory.title }}
|
||||
</os-icon-container>
|
||||
</button>
|
||||
</span>
|
||||
<span class="visibility" *ngIf="directory && directory.inherited_access_groups_id !== true">
|
||||
<span translate>Visibility of this directory:</span>
|
||||
<span class="visible-for" *ngIf="directory.inherited_access_groups_id === false" translate>No one</span>
|
||||
<span class="visible-for" *ngIf="directory.has_inherited_access_groups" translate>
|
||||
<os-icon-container icon="group">{{ directory.inherited_access_groups }}</os-icon-container>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
</div>
|
||||
|
||||
<!-- the actual file manager -->
|
||||
<pbl-ngrid class="file-manager-table" showHeader="false" vScrollAuto [dataSource]="dataSource" [columns]="columnSet">
|
||||
<!-- Icon column -->
|
||||
<div *pblNgridCellDef="'icon'; row as mediafile" class="fill clickable">
|
||||
<a class="detail-link" target="_blank" [routerLink]="mediafile.url" *ngIf="mediafile.is_file"> </a>
|
||||
<a class="detail-link" (click)="changeDirectory(mediafile.id)" *ngIf="mediafile.is_directory"> </a>
|
||||
<mat-icon class="file-icon">{{ mediafile.getIcon() }}</mat-icon>
|
||||
</div>
|
||||
|
||||
<!-- Title column -->
|
||||
<div *pblNgridCellDef="'title'; row as mediafile" class="fill clickable">
|
||||
<a class="detail-link" target="_blank" [routerLink]="mediafile.url" *ngIf="mediafile.is_file"> </a>
|
||||
<a class="detail-link" (click)="changeDirectory(mediafile.id)" *ngIf="mediafile.is_directory"> </a>
|
||||
<div class="innerTable">
|
||||
<div class="file-title ellipsis-overflow">
|
||||
{{ mediafile.title }}
|
||||
</div>
|
||||
<div class="info-text" *ngIf="mediafile.is_file">
|
||||
<span> {{ getDateFromTimestamp(mediafile.timestamp) }} · {{ mediafile.size }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info column -->
|
||||
<div *pblNgridCellDef="'info'; row as file" class="cell-slot fill">
|
||||
<div class="file-info-cell">
|
||||
<os-icon-container icon="insert_drive_file">{{ file.type }}</os-icon-container>
|
||||
<os-icon-container icon="data_usage">{{ file.size }}</os-icon-container>
|
||||
<div *pblNgridCellDef="'info'; row as mediafile" class="fill clickable" (click)="onEditFile(mediafile)">
|
||||
<os-icon-container *ngIf="mediafile.access_groups.length" icon="group">
|
||||
<span translate>
|
||||
{{ mediafile.access_groups }}
|
||||
</span>
|
||||
</os-icon-container>
|
||||
</div>
|
||||
|
||||
<!-- Indicator column -->
|
||||
<div *pblNgridCellDef="'indicator'; row as mediafile" class="fill">
|
||||
<div
|
||||
*ngIf="getFileSettings(mediafile).length > 0"
|
||||
[matMenuTriggerFor]="singleMediafileMenu"
|
||||
[matMenuTriggerData]="{ mediafile: mediafile }"
|
||||
[matTooltip]="formatIndicatorTooltip(mediafile)"
|
||||
>
|
||||
<mat-icon class="file-icon" *ngIf="mediafile.isFont()">text_fields</mat-icon>
|
||||
<mat-icon class="file-icon" *ngIf="mediafile.isImage()">insert_photo</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicator column -->
|
||||
<div *pblNgridCellDef="'indicator'; row as file" class="cell-slot fill">
|
||||
<div
|
||||
*ngIf="getFileSettings(file).length > 0"
|
||||
[matMenuTriggerFor]="singleFileMenu"
|
||||
[matMenuTriggerData]="{ file: file }"
|
||||
[matTooltip]="formatIndicatorTooltip(file)"
|
||||
>
|
||||
<mat-icon *ngIf="file.isFont()">text_fields</mat-icon>
|
||||
<mat-icon *ngIf="file.isImage()">insert_photo</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu column -->
|
||||
<div *pblNgridCellDef="'menu'; row as file" class="cell-slot fill">
|
||||
<div *pblNgridCellDef="'menu'; row as mediafile" class="fill">
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="singleFileMenu"
|
||||
[matMenuTriggerData]="{ file: file }"
|
||||
[disabled]="isMultiSelect"
|
||||
[matMenuTriggerFor]="singleMediafileMenu"
|
||||
[matMenuTriggerData]="{ mediafile: mediafile }"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</os-list-view-table>
|
||||
</pbl-ngrid>
|
||||
|
||||
<!-- Template for the managing buttons -->
|
||||
<ng-template #manageButton let-file="file" let-action="action">
|
||||
<button mat-menu-item (click)="onManageButton($event, file, action)">
|
||||
<mat-icon color="accent"> {{ isUsedAs(file, action) ? 'check_box' : 'check_box_outline_blank' }} </mat-icon>
|
||||
<ng-template #manageButton let-mediafile="mediafile" let-action="action">
|
||||
<button mat-menu-item (click)="onManageButton($event, mediafile, action)">
|
||||
<mat-icon color="accent">
|
||||
{{ isUsedAs(mediafile, action) ? 'check_box' : 'check_box_outline_blank' }}
|
||||
</mat-icon>
|
||||
<span>{{ getNameOfAction(action) }}</span>
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<!-- Menu for single files in the list -->
|
||||
<mat-menu #singleFileMenu="matMenu">
|
||||
<ng-template matMenuContent let-file="file">
|
||||
<mat-menu #singleMediafileMenu="matMenu">
|
||||
<ng-template matMenuContent let-mediafile="mediafile">
|
||||
<!-- Exclusive for images -->
|
||||
<div *ngIf="file.isImage()">
|
||||
<div *ngIf="mediafile.isImage()">
|
||||
<div *ngFor="let action of logoActions">
|
||||
<ng-container *ngTemplateOutlet="manageButton; context: { file: file, action: action }"></ng-container>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="manageButton; context: { mediafile: mediafile, action: action }"
|
||||
></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exclusive for fonts -->
|
||||
<div *ngIf="file.isFont()">
|
||||
<div *ngIf="mediafile.isFont()">
|
||||
<div *ngFor="let action of fontActions">
|
||||
<ng-container *ngTemplateOutlet="manageButton; context: { file: file, action: action }"></ng-container>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="manageButton; context: { mediafile: mediafile, action: action }"
|
||||
></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit and delete for all images -->
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<os-speaker-button [object]="file" [menuItem]="true"></os-speaker-button>
|
||||
<button mat-menu-item (click)="onEditFile(file)">
|
||||
<mat-divider *ngIf="mediafile.isFont() || mediafile.isImage()"></mat-divider>
|
||||
<os-projector-button
|
||||
*ngIf="mediafile.isProjectable()"
|
||||
[object]="mediafile"
|
||||
[menuItem]="true"
|
||||
></os-projector-button>
|
||||
<os-speaker-button [object]="mediafile" [menuItem]="true"></os-speaker-button>
|
||||
<button mat-menu-item (click)="onEditFile(mediafile)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span translate>Edit</span>
|
||||
</button>
|
||||
<button mat-menu-item class="red-warning-text" (click)="onDelete(file)">
|
||||
<button mat-menu-item (click)="move(moveDialog, mediafile)">
|
||||
<mat-icon>near_me</mat-icon>
|
||||
<span translate>Move</span>
|
||||
</button>
|
||||
<button mat-menu-item class="red-warning-text" (click)="onDelete(mediafile)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span translate>Delete</span>
|
||||
</button>
|
||||
@ -117,7 +172,7 @@
|
||||
</mat-menu>
|
||||
|
||||
<!-- Menu for Mediafiles -->
|
||||
<mat-menu #mediafilesMenu="matMenu">
|
||||
<!--<mat-menu #mediafilesMenu="matMenu">
|
||||
<div *ngIf="!isMultiSelect">
|
||||
<button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="toggleMultiSelect()">
|
||||
<mat-icon>library_add</mat-icon>
|
||||
@ -145,12 +200,13 @@
|
||||
<span translate>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</mat-menu>
|
||||
</mat-menu>-->
|
||||
|
||||
<!-- File edit dialog -->
|
||||
<ng-template #fileEditDialog>
|
||||
<h1 mat-dialog-title>{{ 'Edit details for' | translate }}</h1>
|
||||
<div class="os-form-card-mobile" mat-dialog-content>
|
||||
<form class="edit-file-form" [formGroup]="fileEditForm" (keydown)="keyDownFunction($event)">
|
||||
<form class="edit-file-form" [formGroup]="fileEditForm">
|
||||
<mat-form-field>
|
||||
<input
|
||||
type="text"
|
||||
@ -163,12 +219,13 @@
|
||||
<mat-error *ngIf="fileEditForm.invalid" translate>Required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-select formControlName="hidden" placeholder="{{ 'Visibility' | translate }}">
|
||||
<mat-option [value]="true"> <span translate>Hidden</span> </mat-option>
|
||||
<mat-option [value]="false"><span translate>Visible</span></mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<os-search-value-selector
|
||||
ngDefaultControl
|
||||
[formControl]="fileEditForm.get('access_groups_id')"
|
||||
[multiple]="true"
|
||||
listname="{{ 'Access groups' | translate }}"
|
||||
[InputListValues]="groupsBehaviorSubject"
|
||||
></os-search-value-selector>
|
||||
</form>
|
||||
</div>
|
||||
<div mat-dialog-actions>
|
||||
@ -186,3 +243,58 @@
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- New folder dialog -->
|
||||
<ng-template #newFolderDialog>
|
||||
<h1 mat-dialog-title>{{ 'Create new directory' | translate }}</h1>
|
||||
<div class="os-form-card-mobile" mat-dialog-content>
|
||||
<form class="edit-file-form" [formGroup]="newDirectoryForm">
|
||||
<p translate>Please enter a name for the new directory:</p>
|
||||
<mat-form-field>
|
||||
<input matInput osAutofocus formControlName="title" required />
|
||||
</mat-form-field>
|
||||
|
||||
<os-search-value-selector
|
||||
ngDefaultControl
|
||||
[formControl]="newDirectoryForm.get('access_groups_id')"
|
||||
[multiple]="true"
|
||||
listname="{{ 'Access groups' | translate }}"
|
||||
[InputListValues]="groupsBehaviorSubject"
|
||||
></os-search-value-selector>
|
||||
</form>
|
||||
</div>
|
||||
<div mat-dialog-actions>
|
||||
<button type="submit" mat-button [disabled]="!newDirectoryForm.valid" color="primary" [mat-dialog-close]="true">
|
||||
<span translate>Save</span>
|
||||
</button>
|
||||
<button type="button" mat-button [mat-dialog-close]="null">
|
||||
<span translate>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Move dialog -->
|
||||
<ng-template #moveDialog>
|
||||
<h1 mat-dialog-title>
|
||||
<span translate>Move to directory</span>
|
||||
</h1>
|
||||
<div mat-dialog-content>
|
||||
<p translate>Please select the directory to move to:</p>
|
||||
<os-search-value-selector
|
||||
ngDefaultControl
|
||||
[formControl]="moveForm.get('directory_id')"
|
||||
[includeNone]="true"
|
||||
[noneTitle]="'Base folder'"
|
||||
listname="{{ 'Parent directory' | translate }}"
|
||||
[InputListValues]="directoryBehaviorSubject"
|
||||
></os-search-value-selector>
|
||||
</div>
|
||||
<div mat-dialog-actions>
|
||||
<button type="submit" mat-button color="primary" [mat-dialog-close]="true">
|
||||
<span translate>Move</span>
|
||||
</button>
|
||||
<button type="button" mat-button [mat-dialog-close]="null">
|
||||
<span translate>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@ -5,14 +5,69 @@
|
||||
white-space: pre-line !important;
|
||||
}
|
||||
|
||||
// duplicate. Put into own file
|
||||
.file-info-cell {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
.folder-nav-bar {
|
||||
$size: 40px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
line-height: $size;
|
||||
background-color: white; // TODO: theme
|
||||
|
||||
span {
|
||||
.arrow {
|
||||
height: $size;
|
||||
float: left;
|
||||
.mat-icon {
|
||||
font-size: 130%;
|
||||
line-height: $size;
|
||||
}
|
||||
}
|
||||
|
||||
.folder {
|
||||
height: $size;
|
||||
}
|
||||
|
||||
.folder-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: auto 5px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visibility {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
.visible-for {
|
||||
margin-left: 10px;
|
||||
display: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-manager-table {
|
||||
.file-title {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
height: calc(100vh - 170px);
|
||||
|
||||
.pbl-ngrid-row {
|
||||
$size: 60px;
|
||||
height: $size !important;
|
||||
|
||||
.pbl-ngrid-cell {
|
||||
height: $size !important;
|
||||
}
|
||||
}
|
||||
|
||||
// For some reason, hiding the table header adds an empty meta bar.
|
||||
.pbl-ngrid-container {
|
||||
> div {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,13 @@
|
||||
@import '~@angular/material/theming';
|
||||
|
||||
@mixin os-mediafile-list-theme($theme) {
|
||||
$foreground: map-get($theme, foreground);
|
||||
|
||||
.file-icon {
|
||||
color: mat-color($foreground, icon);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: mat-color($foreground, icon);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, ViewChild, TemplateRef } from '@angular/core';
|
||||
import { Component, OnInit, ViewChild, TemplateRef, OnDestroy, ViewEncapsulation } from '@angular/core';
|
||||
import { BehaviorSubject, Subscription } from 'rxjs';
|
||||
import { FormGroup, Validators, FormBuilder } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
@ -6,19 +7,19 @@ import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PblColumnDefinition } from '@pebula/ngrid';
|
||||
import { PblDataSource, columnFactory, createDS } from '@pebula/ngrid';
|
||||
|
||||
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.
|
||||
@ -26,9 +27,15 @@ import { ViewportService } from 'app/core/ui-services/viewport.service';
|
||||
@Component({
|
||||
selector: 'os-mediafile-list',
|
||||
templateUrl: './mediafile-list.component.html',
|
||||
styleUrls: ['./mediafile-list.component.scss']
|
||||
styleUrls: ['./mediafile-list.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
})
|
||||
export class MediafileListComponent extends BaseListViewComponent<ViewMediafile> implements OnInit {
|
||||
export class MediafileListComponent extends BaseViewComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Data source for the files
|
||||
*/
|
||||
public dataSource: PblDataSource<ViewMediafile>;
|
||||
|
||||
/**
|
||||
* Holds the actions for logos. Updated via an observable
|
||||
*/
|
||||
@ -39,16 +46,16 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
*/
|
||||
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<ViewMediafile[]>;
|
||||
public groupsBehaviorSubject: BehaviorSubject<ViewGroup[]>;
|
||||
|
||||
/**
|
||||
* @returns true if the user can manage media files
|
||||
*/
|
||||
@ -76,45 +83,45 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
public fileEditDialog: TemplateRef<string>;
|
||||
|
||||
/**
|
||||
* Define the columns to show
|
||||
* Create the column set
|
||||
*/
|
||||
public tableColumnDefinition: PblColumnDefinition[] = [
|
||||
public columnSet = columnFactory()
|
||||
.table(
|
||||
{
|
||||
prop: 'icon',
|
||||
label: '',
|
||||
width: '40px'
|
||||
},
|
||||
{
|
||||
prop: 'title',
|
||||
width: 'auto'
|
||||
label: this.translate.instant('Title'),
|
||||
width: 'auto',
|
||||
minWidth: 60
|
||||
},
|
||||
{
|
||||
prop: 'info',
|
||||
width: '20%'
|
||||
label: this.translate.instant('Info'),
|
||||
width: '20%',
|
||||
minWidth: 60
|
||||
},
|
||||
{
|
||||
prop: 'indicator',
|
||||
width: this.singleButtonWidth
|
||||
label: '',
|
||||
width: '40px'
|
||||
},
|
||||
{
|
||||
prop: 'menu',
|
||||
width: this.singleButtonWidth
|
||||
label: '',
|
||||
width: '40px'
|
||||
}
|
||||
];
|
||||
)
|
||||
.build();
|
||||
|
||||
/**
|
||||
* Restricted Columns
|
||||
*/
|
||||
public restrictedColumns: ColumnRestriction[] = [
|
||||
{
|
||||
columnName: 'indicator',
|
||||
permission: 'mediafiles.can_manage'
|
||||
},
|
||||
{
|
||||
columnName: 'menu',
|
||||
permission: 'mediafiles.can_manage'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Define extra filter properties
|
||||
*/
|
||||
public filterProps = ['title', 'type'];
|
||||
public isMultiselect = false; // TODO
|
||||
private folderSubscription: Subscription;
|
||||
private directorySubscription: Subscription;
|
||||
public directory: ViewMediafile | null;
|
||||
public directoryChain: ViewMediafile[];
|
||||
|
||||
/**
|
||||
* Constructs the component
|
||||
@ -137,20 +144,29 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
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 +176,10 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
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 +191,56 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
super.ngOnDestroy();
|
||||
this.clearSubscriptions();
|
||||
}
|
||||
|
||||
public getDateFromTimestamp(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleString(this.translate.currentLang);
|
||||
}
|
||||
|
||||
public changeDirectory(directoryId: number | null): void {
|
||||
this.clearSubscriptions();
|
||||
|
||||
this.folderSubscription = this.repo.getListObservableDirectory(directoryId).subscribe(mediafiles => {
|
||||
if (mediafiles) {
|
||||
this.dataSource = createDS<ViewMediafile>()
|
||||
.onTrigger(() => mediafiles)
|
||||
.create();
|
||||
}
|
||||
});
|
||||
|
||||
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 +253,7 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
|
||||
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 +273,7 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
/**
|
||||
* Click on the save button in edit mode
|
||||
*/
|
||||
public onSaveEditedFile(value: { title: string; hidden: any }): void {
|
||||
public onSaveEditedFile(value: Partial<Mediafile>): void {
|
||||
this.repo.update(value, this.fileToEdit).then(() => {
|
||||
this.dialog.closeAll();
|
||||
}, this.raiseError);
|
||||
@ -233,19 +292,6 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler to delete several files at once. Requires data in selectedRows, which
|
||||
* will be made available in multiSelect mode
|
||||
*/
|
||||
public async deleteSelected(): Promise<void> {
|
||||
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 +324,7 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
*/
|
||||
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 +358,45 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
|
||||
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<string>): 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<string>, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ export const MediafileAppConfig: AppConfig = {
|
||||
],
|
||||
mainMenuEntries: [
|
||||
{
|
||||
route: '/mediafiles',
|
||||
route: '/mediafiles/files',
|
||||
displayName: 'Files',
|
||||
icon: 'attach_file',
|
||||
weight: 600,
|
||||
|
@ -4,8 +4,22 @@ 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: '',
|
||||
redirectTo: 'files',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
children: [{ path: '**', component: MediafileListComponent }],
|
||||
pathMatch: 'prefix'
|
||||
},
|
||||
{
|
||||
path: 'upload',
|
||||
data: { basePerm: 'mediafiles.can_upload' },
|
||||
children: [{ path: '**', component: MediaUploadComponent }],
|
||||
pathMatch: 'prefix'
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@ -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,101 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
|
||||
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 get timestamp(): string {
|
||||
return this.mediafile.create_timestamp ? this.mediafile.create_timestamp : null;
|
||||
}
|
||||
|
||||
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 +129,12 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
|
||||
};
|
||||
}
|
||||
|
||||
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 +187,44 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
|
||||
].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 && (<number[]>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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<ViewMediafile> {
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
}
|
@ -1375,7 +1375,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -3,12 +3,6 @@
|
||||
// Determine the distance between the top edge to the start of the table content
|
||||
$text-margin-top: 10px;
|
||||
|
||||
/** css hacks https://codepen.io/edge0703/pen/iHJuA */
|
||||
.innerTable {
|
||||
display: inline-block;
|
||||
line-height: 150%;
|
||||
}
|
||||
|
||||
.mat-button-toggle-group {
|
||||
line-height: normal;
|
||||
vertical-align: middle;
|
||||
|
@ -93,7 +93,7 @@ export class MotionMultiselectService {
|
||||
*/
|
||||
public async moveToItem(motions: ViewMotion[]): Promise<void> {
|
||||
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
|
||||
|
@ -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));
|
||||
|
@ -40,7 +40,7 @@
|
||||
<span translate>Attachments</span>:
|
||||
<mat-list dense>
|
||||
<mat-list-item *ngFor="let file of topic.attachments">
|
||||
<a [routerLink]="file.downloadUrl" target="_blank">{{ file.getTitle() }}</a>
|
||||
<a [routerLink]="file.url" target="_blank">{{ file.getTitle() }}</a>
|
||||
</mat-list-item>
|
||||
</mat-list>
|
||||
</h3>
|
||||
|
@ -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)));
|
||||
|
@ -186,7 +186,7 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> 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)));
|
||||
|
@ -20,6 +20,7 @@
|
||||
@import './app/shared/components/block-tile/block-tile.component.scss';
|
||||
@import './app/shared/components/icon-container/icon-container.component.scss';
|
||||
@import './app/site/common/components/start/start.component.scss';
|
||||
@import './app/site/mediafiles/components/mediafile-list/mediafile-list.component.scss-theme.scss';
|
||||
|
||||
/** fonts */
|
||||
@import './assets/styles/fonts.scss';
|
||||
@ -37,6 +38,7 @@
|
||||
@include os-sorting-tree-style($theme);
|
||||
@include os-global-spinner-theme($theme);
|
||||
@include os-tile-style($theme);
|
||||
@include os-mediafile-list-theme($theme);
|
||||
}
|
||||
|
||||
/** Load projector specific SCSS values */
|
||||
@ -631,6 +633,12 @@ button.mat-menu-item.selected {
|
||||
height: calc(100vh - 128px);
|
||||
}
|
||||
|
||||
/** css hacks https://codepen.io/edge0703/pen/iHJuA */
|
||||
.innerTable {
|
||||
display: inline-block;
|
||||
line-height: 150%;
|
||||
}
|
||||
|
||||
.virtual-scroll-with-head-bar {
|
||||
height: calc(100vh - 189px);
|
||||
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -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.
|
||||
"""
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -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.
|
||||
"""
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -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:
|
||||
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
|
||||
|
@ -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()
|
||||
|
||||
|
54
openslides/mediafiles/config.py
Normal file
54
openslides/mediafiles/config.py
Normal file
@ -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": ""}
|
@ -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)
|
||||
),
|
||||
]
|
@ -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),
|
||||
]
|
@ -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"),
|
||||
]
|
@ -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}
|
||||
|
@ -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"],
|
||||
}
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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, <id>, 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": "",
|
||||
@list_route(methods=["post"])
|
||||
def move(self, request):
|
||||
"""
|
||||
{
|
||||
ids: [<id>, <id>, ...],
|
||||
directory_id: <id>
|
||||
}
|
||||
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)
|
||||
Move <ids> 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.")
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
@ -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:
|
||||
|
@ -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"],
|
||||
|
@ -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:
|
||||
"""
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user