Directories and access permissions for mediafiles

This commit is contained in:
FinnStutzenstein 2019-06-28 07:24:28 +02:00 committed by Sean Engelhardt
parent 3f6fe28f35
commit 56c1da352e
55 changed files with 1429 additions and 549 deletions

View File

@ -5,6 +5,7 @@ import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model
import { CollectionStringMapperService } from './collection-string-mapper.service'; import { CollectionStringMapperService } from './collection-string-mapper.service';
import { Deferred } from '../deferred'; import { Deferred } from '../deferred';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
import { BaseRepository } from '../repositories/base-repository';
/** /**
* Represents information about a deleted model. * 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. * 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 * 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 // notify repositories in two phases
const repositories = this.mapperService.getAllRepositories(); 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) // Phase 1: deleting and creating of view models (in this order)
repositories.forEach(repo => { repositories.forEach(repo => {
repo.deleteModels(slot.getDeletedModelIdsForCollection(repo.collectionString)); const deletedModelIds = slot.getDeletedModelIdsForCollection(repo.collectionString);
repo.changedModels(slot.getChangedModelIdsForCollection(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 // Phase 2: updating dependencies
repositories.forEach(repo => { 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(); slot.DS.triggerModifiedObservable();
// serve next slot request // serve next slot request

View File

@ -52,7 +52,6 @@ export abstract class BaseHasContentObjectRepository<
this.contentObjectMapping[contentObject.collection][contentObject.id] = v; this.contentObjectMapping[contentObject.collection][contentObject.id] = v;
this.updateViewModelObservable(id); this.updateViewModelObservable(id);
}); });
this.updateViewModelListObservable();
} }
/** /**
@ -70,6 +69,5 @@ export abstract class BaseHasContentObjectRepository<
delete this.viewModelStore[id]; delete this.viewModelStore[id];
this.updateViewModelObservable(id); this.updateViewModelObservable(id);
}); });
this.updateViewModelListObservable();
} }
} }

View File

@ -30,7 +30,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
* *
* It's used to debounce messages on the sortedViewModelListSubject * 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. * 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 * 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. * 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. * 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 // 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 // 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. // update of the new list instead of many unnecessary updates.
this.viewModelListSubject.pipe(auditTime(1)).subscribe(models => { this.unsafeViewModelListSubject.pipe(auditTime(1)).subscribe(models => {
this.sortedViewModelListSubject.next(models.sort(this.viewModelSortFn)); if (models) {
this.viewModelListSubject.next(models.sort(this.viewModelSortFn));
}
}); });
this.languageCollator = new Intl.Collator(this.translate.currentLang); 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.DS.clearObservable.subscribe(() => this.clear());
this.translate.onLangChange.subscribe(change => { this.translate.onLangChange.subscribe(change => {
this.languageCollator = new Intl.Collator(change.lang); this.languageCollator = new Intl.Collator(change.lang);
this.updateViewModelListObservable(); if (this.unsafeViewModelListSubject.value) {
}); this.viewModelListSubject.next(this.unsafeViewModelListSubject.value.sort(this.viewModelSortFn));
}
this.loadInitialFromDS();
}
protected loadInitialFromDS(): void {
// Populate the local viewModelStore with ViewModel Objects.
this.DS.getAll(this.baseModelCtor).forEach((model: M) => {
this.viewModelStore[model.id] = this.createViewModelWithTitles(model);
});
// Update the list and then all models on their own
this.updateViewModelListObservable();
this.DS.getAll(this.baseModelCtor).forEach((model: M) => {
this.updateViewModelObservable(model.id);
}); });
} }
/** /**
* 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 * @param ids All model ids
*/ */
@ -134,12 +124,11 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
delete this.viewModelStore[id]; delete this.viewModelStore[id];
this.updateViewModelObservable(id); this.updateViewModelObservable(id);
}); });
this.updateViewModelListObservable();
} }
/** /**
* Updates or creates all given models in the repository (internally, no requests). * 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. * @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.viewModelStore[id] = this.createViewModelWithTitles(this.DS.get(this.collectionString, id));
this.updateViewModelObservable(id); this.updateViewModelObservable(id);
}); });
this.updateViewModelListObservable();
} }
/** /**
* Updates all models in this repository with all changed models. * Updates all models in this repository with all changed models.
* *
* @param changedModels A mapping of collections to ids of 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) { if (!this.depsModelCtors || this.depsModelCtors.length === 0) {
return; return;
} }
@ -184,8 +173,8 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
viewModels.forEach(ownViewModel => { viewModels.forEach(ownViewModel => {
this.updateViewModelObservable(ownViewModel.id); this.updateViewModelObservable(ownViewModel.id);
}); });
this.updateViewModelListObservable();
} }
return somethingUpdated;
} }
public getListTitle: (titleInformation: T) => string = (titleInformation: T) => { 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 { public setSortFunction(fn: (a: V, b: V) => number): void {
this.viewModelSortFn = fn; 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. * @returns all sorted view models stored in this repository.
*/ */
public getSortedViewModelList(): V[] { 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. * @returns the (sorted) Observable of the whole store.
*/ */
public getViewModelListObservable(): Observable<V[]> { 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 * @returns A subject that holds the model list
*/ */
public getViewModelListBehaviorSubject(): BehaviorSubject<V[]> { 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. * update the observable of the list. Also updates the sorting of the view model list.
*/ */
protected updateViewModelListObservable(): void { public commitUpdate(): void {
this.viewModelListSubject.next(this.getViewModelList()); this.unsafeViewModelListSubject.next(this.getViewModelList());
}
/**
* Triggers both the observable update routines
*/
protected updateAllObservables(id: number): void {
this.updateViewModelListObservable();
this.updateViewModelObservable(id);
} }
} }

View File

@ -2,19 +2,21 @@ import { Injectable } from '@angular/core';
import { HttpHeaders } from '@angular/common/http'; import { HttpHeaders } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core'; 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 { ViewMediafile, MediafileTitleInformation } from 'app/site/mediafiles/models/view-mediafile';
import { Mediafile } from 'app/shared/models/mediafiles/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 { DataStoreService } from '../../core-services/data-store.service';
import { Identifiable } from 'app/shared/models/base/identifiable'; import { Identifiable } from 'app/shared/models/base/identifiable';
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
import { DataSendService } from 'app/core/core-services/data-send.service'; import { DataSendService } from 'app/core/core-services/data-send.service';
import { HttpService } from 'app/core/core-services/http.service'; import { HttpService } from 'app/core/core-services/http.service';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.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 { BaseIsListOfSpeakersContentObjectRepository } from '../base-is-list-of-speakers-content-object-repository';
import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; 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 * Repository for MediaFiles
@ -27,6 +29,8 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec
Mediafile, Mediafile,
MediafileTitleInformation MediafileTitleInformation
> { > {
private directoryBehaviorSubject: BehaviorSubject<ViewMediafile[]>;
/** /**
* Constructor for the mediafile repository * Constructor for the mediafile repository
* @param DS Data store * @param DS Data store
@ -42,8 +46,17 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec
dataSend: DataSendService, dataSend: DataSendService,
private httpService: HttpService private httpService: HttpService
) { ) {
super(DS, dataSend, mapperService, viewModelStoreService, translate, Mediafile, [User]); super(DS, dataSend, mapperService, viewModelStoreService, translate, Mediafile, [Mediafile, Group]);
this.initSorting(); 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) => { public getTitle = (titleInformation: MediafileTitleInformation) => {
@ -62,8 +75,40 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec
*/ */
public createViewModel(file: Mediafile): ViewMediafile { public createViewModel(file: Mediafile): ViewMediafile {
const listOfSpeakers = this.viewModelStoreService.get(ViewListOfSpeakers, file.list_of_speakers_id); const listOfSpeakers = this.viewModelStoreService.get(ViewListOfSpeakers, file.list_of_speakers_id);
const uploader = this.viewModelStoreService.get(ViewUser, file.uploader_id); const parent = this.viewModelStoreService.get(ViewMediafile, file.parent_id);
return new ViewMediafile(file, listOfSpeakers, uploader); 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 * @param file created UploadData, containing a file
* @returns the promise to a new mediafile. * @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(); const emptyHeader = new HttpHeaders();
return this.httpService.post<Identifiable>('/rest/mediafiles/mediafile/', file, {}, emptyHeader); return this.httpService.post<Identifiable>('/rest/mediafiles/mediafile/', file, {}, emptyHeader);
} }
/** public getDirectoryBehaviorSubject(): BehaviorSubject<ViewMediafile[]> {
* Sets the default sorting (e.g. in dropdowns and for new users) to 'title' return this.directoryBehaviorSubject;
*/ }
private initSorting(): void {
this.setSortFunction((a: ViewMediafile, b: ViewMediafile) => { public async move(mediafiles: ViewMediafile[], directoryId: number | null): Promise<void> {
return this.languageCollator.compare(a.title, b.title); return await this.httpService.post('/rest/mediafiles/mediafile/move/', {
ids: mediafiles.map(mediafile => mediafile.id),
directory_id: directoryId
}); });
} }
} }

View File

@ -264,7 +264,7 @@ export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersCo
* Special handling of updating personal notes. * Special handling of updating personal notes.
* @override * @override
*/ */
public updateDependencies(changedModels: CollectionIds): void { public updateDependencies(changedModels: CollectionIds): boolean {
if (!this.depsModelCtors || this.depsModelCtors.length === 0) { if (!this.depsModelCtors || this.depsModelCtors.length === 0) {
return; return;
} }
@ -302,8 +302,8 @@ export class MotionRepositoryService extends BaseIsAgendaItemAndListOfSpeakersCo
viewModels.forEach(ownViewModel => { viewModels.forEach(ownViewModel => {
this.updateViewModelObservable(ownViewModel.id); this.updateViewModelObservable(ownViewModel.id);
}); });
this.updateViewModelListObservable();
} }
return somethingUpdated;
} }
/** /**

View File

@ -51,7 +51,7 @@ export class WorkflowRepositoryService extends BaseRepository<ViewWorkflow, Work
) { ) {
super(DS, dataSend, mapperService, viewModelStoreService, translate, Workflow); super(DS, dataSend, mapperService, viewModelStoreService, translate, Workflow);
this.sortedViewModelListSubject.subscribe(models => { this.viewModelListSubject.subscribe(models => {
if (models && models.length > 0) { if (models && models.length > 0) {
this.initSorting(models); this.initSorting(models);
} }

View File

@ -47,7 +47,7 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
* @param DS The DataStore * @param DS The DataStore
* @param mapperService Maps collection strings to classes * @param mapperService Maps collection strings to classes
* @param dataSend sending changed objects * @param dataSend sending changed objects
* @param constants reading out the OpenSlides constants * @param constantsService reading out the OpenSlides constants
*/ */
public constructor( public constructor(
DS: DataStoreService, DS: DataStoreService,
@ -55,7 +55,7 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group, Gro
mapperService: CollectionStringMapperService, mapperService: CollectionStringMapperService,
viewModelStoreService: ViewModelStoreService, viewModelStoreService: ViewModelStoreService,
translate: TranslateService, translate: TranslateService,
private constants: ConstantsService, private constantsService: ConstantsService,
private http: HttpService private http: HttpService
) { ) {
super(DS, dataSend, mapperService, viewModelStoreService, translate, Group); 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 * read the constants, add them to an array of apps
*/ */
private sortPermsPerApp(): void { private sortPermsPerApp(): void {
this.constants.get<any>('permissions').subscribe(perms => { this.constantsService.get<any>('permissions').subscribe(perms => {
let pluginCounter = 0; let pluginCounter = 0;
for (const perm of perms) { for (const perm of perms) {
// extract the apps name // extract the apps name

View File

@ -63,7 +63,7 @@ export class MediaManageService {
const restPath = `/rest/core/config/${action}/`; const restPath = `/rest/core/config/${action}/`;
const config = this.getMediaConfig(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 // Create the payload that the server requires to manage a mediafile
const payload: ManagementPayload = { const payload: ManagementPayload = {

View File

@ -12,6 +12,25 @@
</ngx-file-drop> </ngx-file-drop>
</div> </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"> <div class="table-container" *ngIf="uploadList.data.length > 0">
<table mat-table [dataSource]="uploadList" class="mat-elevation-z8"> <table mat-table [dataSource]="uploadList" class="mat-elevation-z8">
<!-- Title --> <!-- Title -->
@ -44,14 +63,17 @@
</td> </td>
</ng-container> </ng-container>
<!-- Hidden --> <!-- Access groups -->
<ng-container matColumnDef="hidden"> <ng-container matColumnDef="access_groups">
<th mat-header-cell *matHeaderCellDef><span translate>Hidden</span></th> <th mat-header-cell *matHeaderCellDef><span translate>Access permissions</span></th>
<td mat-cell *matCellDef="let file"> <td mat-cell *matCellDef="let file">
<mat-checkbox <os-search-value-selector
[checked]="file.hidden" ngDefaultControl
(change)="onChangeHidden($event.checked, file)" [formControl]="file.form.get('access_groups_id')"
></mat-checkbox> [multiple]="true"
listname="{{ 'Access groups' | translate }}"
[InputListValues]="groupsBehaviorSubject"
></os-search-value-selector>
</td> </td>
</ng-container> </ng-container>

View File

@ -1,10 +1,14 @@
import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core'; import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core';
import { MatTableDataSource, MatTable } from '@angular/material/table'; import { MatTableDataSource, MatTable } from '@angular/material/table';
import { FormBuilder, FormGroup } from '@angular/forms';
import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop'; 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 { 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 * To hold the structure of files to upload
@ -13,8 +17,7 @@ interface FileData {
mediafile: File; mediafile: File;
filename: string; filename: string;
title: string; title: string;
uploader_id: number; form: FormGroup;
hidden: boolean;
} }
@Component({ @Component({
@ -26,7 +29,7 @@ export class MediaUploadContentComponent implements OnInit {
/** /**
* Columns to display in the upload-table * 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 * Determine wether to show the progress bar
@ -50,6 +53,9 @@ export class MediaUploadContentComponent implements OnInit {
@Input() @Input()
public parallel = true; public parallel = true;
@Input()
public directoryId: number | null | undefined;
/** /**
* Set if an error was detected to prevent automatic navigation * Set if an error was detected to prevent automatic navigation
*/ */
@ -73,13 +79,40 @@ export class MediaUploadContentComponent implements OnInit {
@Output() @Output()
public errorEvent = new EventEmitter<string>(); 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 * Constructor for the media upload page
* *
* @param repo the mediafile repository * @param repo the mediafile repository
* @param op the operator, to check who was the uploader * @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 * Init
@ -89,6 +122,10 @@ export class MediaUploadContentComponent implements OnInit {
this.uploadList = new MatTableDataSource<FileData>(); 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 * Converts given FileData into FormData format and hands it over to the repository
* to upload * to upload
@ -99,8 +136,13 @@ export class MediaUploadContentComponent implements OnInit {
const input = new FormData(); const input = new FormData();
input.set('mediafile', fileData.mediafile); input.set('mediafile', fileData.mediafile);
input.set('title', fileData.title); input.set('title', fileData.title);
input.set('uploader_id', '' + fileData.uploader_id); const access_groups_id = fileData.form.value.access_groups_id || [];
input.set('hidden', '' + fileData.hidden); 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 // raiseError will automatically ignore existing files
await this.repo.uploadFile(input).then( await this.repo.uploadFile(input).then(
@ -127,16 +169,6 @@ export class MediaUploadContentComponent implements OnInit {
return `${bytes} ${['B', 'kB', 'MB', 'GB', 'TB'][unitLevel]}`; 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 * Change event to adjust the title
* *
@ -157,8 +189,9 @@ export class MediaUploadContentComponent implements OnInit {
mediafile: file, mediafile: file,
filename: file.name, filename: file.name,
title: file.name, title: file.name,
uploader_id: this.op.user.id, form: this.formBuilder.group({
hidden: false access_groups_id: [[]]
})
}; };
this.uploadList.data.push(newFile); this.uploadList.data.push(newFile);

View File

@ -3,7 +3,7 @@
<ngx-mat-select-search ngModel (ngModelChange)="onSearch($event)"></ngx-mat-select-search> <ngx-mat-select-search ngModel (ngModelChange)="onSearch($event)"></ngx-mat-select-search>
<div *ngIf="!multiple && includeNone"> <div *ngIf="!multiple && includeNone">
<mat-option [value]="null"> <mat-option [value]="null">
<span></span> {{ noneTitle | translate }}
</mat-option> </mat-option>
<mat-divider></mat-divider> <mat-divider></mat-divider>
</div> </div>

View File

@ -11,7 +11,7 @@ import { Selectable } from '../selectable';
/** /**
* Reusable Searchable Value Selector * 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: * ## Examples:
* *
@ -64,6 +64,9 @@ export class SearchValueSelectorComponent implements OnDestroy {
@Input() @Input()
public includeNone = false; public includeNone = false;
@Input()
public noneTitle = '';
/** /**
* Boolean, whether the component should be rendered with full width. * Boolean, whether the component should be rendered with full width.
*/ */

View File

@ -17,15 +17,24 @@ export class Mediafile extends BaseModelWithListOfSpeakers<Mediafile> {
public static COLLECTIONSTRING = 'mediafiles/mediafile'; public static COLLECTIONSTRING = 'mediafiles/mediafile';
public id: number; public id: number;
public title: string; public title: string;
public mediafile: FileMetadata; public mediafile?: FileMetadata;
public media_url_prefix: string; public media_url_prefix: string;
public uploader_id: number;
public filesize: string; public filesize: string;
public hidden: boolean; public access_groups_id: number[];
public timestamp: string; 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) { 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 * @returns the download URL for the specific file as string
*/ */
public get downloadUrl(): string { public get url(): string {
return `${this.media_url_prefix}${this.mediafile.name}`; return `${this.media_url_prefix}${this.path}`;
} }
} }

View File

@ -163,7 +163,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
: true; : true;
if (this.isCurrentListOfSpeakers) { if (this.isCurrentListOfSpeakers) {
this.projectors = this.projectorRepo.getSortedViewModelList(); this.projectors = this.projectorRepo.getViewModelList();
this.updateClosProjector(); this.updateClosProjector();
this.projectorRepo.getViewModelListObservable().subscribe(newProjectors => { this.projectorRepo.getViewModelListObservable().subscribe(newProjectors => {
this.projectors = newProjectors; this.projectors = newProjectors;

View File

@ -108,7 +108,7 @@
<h4 translate>Election documents</h4> <h4 translate>Election documents</h4>
<mat-list dense class="election-document-list"> <mat-list dense class="election-document-list">
<mat-list-item *ngFor="let file of assignment.attachments"> <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-item>
</mat-list> </mat-list>
</div> </div>

View File

@ -110,7 +110,7 @@ export class AssignmentListComponent extends BaseListViewComponent<ViewAssignmen
* otherwise the whole list of assignments is exported. * otherwise the whole list of assignments is exported.
*/ */
public downloadAssignmentButton(assignments?: ViewAssignment[]): void { public downloadAssignmentButton(assignments?: ViewAssignment[]): void {
this.pdfService.exportMultipleAssignments(assignments ? assignments : this.repo.getSortedViewModelList()); this.pdfService.exportMultipleAssignments(assignments ? assignments : this.repo.getViewModelList());
} }
/** /**

View File

@ -13,6 +13,7 @@
<mat-card class="os-card"> <mat-card class="os-card">
<os-media-upload-content <os-media-upload-content
[parallel]="parallel" [parallel]="parallel"
[directoryId]="directoryId"
(uploadSuccessEvent)="uploadSuccess()" (uploadSuccessEvent)="uploadSuccess()"
(errorEvent)="showError($event)" (errorEvent)="showError($event)"
></os-media-upload-content> ></os-media-upload-content>

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
@ -6,6 +6,7 @@ import { TranslateService } from '@ngx-translate/core';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service';
/** /**
* Handle file uploads from user * Handle file uploads from user
@ -15,13 +16,15 @@ import { Router, ActivatedRoute } from '@angular/router';
templateUrl: './media-upload.component.html', templateUrl: './media-upload.component.html',
styleUrls: ['./media-upload.component.scss'] 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. * Determine if uploading should happen parallel or synchronously.
* Synchronous uploading might be necessary if we see that stuff breaks * Synchronous uploading might be necessary if we see that stuff breaks
*/ */
public parallel = true; public parallel = true;
public directoryId: number | null = null;
/** /**
* Constructor for the media upload page * Constructor for the media upload page
* *
@ -36,11 +39,18 @@ export class MediaUploadComponent extends BaseViewComponent {
translate: TranslateService, translate: TranslateService,
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
private router: Router, private router: Router,
private route: ActivatedRoute private route: ActivatedRoute,
private repo: MediafileRepositoryService
) { ) {
super(titleService, translate, matSnackBar); 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 * Handler for successful uploads
*/ */

View File

@ -1,4 +1,4 @@
<os-head-bar [mainButton]="canUploadFiles" [multiSelectMode]="isMultiSelect" (mainEvent)="onMainEvent()"> <os-head-bar [mainButton]="canUploadFiles" [multiSelectMode]="false" (mainEvent)="onMainEvent()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Files</h2> <h2 translate>Files</h2>
@ -6,110 +6,151 @@
<!-- Menu --> <!-- Menu -->
<div class="menu-slot" *osPerms="'mediafiles.can_manage'"> <div class="menu-slot" *osPerms="'mediafiles.can_manage'">
<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu"> <button type="button" mat-icon-button (click)="createNewFolder(newFolderDialog)">
<mat-icon>more_vert</mat-icon> <mat-icon>create_new_folder</mat-icon>
</button> </button>
<!--<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu">
<mat-icon>more_vert</mat-icon>
</button>-->
</div> </div>
<!-- Multiselect info --> <!-- 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> <button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
<span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span> <span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span>
</div> </div>-->
</os-head-bar> </os-head-bar>
<os-list-view-table <!-- TODO: Sort bar -->
[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>
&nbsp;
</span>
<div>
{{ file.title }}
</div>
</div>
<!-- Info column --> <div>
<div *pblNgridCellDef="'info'; row as file" class="cell-slot fill"> <button mat-button (click)="changeDirectory(null)">
<div class="file-info-cell"> <span translate>Base folder</span>
<os-icon-container icon="insert_drive_file">{{ file.type }}</os-icon-container> </button>
<os-icon-container icon="data_usage">{{ file.size }}</os-icon-container> <span *ngFor="let directory of directoryChain; let last=last">
</div> <mat-icon>chevron_right</mat-icon>
</div> <button *ngIf="!last" mat-button (click)="changeDirectory(directory.id)">
{{ directory.title }}
<!-- 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">
<button
mat-icon-button
[matMenuTriggerFor]="singleFileMenu"
[matMenuTriggerData]="{ file: file }"
[disabled]="isMultiSelect"
>
<mat-icon>more_vert</mat-icon>
</button> </button>
</div> <button *ngIf="last" mat-button (click)="onEditFile(directory)">
</os-list-view-table> {{ directory.title }}
<mat-icon>edit</mat-icon>
</button>
</span>
<button mat-icon-button *ngIf="directory" (click)="changeDirectory(directory.parent_id)">
<mat-icon>arrow_upward</mat-icon>
</button>
</div>
<div *ngIf="directory && directory.inherited_access_groups_id !== true">
<span translate>Visibility of this directory:</span>
<span *ngIf="directory.inherited_access_groups_id === false" translate>No one</span>
<span *ngIf="directory.has_inherited_access_groups" translate>
<os-icon-container icon="group">{{ directory.inherited_access_groups }}</os-icon-container>
</span>
</div>
<mat-table [dataSource]="dataSource" class="os-listview-table">
<!-- Projector button -->
<ng-container matColumnDef="projector">
<td mat-cell *matCellDef="let mediafile">
<os-projector-button *ngIf="mediafile.isProjectable()" class="projector-button" [object]="mediafile"></os-projector-button>
</td>
</ng-container>
<ng-container matColumnDef="icon">
<td mat-cell *matCellDef="let mediafile">
<mat-icon>{{ mediafile.getIcon() }}</mat-icon>
</td>
</ng-container>
<ng-container matColumnDef="title">
<td mat-cell *matCellDef="let mediafile">
<a target="_blank" [routerLink]="mediafile.url" *ngIf="mediafile.is_file">
{{ mediafile.title }}
</a>
<a (click)="changeDirectory(mediafile.id)" *ngIf="mediafile.is_directory">
{{ mediafile.title }}
</a>
</td>
</ng-container>
<ng-container matColumnDef="info">
<td mat-cell *matCellDef="let mediafile">
<os-icon-container *ngIf="mediafile.is_file" icon="data_usage">{{ mediafile.size }}</os-icon-container>
<os-icon-container *ngIf="mediafile.access_groups.length" icon="group">{{ mediafile.access_groups }}</os-icon-container>
</td>
</ng-container>
<ng-container matColumnDef="indicator">
<td mat-cell *matCellDef="let mediafile">
<div
*ngIf="getFileSettings(mediafile).length > 0"
[matMenuTriggerFor]="singleMediafileMenu"
[matMenuTriggerData]="{ file: file }"
[matTooltip]="formatIndicatorTooltip(mediafile)"
>
<mat-icon *ngIf="mediafile.isFont()">text_fields</mat-icon>
<mat-icon *ngIf="mediafile.isImage()">insert_photo</mat-icon>
</div>
</td>
</ng-container>
<ng-container matColumnDef="menu">
<td mat-cell *matCellDef="let mediafile">
<button
mat-icon-button
[matMenuTriggerFor]="singleMediafileMenu"
[matMenuTriggerData]="{ mediafile: mediafile }"
>
<!-- TODO: [disabled]="isMultiSelect" -->
<mat-icon>more_vert</mat-icon>
</button>
</td>
</ng-container>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</mat-table>
<!-- Template for the managing buttons --> <!-- Template for the managing buttons -->
<ng-template #manageButton let-file="file" let-action="action"> <ng-template #manageButton let-mediafile="mediafile" let-action="action">
<button mat-menu-item (click)="onManageButton($event, file, action)"> <button mat-menu-item (click)="onManageButton($event, mediafile, action)">
<mat-icon color="accent"> {{ isUsedAs(file, action) ? 'check_box' : 'check_box_outline_blank' }} </mat-icon> <mat-icon color="accent"> {{ isUsedAs(mediafile, action) ? 'check_box' : 'check_box_outline_blank' }} </mat-icon>
<span>{{ getNameOfAction(action) }}</span> <span>{{ getNameOfAction(action) }}</span>
</button> </button>
</ng-template> </ng-template>
<!-- Menu for single files in the list --> <!-- Menu for single files in the list -->
<mat-menu #singleFileMenu="matMenu"> <mat-menu #singleMediafileMenu="matMenu">
<ng-template matMenuContent let-file="file"> <ng-template matMenuContent let-mediafile="mediafile">
<!-- Exclusive for images --> <!-- Exclusive for images -->
<div *ngIf="file.isImage()"> <div *ngIf="mediafile.isImage()">
<div *ngFor="let action of logoActions"> <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>
</div> </div>
<!-- Exclusive for fonts --> <!-- Exclusive for fonts -->
<div *ngIf="file.isFont()"> <div *ngIf="mediafile.isFont()">
<div *ngFor="let action of fontActions"> <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>
</div> </div>
<!-- Edit and delete for all images --> <!-- Edit and delete for all images -->
<mat-divider></mat-divider> <mat-divider *ngIf="mediafile.isFont() || mediafile.isImage()"></mat-divider>
<os-speaker-button [object]="file" [menuItem]="true"></os-speaker-button> <os-speaker-button [object]="mediafile" [menuItem]="true"></os-speaker-button>
<button mat-menu-item (click)="onEditFile(file)"> <button mat-menu-item (click)="onEditFile(mediafile)">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
<span translate>Edit</span> <span translate>Edit</span>
</button> </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> <mat-icon>delete</mat-icon>
<span translate>Delete</span> <span translate>Delete</span>
</button> </button>
@ -117,7 +158,7 @@
</mat-menu> </mat-menu>
<!-- Menu for Mediafiles --> <!-- Menu for Mediafiles -->
<mat-menu #mediafilesMenu="matMenu"> <!--<mat-menu #mediafilesMenu="matMenu">
<div *ngIf="!isMultiSelect"> <div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="toggleMultiSelect()"> <button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon> <mat-icon>library_add</mat-icon>
@ -145,7 +186,7 @@
<span translate>Delete</span> <span translate>Delete</span>
</button> </button>
</div> </div>
</mat-menu> </mat-menu>-->
<ng-template #fileEditDialog> <ng-template #fileEditDialog>
<h1 mat-dialog-title>{{ 'Edit details for' | translate }}</h1> <h1 mat-dialog-title>{{ 'Edit details for' | translate }}</h1>
@ -163,12 +204,13 @@
<mat-error *ngIf="fileEditForm.invalid" translate>Required</mat-error> <mat-error *ngIf="fileEditForm.invalid" translate>Required</mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field> <os-search-value-selector
<mat-select formControlName="hidden" placeholder="{{ 'Visibility' | translate }}"> ngDefaultControl
<mat-option [value]="true"> <span translate>Hidden</span> </mat-option> [formControl]="fileEditForm.get('access_groups_id')"
<mat-option [value]="false"><span translate>Visible</span></mat-option> [multiple]="true"
</mat-select> listname="{{ 'Access groups' | translate }}"
</mat-form-field> [InputListValues]="groupsBehaviorSubject"
></os-search-value-selector>
</form> </form>
</div> </div>
<div mat-dialog-actions> <div mat-dialog-actions>
@ -186,3 +228,58 @@
</button> </button>
</div> </div>
</ng-template> </ng-template>
<!-- New folder dialog -->
<ng-template #newFolderDialog>
<h1 mat-dialog-title>
<span translate>Create new directory</span>
</h1>
<div mat-dialog-content>
<p translate>Please enter a name for the new directory:</p>
<mat-form-field [formGroup]="newDirectoryForm">
<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>
</div>
<div mat-dialog-actions>
<button type="submit" mat-button 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')"
[multiple]="false"
[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>

View File

@ -4,15 +4,3 @@
::ng-deep .mat-tooltip { ::ng-deep .mat-tooltip {
white-space: pre-line !important; white-space: pre-line !important;
} }
// duplicate. Put into own file
.file-info-cell {
display: grid;
margin: 0;
span {
.mat-icon {
font-size: 130%;
}
}
}

View File

@ -1,24 +1,25 @@
import { Component, OnInit, ViewChild, TemplateRef } from '@angular/core'; import { Component, OnInit, ViewChild, TemplateRef, OnDestroy } from '@angular/core';
import { FormGroup, Validators, FormBuilder } from '@angular/forms'; import { FormGroup, Validators, FormBuilder } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableDataSource } from '@angular/material';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid'; import { BehaviorSubject, Subscription } from 'rxjs';
import { ColumnRestriction } from 'app/shared/components/list-view-table/list-view-table.component';
import { BaseListViewComponent } from 'app/site/base/base-list-view';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service'; import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service';
import { MediaManageService } from 'app/core/ui-services/media-manage.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 { MediafilesSortListService } from '../../services/mediafiles-sort-list.service';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { 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 { 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. * Lists all the uploaded files.
@ -28,7 +29,9 @@ import { ViewportService } from 'app/core/ui-services/viewport.service';
templateUrl: './mediafile-list.component.html', templateUrl: './mediafile-list.component.html',
styleUrls: ['./mediafile-list.component.scss'] styleUrls: ['./mediafile-list.component.scss']
}) })
export class MediafileListComponent extends BaseListViewComponent<ViewMediafile> implements OnInit { export class MediafileListComponent extends BaseViewComponent implements OnInit, OnDestroy {
public readonly dataSource: MatTableDataSource<ViewMediafile> = new MatTableDataSource<ViewMediafile>();
/** /**
* Holds the actions for logos. Updated via an observable * Holds the actions for logos. Updated via an observable
*/ */
@ -39,16 +42,18 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
*/ */
public fontActions: string[]; public fontActions: string[];
/**
* Show or hide the edit mode
*/
public editFile = false;
/** /**
* Holds the file to edit * Holds the file to edit
*/ */
public fileToEdit: ViewMediafile; 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 * @returns true if the user can manage media files
*/ */
@ -75,46 +80,14 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
@ViewChild('fileEditDialog', { static: true }) @ViewChild('fileEditDialog', { static: true })
public fileEditDialog: TemplateRef<string>; public fileEditDialog: TemplateRef<string>;
/** public displayedColumns = ['projector', 'icon', 'title', 'info', 'indicator', 'menu'];
* Define the columns to show
*/
public tableColumnDefinition: PblColumnDefinition[] = [
{
prop: 'title',
width: 'auto'
},
{
prop: 'info',
width: '20%'
},
{
prop: 'indicator',
width: this.singleButtonWidth
},
{
prop: 'menu',
width: this.singleButtonWidth
}
];
/** public isMultiselect = false; // TODO
* Restricted Columns
*/
public restrictedColumns: ColumnRestriction[] = [
{
columnName: 'indicator',
permission: 'mediafiles.can_manage'
},
{
columnName: 'menu',
permission: 'mediafiles.can_manage'
}
];
/** private folderSubscription: Subscription;
* Define extra filter properties private directorySubscription: Subscription;
*/ public directory: ViewMediafile | null;
public filterProps = ['title', 'type']; public directoryChain: ViewMediafile[];
/** /**
* Constructs the component * Constructs the component
@ -137,20 +110,29 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
protected translate: TranslateService, protected translate: TranslateService,
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
private route: ActivatedRoute, private route: ActivatedRoute,
storage: StorageService,
private router: Router, private router: Router,
public repo: MediafileRepositoryService, public repo: MediafileRepositoryService,
private mediaManage: MediaManageService, private mediaManage: MediaManageService,
private promptService: PromptService, private promptService: PromptService,
public vp: ViewportService, public vp: ViewportService,
public filterService: MediafileFilterListService,
public sortService: MediafilesSortListService, public sortService: MediafilesSortListService,
private operator: OperatorService, private operator: OperatorService,
private dialog: MatDialog, private dialog: MatDialog,
private fb: FormBuilder private fb: FormBuilder,
private formBuilder: FormBuilder,
private groupRepo: GroupRepositoryService
) { ) {
super(titleService, translate, matSnackBar, storage); super(titleService, translate, matSnackBar);
this.canMultiSelect = true;
this.newDirectoryForm = this.formBuilder.group({
title: ['', Validators.required],
access_groups_id: []
});
this.moveForm = this.formBuilder.group({
directory_id: []
});
this.directoryBehaviorSubject = this.repo.getDirectoryBehaviorSubject();
this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject();
} }
/** /**
@ -160,6 +142,10 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
public ngOnInit(): void { public ngOnInit(): void {
super.setTitle('Files'); super.setTitle('Files');
this.repo.getDirectoryIdByPath(this.route.snapshot.url.map(x => x.path)).then(directoryId => {
this.changeDirectory(directoryId);
});
// Observe the logo actions // Observe the logo actions
this.mediaManage.getLogoActions().subscribe(action => { this.mediaManage.getLogoActions().subscribe(action => {
this.logoActions = action; this.logoActions = action;
@ -171,17 +157,44 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
}); });
} }
public changeDirectory(directoryId: number | null): void {
this.clearSubscriptions();
this.folderSubscription = this.repo.getListObservableDirectory(directoryId).subscribe(mediafiles => {
this.dataSource.data = [];
this.dataSource.data = mediafiles;
});
if (directoryId) {
this.directorySubscription = this.repo.getViewModelObservable(directoryId).subscribe(d => {
this.directory = d;
if (d) {
this.directoryChain = d.getDirectoryChain();
// Update the URL.
this.router.navigate(['/mediafiles/files/' + d.path], {
replaceUrl: true
});
} else {
this.directoryChain = [];
this.router.navigate(['/mediafiles/files/'], {
replaceUrl: true
});
}
});
} else {
this.directory = null;
this.directoryChain = [];
this.router.navigate(['/mediafiles/files/'], {
replaceUrl: true
});
}
}
/** /**
* Handler for the main Event.
* In edit mode, this abandons the changes
* Without edit mode, this will navigate to the upload page
*/ */
public onMainEvent(): void { public onMainEvent(): void {
if (!this.editFile) { const path = '/mediafiles/upload/' + (this.directory ? this.directory.path : '');
this.router.navigate(['./upload'], { relativeTo: this.route }); this.router.navigate([path]);
} else {
this.editFile = false;
}
} }
/** /**
@ -194,7 +207,7 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
this.fileEditForm = this.fb.group({ this.fileEditForm = this.fb.group({
title: [file.title, Validators.required], title: [file.title, Validators.required],
hidden: [file.hidden] access_groups_id: [file.access_groups_id]
}); });
const dialogRef = this.dialog.open(this.fileEditDialog, { const dialogRef = this.dialog.open(this.fileEditDialog, {
@ -214,7 +227,7 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
/** /**
* Click on the save button in edit mode * 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.repo.update(value, this.fileToEdit).then(() => {
this.dialog.closeAll(); this.dialog.closeAll();
}, this.raiseError); }, this.raiseError);
@ -233,19 +246,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 * Returns the display name of an action
* *
@ -278,7 +278,7 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
*/ */
public isUsedAs(file: ViewMediafile, mediaFileAction: string): boolean { public isUsedAs(file: ViewMediafile, mediaFileAction: string): boolean {
const config = this.mediaManage.getMediaConfig(mediaFileAction); const config = this.mediaManage.getMediaConfig(mediaFileAction);
return config ? config.path === file.downloadUrl : false; return config ? config.path === file.url : false;
} }
/** /**
@ -312,14 +312,50 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
this.mediaManage.setAs(file, action); this.mediaManage.setAs(file, action);
} }
/** public createNewFolder(templateRef: TemplateRef<string>): void {
* Clicking escape while in editFileForm should deactivate edit mode. this.newDirectoryForm.reset();
* const dialogRef = this.dialog.open(templateRef, {
* @param event The key that was pressed width: '400px'
*/ });
public keyDownFunction(event: KeyboardEvent): void {
if (event.key === 'Escape') { dialogRef.afterClosed().subscribe(result => {
this.editFile = false; 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;
} }
} }
public ngOnDestroy(): void {
super.ngOnDestroy();
this.clearSubscriptions();
}
} }

View File

@ -17,7 +17,7 @@ export const MediafileAppConfig: AppConfig = {
], ],
mainMenuEntries: [ mainMenuEntries: [
{ {
route: '/mediafiles', route: '/mediafiles/files',
displayName: 'Files', displayName: 'Files',
icon: 'attach_file', icon: 'attach_file',
weight: 600, weight: 600,

View File

@ -4,8 +4,17 @@ import { MediafileListComponent } from './components/mediafile-list/mediafile-li
import { MediaUploadComponent } from './components/media-upload/media-upload.component'; import { MediaUploadComponent } from './components/media-upload/media-upload.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: MediafileListComponent, pathMatch: 'full' }, {
{ path: 'upload', component: MediaUploadComponent, data: { basePerm: 'mediafiles.can_upload' } } path: 'files',
children: [{ path: '**', component: MediafileListComponent }],
pathMatch: 'prefix'
},
{
path: 'upload',
data: { basePerm: 'mediafiles.can_upload' },
children: [{ path: '**', component: MediaUploadComponent }],
pathMatch: 'prefix'
}
]; ];
@NgModule({ @NgModule({

View File

@ -2,10 +2,10 @@ import { BaseViewModel } from '../../base/base-view-model';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { Searchable } from 'app/site/base/searchable'; import { Searchable } from 'app/site/base/searchable';
import { SearchRepresentation } from 'app/core/ui-services/search.service'; 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 { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { BaseViewModelWithListOfSpeakers } from 'app/site/base/base-view-model-with-list-of-speakers'; 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 { 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 IMAGE_MIMETYPES = ['image/png', 'image/jpeg', 'image/gif'];
export const FONT_MIMETYPES = ['font/ttf', 'font/woff', 'application/font-woff', 'application/font-sfnt']; export const FONT_MIMETYPES = ['font/ttf', 'font/woff', 'application/font-woff', 'application/font-sfnt'];
@ -19,76 +19,97 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
implements MediafileTitleInformation, Searchable { implements MediafileTitleInformation, Searchable {
public static COLLECTIONSTRING = Mediafile.COLLECTIONSTRING; public static COLLECTIONSTRING = Mediafile.COLLECTIONSTRING;
private _uploader: ViewUser; private _parent?: ViewMediafile;
private _access_groups?: ViewGroup[];
private _inherited_access_groups?: ViewGroup[];
public get mediafile(): Mediafile { public get mediafile(): Mediafile {
return this._model; return this._model;
} }
public get uploader(): ViewUser { public get parent(): ViewMediafile | null {
return this._uploader; return this._parent;
} }
public get uploader_id(): number { public get access_groups(): ViewGroup[] {
return this.mediafile.uploader_id; 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 { public get title(): string {
return this.mediafile.title; return this.mediafile.title;
} }
public get size(): string { public get path(): string {
return this.mediafile.filesize; return this.mediafile.path;
} }
public get type(): string { public get parent_id(): number {
return this.mediafile.mediafile.type; 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 { public get prefix(): string {
return this.mediafile.media_url_prefix; return this.mediafile.media_url_prefix;
} }
public get hidden(): boolean { public get url(): string {
return this.mediafile.hidden; return this.mediafile.url;
} }
public get fileName(): string { public get type(): string {
return this.mediafile.mediafile.name; return this.mediafile.mediafile ? this.mediafile.mediafile.type : '';
}
public get downloadUrl(): string {
return this.mediafile.downloadUrl;
} }
public get pages(): number | null { public get pages(): number | null {
return this.mediafile.mediafile.pages; return this.mediafile.mediafile ? this.mediafile.mediafile.pages : null;
} }
/** public constructor(
* Determines if the file has the 'hidden' attribute mediafile: Mediafile,
* @returns the hidden attribute, also 'hidden' if there is no file listOfSpeakers?: ViewListOfSpeakers,
* TODO Which is the expected behavior for 'no file'? parent?: ViewMediafile,
*/ access_groups?: ViewGroup[],
public get is_hidden(): boolean { inherited_access_groups?: ViewGroup[]
return this.mediafile.hidden; ) {
}
public constructor(mediafile: Mediafile, listOfSpeakers?: ViewListOfSpeakers, uploader?: ViewUser) {
super(Mediafile.COLLECTIONSTRING, mediafile, listOfSpeakers); 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 { public formatForSearch(): SearchRepresentation {
const searchValues = [this.title]; return [this.title, this.path];
if (this.uploader) {
searchValues.push(this.uploader.full_name);
}
return searchValues;
} }
public getDetailStateURL(): string { public getDetailStateURL(): string {
return this.downloadUrl; return this.url;
} }
public getSlide(): ProjectorElementBuildDeskriptor { public getSlide(): ProjectorElementBuildDeskriptor {
@ -104,6 +125,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 { public isProjectable(): boolean {
return this.isImage() || this.isPdf(); return this.isImage() || this.isPdf();
} }
@ -156,19 +183,44 @@ export class ViewMediafile extends BaseViewModelWithListOfSpeakers<Mediafile>
].includes(this.type); ].includes(this.type);
} }
/** public getIcon(): string {
* Determine if the file is presentable if (this.is_directory) {
* return 'folder';
* @returns true or false } else if (this.isPdf()) {
*/ return 'picture_as_pdf';
public isPresentable(): boolean { } else if (this.isImage()) {
return this.isPdf() || this.isImage() || this.isVideo(); 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 { public updateDependencies(update: BaseViewModel): void {
super.updateDependencies(update); super.updateDependencies(update);
if (update instanceof ViewUser && this.uploader_id === update.id) { if (update instanceof ViewMediafile && update.id === this.parent_id) {
this._uploader = update; 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;
}
}
} }
} }
} }

View File

@ -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];
}
}

View File

@ -1361,7 +1361,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
* @param attachment the selected file * @param attachment the selected file
*/ */
public onClickAttachment(attachment: Mediafile): void { public onClickAttachment(attachment: Mediafile): void {
window.open(attachment.downloadUrl); window.open(attachment.url);
} }
/** /**

View File

@ -82,7 +82,7 @@ export class MotionExportDialogComponent implements OnInit {
* @returns a list of availavble commentSections * @returns a list of availavble commentSections
*/ */
public get commentsToExport(): ViewMotionCommentSection[] { public get commentsToExport(): ViewMotionCommentSection[] {
return this.commentRepo.getSortedViewModelList(); return this.commentRepo.getViewModelList();
} }
/** /**

View File

@ -93,7 +93,7 @@ export class MotionMultiselectService {
*/ */
public async moveToItem(motions: ViewMotion[]): Promise<void> { public async moveToItem(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will move all selected motions as childs to:'); 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); const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice) { if (selectedChoice) {
const requestData = { const requestData = {
@ -173,7 +173,7 @@ export class MotionMultiselectService {
const clearChoice = this.translate.instant('No category'); const clearChoice = this.translate.instant('No category');
const selectedChoice = await this.choiceService.open( const selectedChoice = await this.choiceService.open(
title, title,
this.categoryRepo.getSortedViewModelList(), this.categoryRepo.getViewModelList(),
false, false,
null, null,
clearChoice clearChoice
@ -199,12 +199,7 @@ export class MotionMultiselectService {
'This will add or remove the following submitters for all selected motions:' 'This will add or remove the following submitters for all selected motions:'
); );
const choices = [this.translate.instant('Add'), this.translate.instant('Remove')]; const choices = [this.translate.instant('Add'), this.translate.instant('Remove')];
const selectedChoice = await this.choiceService.open( const selectedChoice = await this.choiceService.open(title, this.userRepo.getViewModelList(), true, choices);
title,
this.userRepo.getSortedViewModelList(),
true,
choices
);
if (selectedChoice) { if (selectedChoice) {
let requestData = null; let requestData = null;
if (selectedChoice.action === choices[0]) { if (selectedChoice.action === choices[0]) {
@ -247,12 +242,7 @@ export class MotionMultiselectService {
this.translate.instant('Remove'), this.translate.instant('Remove'),
this.translate.instant('Clear tags') this.translate.instant('Clear tags')
]; ];
const selectedChoice = await this.choiceService.open( const selectedChoice = await this.choiceService.open(title, this.tagRepo.getViewModelList(), true, choices);
title,
this.tagRepo.getSortedViewModelList(),
true,
choices
);
if (selectedChoice) { if (selectedChoice) {
let requestData = null; let requestData = null;
if (selectedChoice.action === choices[0]) { if (selectedChoice.action === choices[0]) {
@ -301,7 +291,7 @@ export class MotionMultiselectService {
const clearChoice = this.translate.instant('Clear motion block'); const clearChoice = this.translate.instant('Clear motion block');
const selectedChoice = await this.choiceService.open( const selectedChoice = await this.choiceService.open(
title, title,
this.motionBlockRepo.getSortedViewModelList(), this.motionBlockRepo.getViewModelList(),
false, false,
null, null,
clearChoice clearChoice

View File

@ -154,7 +154,7 @@ export class MotionPdfService {
} }
if (infoToExport && infoToExport.includes('allcomments')) { if (infoToExport && infoToExport.includes('allcomments')) {
commentsToExport = this.commentRepo.getSortedViewModelList().map(vm => vm.id); commentsToExport = this.commentRepo.getViewModelList().map(vm => vm.id);
} }
if (commentsToExport) { if (commentsToExport) {
motionPdfContent.push(this.createComments(motion, commentsToExport)); motionPdfContent.push(this.createComments(motion, commentsToExport));

View File

@ -40,7 +40,7 @@
<span translate>Attachments</span>: <span translate>Attachments</span>:
<mat-list dense> <mat-list dense>
<mat-list-item *ngFor="let file of topic.attachments"> <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-item>
</mat-list> </mat-list>
</h3> </h3>

View File

@ -141,7 +141,7 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
} }
this.createForm(); this.createForm();
this.groups = this.groupRepo.getSortedViewModelList().filter(group => group.id !== 1); this.groups = this.groupRepo.getViewModelList().filter(group => group.id !== 1);
this.groupRepo this.groupRepo
.getViewModelListObservable() .getViewModelListObservable()
.subscribe(groups => (this.groups = groups.filter(group => group.id !== 1))); .subscribe(groups => (this.groups = groups.filter(group => group.id !== 1)));

View File

@ -186,7 +186,7 @@ export class UserListComponent extends BaseListViewComponent<ViewUser> implement
super.setTitle('Participants'); super.setTitle('Participants');
// Initialize the groups // 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 this.groupRepo
.getViewModelListObservable() .getViewModelListObservable()
.subscribe(groups => (this.groups = groups.filter(group => group.id !== 1))); .subscribe(groups => (this.groups = groups.filter(group => group.id !== 1)));

View File

@ -26,7 +26,7 @@ class Migration(migrations.Migration):
model_name="speaker", model_name="speaker",
name="user", name="user",
field=models.ForeignKey( field=models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
to=settings.AUTH_USER_MODEL, to=settings.AUTH_USER_MODEL,
), ),
), ),

View File

@ -15,7 +15,7 @@ from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.models import RESTModelMixin from openslides.utils.models import RESTModelMixin
from openslides.utils.utils import to_roman 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 from .access_permissions import ItemAccessPermissions, ListOfSpeakersAccessPermissions
@ -445,7 +445,7 @@ class Speaker(RESTModelMixin, models.Model):
objects = SpeakerManager() 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. ForeinKey to the user who speaks.
""" """

View File

@ -24,7 +24,7 @@ class Migration(migrations.Migration):
model_name="assignmentrelateduser", model_name="assignmentrelateduser",
name="user", name="user",
field=models.ForeignKey( field=models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
to=settings.AUTH_USER_MODEL, to=settings.AUTH_USER_MODEL,
), ),
), ),

View File

@ -22,7 +22,7 @@ from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.models import RESTModelMixin 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 from .access_permissions import AssignmentAccessPermissions
@ -38,7 +38,7 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model):
ForeinKey to the assignment. 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. ForeinKey to the user who is related to the assignment.
""" """

View File

@ -16,7 +16,7 @@ class Migration(migrations.Migration):
model_name="chatmessage", model_name="chatmessage",
name="user", name="user",
field=models.ForeignKey( field=models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
to=settings.AUTH_USER_MODEL, to=settings.AUTH_USER_MODEL,
), ),
), ),

View File

@ -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.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): class MediafileAccessPermissions(BaseAccessPermissions):
@ -18,15 +18,15 @@ class MediafileAccessPermissions(BaseAccessPermissions):
Returns the restricted serialized data for the instance prepared Returns the restricted serialized data for the instance prepared
for the user. Removes hidden mediafiles for some users. for the user. Removes hidden mediafiles for some users.
""" """
# Parse data. if not await async_has_perm(user_id, "mediafiles.can_see"):
if await async_has_perm(user_id, "mediafiles.can_see") and await async_has_perm( return []
user_id, "mediafiles.can_see_hidden"
): data = []
data = full_data for full in full_data:
elif await async_has_perm(user_id, "mediafiles.can_see"): access_groups = full["inherited_access_groups_id"]
# Exclude hidden mediafiles. if (
data = [full for full in full_data if not full["hidden"]] isinstance(access_groups, bool) and access_groups
else: ) or await async_in_some_groups(user_id, cast(List[int], access_groups)):
data = [] data.append(full)
return data return data

View File

@ -1,6 +1,8 @@
from typing import Any, Dict, Set from typing import Any, Dict, Set
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class MediafilesAppConfig(AppConfig): class MediafilesAppConfig(AppConfig):
@ -17,6 +19,14 @@ class MediafilesAppConfig(AppConfig):
from . import serializers # noqa from . import serializers # noqa
from ..utils.access_permissions import required_user 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. # Define projector elements.
register_projector_slides() register_projector_slides()

View 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": ""}

View File

@ -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)
),
]

View File

@ -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),
]

View File

@ -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"),
]

View File

@ -1,10 +1,14 @@
import os
import uuid
from typing import List, cast
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from ..agenda.mixins import ListOfSpeakersMixin from ..agenda.mixins import ListOfSpeakersMixin
from ..core.config import config from ..core.config import config
from ..utils.autoupdate import inform_changed_data from ..utils.models import RESTModelMixin
from ..utils.models import SET_NULL_AND_AUTOUPDATE, RESTModelMixin from ..utils.rest_api import ValidationError
from .access_permissions import MediafileAccessPermissions from .access_permissions import MediafileAccessPermissions
@ -18,7 +22,21 @@ class MediafileManager(models.Manager):
Returns the normal queryset with all mediafiles. In the background Returns the normal queryset with all mediafiles. In the background
all related list of speakers are prefetched from the database. 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): class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model):
@ -30,55 +48,140 @@ class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model):
access_permissions = MediafileAccessPermissions() access_permissions = MediafileAccessPermissions()
can_see_permission = "mediafiles.can_see" 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 See https://docs.djangoproject.com/en/dev/ref/models/fields/#filefield
for more information. 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.""" """A string representing the title of the file."""
uploader = models.ForeignKey( original_filename = models.CharField(max_length=255)
settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True
)
"""A user the uploader of a file."""
hidden = models.BooleanField(default=False) create_timestamp = models.DateTimeField(auto_now_add=True)
"""Whether or not this mediafile should be marked as hidden"""
timestamp = models.DateTimeField(auto_now_add=True)
"""A DateTimeField to save the upload date and time.""" """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: class Meta:
""" """
Meta class for the mediafile model. Meta class for the mediafile model.
""" """
ordering = ["title"] ordering = ("title",)
default_permissions = () default_permissions = ()
permissions = ( permissions = (
("can_see", "Can see the list of files"), ("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"), ("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): def __str__(self):
""" """
Method for representation. Method for representation.
""" """
return self.title 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) own_access_groups = [group.id for group in self.access_groups.all()]
# Send uploader via autoupdate because users without permission if not self.parent:
# to see users may not have it but can get it now. return own_access_groups or True # either some groups or all
inform_changed_data(self.uploader)
return result 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): def get_filesize(self):
""" """
@ -89,6 +192,9 @@ class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model):
size = self.mediafile.size size = self.mediafile.size
except OSError: except OSError:
size_string = "unknown" size_string = "unknown"
except ValueError:
# happens, if this is a directory and no file exists
return None
else: else:
if size < 1024: if size < 1024:
size_string = "< 1 kB" size_string = "< 1 kB"
@ -100,17 +206,31 @@ class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model):
size_string = "%d kB" % kB size_string = "%d kB" % kB
return size_string return size_string
@property
def is_logo(self): def is_logo(self):
if self.is_directory:
return False
for key in config["logos_available"]: for key in config["logos_available"]:
if config[key]["path"] == self.mediafile.url: if config[key]["path"] == self.url:
return True return True
return False return False
@property
def is_font(self): def is_font(self):
if self.is_directory:
return False
for key in config["fonts_available"]: for key in config["fonts_available"]:
if config[key]["path"] == self.mediafile.url: if config[key]["path"] == self.url:
return True return True
return False 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): def get_list_of_speakers_title_information(self):
return {"title": self.title} return {"title": self.title}

View File

@ -31,7 +31,7 @@ async def mediafile_slide(
) )
return { return {
"path": mediafile["mediafile"]["name"], "path": mediafile["path"],
"type": mediafile["mediafile"]["type"], "type": mediafile["mediafile"]["type"],
"media_url_prefix": mediafile["media_url_prefix"], "media_url_prefix": mediafile["media_url_prefix"],
} }

View File

@ -5,7 +5,14 @@ from django.db import models as dbmodels
from PyPDF2 import PdfFileReader from PyPDF2 import PdfFileReader
from PyPDF2.utils import PdfReadError 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 from .models import Mediafile
@ -16,13 +23,22 @@ class AngularCompatibleFileField(FileField):
return super(AngularCompatibleFileField, self).to_internal_value(data) return super(AngularCompatibleFileField, self).to_internal_value(data)
def to_representation(self, value): def to_representation(self, value):
if value is None: if value is None or value.name is None:
return None return None
filetype = mimetypes.guess_type(value.path)[0] filetype = mimetypes.guess_type(value.name)[0]
result = {"name": value.name, "type": filetype} result = {"name": value.name, "type": filetype}
if filetype == "application/pdf": if filetype == "application/pdf":
try: 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: except FileNotFoundError:
# File was deleted from server. Set 'pages' to 0. # File was deleted from server. Set 'pages' to 0.
result["pages"] = 0 result["pages"] = 0
@ -40,6 +56,9 @@ class MediafileSerializer(ModelSerializer):
media_url_prefix = SerializerMethodField() media_url_prefix = SerializerMethodField()
filesize = SerializerMethodField() filesize = SerializerMethodField()
access_groups = IdPrimaryKeyRelatedField(
many=True, required=False, queryset=get_group_model().objects.all()
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
""" """
@ -48,6 +67,8 @@ class MediafileSerializer(ModelSerializer):
""" """
super(MediafileSerializer, self).__init__(*args, **kwargs) super(MediafileSerializer, self).__init__(*args, **kwargs)
self.serializer_field_mapping[dbmodels.FileField] = AngularCompatibleFileField self.serializer_field_mapping[dbmodels.FileField] = AngularCompatibleFileField
# Make some fields read-oinly for updates (not creation)
if self.instance is not None: if self.instance is not None:
self.fields["mediafile"].read_only = True self.fields["mediafile"].read_only = True
@ -58,13 +79,49 @@ class MediafileSerializer(ModelSerializer):
"title", "title",
"mediafile", "mediafile",
"media_url_prefix", "media_url_prefix",
"uploader",
"filesize", "filesize",
"hidden", "access_groups",
"timestamp", "create_timestamp",
"is_directory",
"path",
"parent",
"list_of_speakers_id", "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): def get_filesize(self, mediafile):
return mediafile.get_filesize() return mediafile.get_filesize()

View File

@ -1,10 +1,14 @@
from django.http import HttpResponseForbidden, HttpResponseNotFound from django.http import HttpResponseForbidden, HttpResponseNotFound
from django.http.request import QueryDict
from django.views.static import serve from django.views.static import serve
from ..core.config import config from openslides.core.models import Projector
from ..utils.auth import has_perm
from ..utils.rest_api import ModelViewSet, ValidationError 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 .access_permissions import MediafileAccessPermissions
from .config import watch_and_update_configs
from .models import Mediafile from .models import Mediafile
@ -26,21 +30,9 @@ class MediafileViewSet(ModelViewSet):
""" """
Returns True if the user has required permissions. Returns True if the user has required permissions.
""" """
if self.action in ("list", "retrieve"): if self.action in ("list", "retrieve", "metadata"):
result = self.get_access_permissions().check_permissions(self.request.user) result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == "metadata": elif self.action in ("create", "partial_update", "update", "move", "destroy"):
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":
result = has_perm(self.request.user, "mediafiles.can_see") and has_perm( result = has_perm(self.request.user, "mediafiles.can_see") and has_perm(
self.request.user, "mediafiles.can_manage" self.request.user, "mediafiles.can_manage"
) )
@ -52,62 +44,166 @@ class MediafileViewSet(ModelViewSet):
""" """
Customized view endpoint to upload a new file. Customized view endpoint to upload a new file.
""" """
# Check permission to check if the uploader has to be changed. # The form data may send the groups_id
uploader_id = self.request.data.get("uploader_id") if isinstance(request.data, QueryDict):
if ( request.data._mutable = True
uploader_id
and not has_perm(request.user, "mediafiles.can_manage") # convert formdata string "<id, <id>, id>" to a list of numbers.
and str(self.request.user.pk) != str(uploader_id) if "access_groups_id" in request.data and isinstance(request.data, QueryDict):
): access_groups_id = request.data.get("access_groups_id")
self.permission_denied(request) if access_groups_id:
if not self.request.data.get("mediafile"): 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."}) raise ValidationError({"detail": "You forgot to provide a file."})
return super().create(request, *args, **kwargs) return super().create(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs): def destroy(self, *args, **kwargs):
""" with watch_and_update_configs():
Customized view endpoint to delete uploaded files. response = super().destroy(*args, **kwargs)
return response
Does also delete the file from filesystem. def update(self, *args, **kwargs):
""" with watch_and_update_configs():
# To avoid Django calling save() and triggering autoupdate we do not response = super().update(*args, **kwargs)
# use the builtin method mediafile.mediafile.delete() but call inform_changed_data(self.get_object().get_children_deep())
# mediafile.mediafile.storage.delete(...) directly. This may have return response
# 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)
# check if the file was used as a logo or font @list_route(methods=["post"])
for logo in config["logos_available"]: def move(self, request):
if config[logo]["path"] == mediafile.mediafile.url: """
config[logo] = { {
"display_name": config[logo]["display_name"], ids: [<id>, <id>, ...],
"path": "", directory_id: <id>
} }
for font in config["fonts_available"]: Move <ids> to the given directory_id. This will raise an error, if
if config[font]["path"] == mediafile.mediafile.url: the move would be recursive.
config[font] = { """
"display_name": config[font]["display_name"],
"default": config[font]["default"], # Validate data:
"path": "", if not isinstance(request.data, dict):
} raise ValidationError({"detail": "The data must be a dict"})
return super().destroy(request, *args, **kwargs) 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): def protected_serve(request, path, document_root=None, show_indexes=False):
try: try:
mediafile = Mediafile.objects.get(mediafile=path) mediafile = get_mediafile(request, path)
except Mediafile.DoesNotExist: except Mediafile.DoesNotExist:
return HttpResponseNotFound(content="Not found.") return HttpResponseNotFound(content="Not found.")
can_see = has_perm(request.user, "mediafiles.can_see") if mediafile:
is_special_file = mediafile.is_logo() or mediafile.is_font() return serve(request, mediafile.mediafile.name, document_root, show_indexes)
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.")
else: else:
return serve(request, path, document_root, show_indexes) return HttpResponseForbidden(content="Forbidden.")

View File

@ -88,7 +88,7 @@ class Migration(migrations.Migration):
model_name="motionchangerecommendation", model_name="motionchangerecommendation",
name="motion", name="motion",
field=models.ForeignKey( field=models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
related_name="change_recommendations", related_name="change_recommendations",
to="motions.Motion", to="motions.Motion",
), ),
@ -106,7 +106,7 @@ class Migration(migrations.Migration):
model_name="submitter", model_name="submitter",
name="user", name="user",
field=models.ForeignKey( field=models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
to=settings.AUTH_USER_MODEL, to=settings.AUTH_USER_MODEL,
), ),
), ),

View File

@ -19,7 +19,7 @@ from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.models import RESTModelMixin 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 ( from .access_permissions import (
CategoryAccessPermissions, CategoryAccessPermissions,
MotionAccessPermissions, MotionAccessPermissions,
@ -657,7 +657,7 @@ class Submitter(RESTModelMixin, models.Model):
Use custom Manager. 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. ForeignKey to the user who is the submitter.
""" """
@ -707,7 +707,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model):
objects = MotionChangeRecommendationManager() objects = MotionChangeRecommendationManager()
motion = models.ForeignKey( 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.""" """The motion to which the change recommendation belongs."""

View File

@ -15,7 +15,7 @@ class Migration(migrations.Migration):
model_name="personalnote", model_name="personalnote",
name="user", name="user",
field=models.OneToOneField( field=models.OneToOneField(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
to=settings.AUTH_USER_MODEL, to=settings.AUTH_USER_MODEL,
), ),
) )

View File

@ -20,7 +20,7 @@ from jsonfield import JSONField
from ..core.config import config from ..core.config import config
from ..utils.auth import GROUP_ADMIN_PK 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 ( from .access_permissions import (
GroupAccessPermissions, GroupAccessPermissions,
PersonalNoteAccessPermissions, PersonalNoteAccessPermissions,
@ -351,7 +351,7 @@ class PersonalNote(RESTModelMixin, models.Model):
objects = PersonalNoteManager() objects = PersonalNoteManager()
user = models.OneToOneField(User, on_delete=CASCADE_AND_AUTOUODATE) user = models.OneToOneField(User, on_delete=CASCADE_AND_AUTOUPDATE)
notes = JSONField() notes = JSONField()
class Meta: class Meta:

View File

@ -52,8 +52,6 @@ def create_builtin_groups_and_admin(**kwargs):
"core.can_see_projector", "core.can_see_projector",
"mediafiles.can_manage", "mediafiles.can_manage",
"mediafiles.can_see", "mediafiles.can_see",
"mediafiles.can_see_hidden",
"mediafiles.can_upload",
"motions.can_create", "motions.can_create",
"motions.can_create_amendments", "motions.can_create_amendments",
"motions.can_manage", "motions.can_manage",
@ -145,8 +143,6 @@ def create_builtin_groups_and_admin(**kwargs):
permission_dict["core.can_manage_tags"], permission_dict["core.can_manage_tags"],
permission_dict["mediafiles.can_see"], permission_dict["mediafiles.can_see"],
permission_dict["mediafiles.can_manage"], 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"],
permission_dict["motions.can_see_internal"], permission_dict["motions.can_see_internal"],
permission_dict["motions.can_create"], permission_dict["motions.can_create"],

View File

@ -191,7 +191,7 @@ def SET_NULL_AND_AUTOUPDATE(
models.SET_NULL(collector, field, sub_objs, using) 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 collector: Any, field: Any, sub_objs: Any, using: Any
) -> None: ) -> None:
""" """

View File

@ -1,7 +1,11 @@
import pytest import pytest
from django.core.files.uploadedfile import SimpleUploadedFile 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.mediafiles.models import Mediafile
from openslides.utils.test import TestCase
from ..helpers import count_queries from ..helpers import count_queries
@ -12,6 +16,8 @@ def test_mediafiles_db_queries():
Tests that only the following db queries are done: Tests that only the following db queries are done:
* 1 requests to get the list of all files * 1 requests to get the list of all files
* 1 request to get all lists of speakers. * 1 request to get all lists of speakers.
* 1 request to get all groups
* 1 request to prefetch parents
""" """
for index in range(10): for index in range(10):
Mediafile.objects.create( Mediafile.objects.create(
@ -19,4 +25,190 @@ def test_mediafiles_db_queries():
mediafile=SimpleUploadedFile(f"some_file{index}", b"some content."), 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)

View File

@ -514,8 +514,6 @@ class GroupUpdate(TestCase):
"core.can_see_projector", "core.can_see_projector",
"mediafiles.can_manage", "mediafiles.can_manage",
"mediafiles.can_see", "mediafiles.can_see",
"mediafiles.can_see_hidden",
"mediafiles.can_upload",
"motions.can_create", "motions.can_create",
"motions.can_manage", "motions.can_manage",
"motions.can_see", "motions.can_see",