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 { Deferred } from '../deferred';
import { StorageService } from './storage.service';
import { BaseRepository } from '../repositories/base-repository';
/**
* Represents information about a deleted model.
@ -187,7 +188,7 @@ export class DataStoreUpdateManagerService {
}
/**
* Commits the given update slot. THis slot must be the current one. If there are requests
* Commits the given update slot. This slot must be the current one. If there are requests
* for update slots queued, the next one will be served.
*
* Note: I added this param to make sure, that only the user of the slot
@ -203,18 +204,33 @@ export class DataStoreUpdateManagerService {
// notify repositories in two phases
const repositories = this.mapperService.getAllRepositories();
// just commit the update in a repository, if something was changed. Save
// this information in this mapping. the boolean is not evaluated; if there is an
const affectedRepos: { [collection: string]: BaseRepository<any, any, any> } = {};
// Phase 1: deleting and creating of view models (in this order)
repositories.forEach(repo => {
repo.deleteModels(slot.getDeletedModelIdsForCollection(repo.collectionString));
repo.changedModels(slot.getChangedModelIdsForCollection(repo.collectionString));
const deletedModelIds = slot.getDeletedModelIdsForCollection(repo.collectionString);
repo.deleteModels(deletedModelIds);
const changedModelIds = slot.getChangedModelIdsForCollection(repo.collectionString);
repo.changedModels(changedModelIds);
if (deletedModelIds.length || changedModelIds.length) {
affectedRepos[repo.collectionString] = repo;
}
});
// Phase 2: updating dependencies
repositories.forEach(repo => {
repo.updateDependencies(slot.getChangedModels());
if (repo.updateDependencies(slot.getChangedModels())) {
affectedRepos[repo.collectionString] = repo;
}
});
// Phase 3: committing the update to all affected repos. This will trigger all
// list observables/subjects to emit the new list.
Object.values(affectedRepos).forEach(repo => repo.commitUpdate());
slot.DS.triggerModifiedObservable();
// serve next slot request

View File

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

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
*/
private readonly viewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]);
protected readonly unsafeViewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>(null);
/**
* Observable subject for the sorted view model list.
@ -39,7 +39,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
* updates, if e.g. an autoupdate with a lot motions come in. The result is just one
* update of the new list instead of many unnecessary updates.
*/
protected readonly sortedViewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]);
protected readonly viewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]);
/**
* Observable subject for any changes of view models.
@ -94,8 +94,10 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
// All data is piped through an auditTime of 1ms. This is to prevent massive
// updates, if e.g. an autoupdate with a lot motions come in. The result is just one
// update of the new list instead of many unnecessary updates.
this.viewModelListSubject.pipe(auditTime(1)).subscribe(models => {
this.sortedViewModelListSubject.next(models.sort(this.viewModelSortFn));
this.unsafeViewModelListSubject.pipe(auditTime(1)).subscribe(models => {
if (models) {
this.viewModelListSubject.next(models.sort(this.viewModelSortFn));
}
});
this.languageCollator = new Intl.Collator(this.translate.currentLang);
@ -105,27 +107,15 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
this.DS.clearObservable.subscribe(() => this.clear());
this.translate.onLangChange.subscribe(change => {
this.languageCollator = new Intl.Collator(change.lang);
this.updateViewModelListObservable();
});
this.loadInitialFromDS();
}
protected loadInitialFromDS(): void {
// Populate the local viewModelStore with ViewModel Objects.
this.DS.getAll(this.baseModelCtor).forEach((model: M) => {
this.viewModelStore[model.id] = this.createViewModelWithTitles(model);
});
// Update the list and then all models on their own
this.updateViewModelListObservable();
this.DS.getAll(this.baseModelCtor).forEach((model: M) => {
this.updateViewModelObservable(model.id);
if (this.unsafeViewModelListSubject.value) {
this.viewModelListSubject.next(this.unsafeViewModelListSubject.value.sort(this.viewModelSortFn));
}
});
}
/**
* Deletes all models from the repository (internally, no requests). Informs all subjects.
* Deletes all models from the repository (internally, no requests). Changes need
* to be committed via `commitUpdate()`.
*
* @param ids All model ids
*/
@ -134,12 +124,11 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
delete this.viewModelStore[id];
this.updateViewModelObservable(id);
});
this.updateViewModelListObservable();
}
/**
* Updates or creates all given models in the repository (internally, no requests).
* Informs all subjects.
* Changes need to be committed via `commitUpdate()`.
*
* @param ids All model ids.
*/
@ -148,15 +137,15 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
this.viewModelStore[id] = this.createViewModelWithTitles(this.DS.get(this.collectionString, id));
this.updateViewModelObservable(id);
});
this.updateViewModelListObservable();
}
/**
* Updates all models in this repository with all changed models.
*
* @param changedModels A mapping of collections to ids of all changed models.
* @returns if at least one model was affected.
*/
public updateDependencies(changedModels: CollectionIds): void {
public updateDependencies(changedModels: CollectionIds): boolean {
if (!this.depsModelCtors || this.depsModelCtors.length === 0) {
return;
}
@ -184,8 +173,8 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
viewModels.forEach(ownViewModel => {
this.updateViewModelObservable(ownViewModel.id);
});
this.updateViewModelListObservable();
}
return somethingUpdated;
}
public getListTitle: (titleInformation: T) => string = (titleInformation: T) => {
@ -292,7 +281,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
*/
public setSortFunction(fn: (a: V, b: V) => number): void {
this.viewModelSortFn = fn;
this.updateViewModelListObservable();
this.commitUpdate();
}
/**
@ -316,7 +305,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
* @returns all sorted view models stored in this repository.
*/
public getSortedViewModelList(): V[] {
return this.sortedViewModelListSubject.getValue();
return this.viewModelListSubject.getValue();
}
/**
@ -333,7 +322,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
* @returns the (sorted) Observable of the whole store.
*/
public getViewModelListObservable(): Observable<V[]> {
return this.sortedViewModelListSubject.asObservable();
return this.viewModelListSubject.asObservable();
}
/**
@ -343,7 +332,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
* @returns A subject that holds the model list
*/
public getViewModelListBehaviorSubject(): BehaviorSubject<V[]> {
return this.sortedViewModelListSubject;
return this.viewModelListSubject;
}
/**
@ -366,15 +355,7 @@ export abstract class BaseRepository<V extends BaseViewModel & T, M extends Base
/**
* update the observable of the list. Also updates the sorting of the view model list.
*/
protected updateViewModelListObservable(): void {
this.viewModelListSubject.next(this.getViewModelList());
}
/**
* Triggers both the observable update routines
*/
protected updateAllObservables(id: number): void {
this.updateViewModelListObservable();
this.updateViewModelObservable(id);
public commitUpdate(): void {
this.unsafeViewModelListSubject.next(this.getViewModelList());
}
}

View File

@ -2,19 +2,21 @@ import { Injectable } from '@angular/core';
import { HttpHeaders } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core';
import { map, first } from 'rxjs/operators';
import { Observable, BehaviorSubject } from 'rxjs';
import { ViewMediafile, MediafileTitleInformation } from 'app/site/mediafiles/models/view-mediafile';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { User } from 'app/shared/models/users/user';
import { DataStoreService } from '../../core-services/data-store.service';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
import { DataSendService } from 'app/core/core-services/data-send.service';
import { HttpService } from 'app/core/core-services/http.service';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { ViewUser } from 'app/site/users/models/view-user';
import { BaseIsListOfSpeakersContentObjectRepository } from '../base-is-list-of-speakers-content-object-repository';
import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers';
import { ViewGroup } from 'app/site/users/models/view-group';
import { Group } from 'app/shared/models/users/group';
/**
* Repository for MediaFiles
@ -27,6 +29,8 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec
Mediafile,
MediafileTitleInformation
> {
private directoryBehaviorSubject: BehaviorSubject<ViewMediafile[]>;
/**
* Constructor for the mediafile repository
* @param DS Data store
@ -42,8 +46,17 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec
dataSend: DataSendService,
private httpService: HttpService
) {
super(DS, dataSend, mapperService, viewModelStoreService, translate, Mediafile, [User]);
this.initSorting();
super(DS, dataSend, mapperService, viewModelStoreService, translate, Mediafile, [Mediafile, Group]);
this.directoryBehaviorSubject = new BehaviorSubject([]);
this.getViewModelListObservable().subscribe(mediafiles => {
if (mediafiles) {
this.directoryBehaviorSubject.next(mediafiles.filter(mediafile => mediafile.is_directory));
}
});
this.viewModelSortFn = (a: ViewMediafile, b: ViewMediafile) => {
return this.languageCollator.compare(a.title, b.title);
};
}
public getTitle = (titleInformation: MediafileTitleInformation) => {
@ -62,8 +75,40 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec
*/
public createViewModel(file: Mediafile): ViewMediafile {
const listOfSpeakers = this.viewModelStoreService.get(ViewListOfSpeakers, file.list_of_speakers_id);
const uploader = this.viewModelStoreService.get(ViewUser, file.uploader_id);
return new ViewMediafile(file, listOfSpeakers, uploader);
const parent = this.viewModelStoreService.get(ViewMediafile, file.parent_id);
const accessGroups = this.viewModelStoreService.getMany(ViewGroup, file.access_groups_id);
let inheritedAccessGroups;
if (file.has_inherited_access_groups) {
inheritedAccessGroups = this.viewModelStoreService.getMany(ViewGroup, <number[]>(
file.inherited_access_groups_id
));
}
return new ViewMediafile(file, listOfSpeakers, parent, accessGroups, inheritedAccessGroups);
}
public async getDirectoryIdByPath(pathSegments: string[]): Promise<number | null> {
let parentId = null;
const mediafiles = await this.unsafeViewModelListSubject.pipe(first(x => !!x)).toPromise();
pathSegments.forEach(segment => {
const mediafile = mediafiles.find(m => m.is_directory && m.title === segment && m.parent_id === parentId);
if (!mediafile) {
parentId = null;
return;
} else {
parentId = mediafile.id;
}
});
return parentId;
}
public getListObservableDirectory(parentId: number | null): Observable<ViewMediafile[]> {
return this.getViewModelListObservable().pipe(
map(mediafiles => {
return mediafiles.filter(mediafile => mediafile.parent_id === parentId);
})
);
}
/**
@ -74,17 +119,19 @@ export class MediafileRepositoryService extends BaseIsListOfSpeakersContentObjec
* @param file created UploadData, containing a file
* @returns the promise to a new mediafile.
*/
public async uploadFile(file: FormData): Promise<Identifiable> {
public async uploadFile(file: any): Promise<Identifiable> {
const emptyHeader = new HttpHeaders();
return this.httpService.post<Identifiable>('/rest/mediafiles/mediafile/', file, {}, emptyHeader);
}
/**
* Sets the default sorting (e.g. in dropdowns and for new users) to 'title'
*/
private initSorting(): void {
this.setSortFunction((a: ViewMediafile, b: ViewMediafile) => {
return this.languageCollator.compare(a.title, b.title);
public getDirectoryBehaviorSubject(): BehaviorSubject<ViewMediafile[]> {
return this.directoryBehaviorSubject;
}
public async move(mediafiles: ViewMediafile[], directoryId: number | null): Promise<void> {
return await this.httpService.post('/rest/mediafiles/mediafile/move/', {
ids: mediafiles.map(mediafile => mediafile.id),
directory_id: directoryId
});
}
}

View File

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

View File

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

View File

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

View File

@ -63,7 +63,7 @@ export class MediaManageService {
const restPath = `/rest/core/config/${action}/`;
const config = this.getMediaConfig(action);
const path = config.path !== file.downloadUrl ? file.downloadUrl : '';
const path = config.path !== file.url ? file.url : '';
// Create the payload that the server requires to manage a mediafile
const payload: ManagementPayload = {

View File

@ -12,6 +12,25 @@
</ngx-file-drop>
</div>
<!-- Directory selector, if no external directory is provided -->
<div *ngIf="showDirectorySelector">
<os-search-value-selector
ngDefaultControl
[formControl]="directorySelectionForm.get('parent_id')"
[multiple]="false"
[includeNone]="true"
[noneTitle]="'Base folder'"
listname="{{ 'Parent directory' | translate }}"
[InputListValues]="directoryBehaviorSubject"
></os-search-value-selector>
</div>
<div>
Upload to:
<span *ngIf="selectedDirectoryId === null" translate>Base folder</span>
<span *ngIf="selectedDirectoryId !== null">{{ getDirectory(selectedDirectoryId).title }}</span>
</div>
<div class="table-container" *ngIf="uploadList.data.length > 0">
<table mat-table [dataSource]="uploadList" class="mat-elevation-z8">
<!-- Title -->
@ -44,14 +63,17 @@
</td>
</ng-container>
<!-- Hidden -->
<ng-container matColumnDef="hidden">
<th mat-header-cell *matHeaderCellDef><span translate>Hidden</span></th>
<!-- Access groups -->
<ng-container matColumnDef="access_groups">
<th mat-header-cell *matHeaderCellDef><span translate>Access permissions</span></th>
<td mat-cell *matCellDef="let file">
<mat-checkbox
[checked]="file.hidden"
(change)="onChangeHidden($event.checked, file)"
></mat-checkbox>
<os-search-value-selector
ngDefaultControl
[formControl]="file.form.get('access_groups_id')"
[multiple]="true"
listname="{{ 'Access groups' | translate }}"
[InputListValues]="groupsBehaviorSubject"
></os-search-value-selector>
</td>
</ng-container>

View File

@ -1,10 +1,14 @@
import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core';
import { MatTableDataSource, MatTable } from '@angular/material/table';
import { FormBuilder, FormGroup } from '@angular/forms';
import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop';
import { BehaviorSubject } from 'rxjs';
import { OperatorService } from 'app/core/core-services/operator.service';
import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { ViewGroup } from 'app/site/users/models/view-group';
/**
* To hold the structure of files to upload
@ -13,8 +17,7 @@ interface FileData {
mediafile: File;
filename: string;
title: string;
uploader_id: number;
hidden: boolean;
form: FormGroup;
}
@Component({
@ -26,7 +29,7 @@ export class MediaUploadContentComponent implements OnInit {
/**
* Columns to display in the upload-table
*/
public displayedColumns: string[] = ['title', 'filename', 'information', 'hidden', 'remove'];
public displayedColumns: string[] = ['title', 'filename', 'information', 'access_groups', 'remove'];
/**
* Determine wether to show the progress bar
@ -50,6 +53,9 @@ export class MediaUploadContentComponent implements OnInit {
@Input()
public parallel = true;
@Input()
public directoryId: number | null | undefined;
/**
* Set if an error was detected to prevent automatic navigation
*/
@ -73,13 +79,40 @@ export class MediaUploadContentComponent implements OnInit {
@Output()
public errorEvent = new EventEmitter<string>();
public directoryBehaviorSubject: BehaviorSubject<ViewMediafile[]>;
public groupsBehaviorSubject: BehaviorSubject<ViewGroup[]>;
public directorySelectionForm: FormGroup;
public get showDirectorySelector(): boolean {
return this.directoryId === undefined;
}
public get selectedDirectoryId(): number | null {
if (this.showDirectorySelector) {
return this.directorySelectionForm.controls.parent_id.value;
} else {
return this.directoryId;
}
}
/**
* Constructor for the media upload page
*
* @param repo the mediafile repository
* @param op the operator, to check who was the uploader
*/
public constructor(private repo: MediafileRepositoryService, private op: OperatorService) {}
public constructor(
private repo: MediafileRepositoryService,
private formBuilder: FormBuilder,
private groupRepo: GroupRepositoryService
) {
this.directoryBehaviorSubject = this.repo.getDirectoryBehaviorSubject();
this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject();
this.directorySelectionForm = this.formBuilder.group({
parent_id: []
});
}
/**
* Init
@ -89,6 +122,10 @@ export class MediaUploadContentComponent implements OnInit {
this.uploadList = new MatTableDataSource<FileData>();
}
public getDirectory(directoryId: number): ViewMediafile {
return this.repo.getViewModel(directoryId);
}
/**
* Converts given FileData into FormData format and hands it over to the repository
* to upload
@ -99,8 +136,13 @@ export class MediaUploadContentComponent implements OnInit {
const input = new FormData();
input.set('mediafile', fileData.mediafile);
input.set('title', fileData.title);
input.set('uploader_id', '' + fileData.uploader_id);
input.set('hidden', '' + fileData.hidden);
const access_groups_id = fileData.form.value.access_groups_id || [];
if (access_groups_id.length > 0) {
input.set('access_groups_id', '' + access_groups_id);
}
if (this.selectedDirectoryId) {
input.set('parent_id', '' + this.selectedDirectoryId);
}
// raiseError will automatically ignore existing files
await this.repo.uploadFile(input).then(
@ -127,16 +169,6 @@ export class MediaUploadContentComponent implements OnInit {
return `${bytes} ${['B', 'kB', 'MB', 'GB', 'TB'][unitLevel]}`;
}
/**
* Change event to set a file to hidden or not
*
* @param hidden whether the file should be hidden
* @param file the given file
*/
public onChangeHidden(hidden: boolean, file: FileData): void {
file.hidden = hidden;
}
/**
* Change event to adjust the title
*
@ -157,8 +189,9 @@ export class MediaUploadContentComponent implements OnInit {
mediafile: file,
filename: file.name,
title: file.name,
uploader_id: this.op.user.id,
hidden: false
form: this.formBuilder.group({
access_groups_id: [[]]
})
};
this.uploadList.data.push(newFile);

View File

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

View File

@ -11,7 +11,7 @@ import { Selectable } from '../selectable';
/**
* Reusable Searchable Value Selector
*
* Use `multiple="true"`, `[InputListValues]=myValues`,`[formControl]="myformcontrol"`, `[form]="myform_name"` and `placeholder={{listname}}` to pass the Values and Listname
* Use `multiple="true"`, `[InputListValues]=myValues`,`[formControl]="myformcontrol"` and `placeholder={{listname}}` to pass the Values and Listname
*
* ## Examples:
*
@ -64,6 +64,9 @@ export class SearchValueSelectorComponent implements OnDestroy {
@Input()
public includeNone = false;
@Input()
public noneTitle = '';
/**
* Boolean, whether the component should be rendered with full width.
*/

View File

@ -17,15 +17,24 @@ export class Mediafile extends BaseModelWithListOfSpeakers<Mediafile> {
public static COLLECTIONSTRING = 'mediafiles/mediafile';
public id: number;
public title: string;
public mediafile: FileMetadata;
public mediafile?: FileMetadata;
public media_url_prefix: string;
public uploader_id: number;
public filesize: string;
public hidden: boolean;
public timestamp: string;
public access_groups_id: number[];
public create_timestamp: string;
public parent_id: number | null;
public is_directory: boolean;
public path: string;
public inherited_access_groups_id: boolean | number[];
public get has_inherited_access_groups(): boolean {
return typeof this.inherited_access_groups_id !== 'boolean';
}
public constructor(input?: any) {
super(Mediafile.COLLECTIONSTRING, input);
super(Mediafile.COLLECTIONSTRING);
// Do not change null to undefined...
this.deserialize(input);
}
/**
@ -33,7 +42,7 @@ export class Mediafile extends BaseModelWithListOfSpeakers<Mediafile> {
*
* @returns the download URL for the specific file as string
*/
public get downloadUrl(): string {
return `${this.media_url_prefix}${this.mediafile.name}`;
public get url(): string {
return `${this.media_url_prefix}${this.path}`;
}
}

View File

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

View File

@ -108,7 +108,7 @@
<h4 translate>Election documents</h4>
<mat-list dense class="election-document-list">
<mat-list-item *ngFor="let file of assignment.attachments">
<a [routerLink]="file.downloadUrl" target="_blank">{{ file.getTitle() }}</a>
<a [routerLink]="file.url" target="_blank">{{ file.getTitle() }}</a>
</mat-list-item>
</mat-list>
</div>

View File

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

View File

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

View File

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

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 -->
<div class="title-slot">
<h2 translate>Files</h2>
@ -6,110 +6,151 @@
<!-- Menu -->
<div class="menu-slot" *osPerms="'mediafiles.can_manage'">
<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu">
<mat-icon>more_vert</mat-icon>
<button type="button" mat-icon-button (click)="createNewFolder(newFolderDialog)">
<mat-icon>create_new_folder</mat-icon>
</button>
<!--<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMenu">
<mat-icon>more_vert</mat-icon>
</button>-->
</div>
<!-- Multiselect info -->
<div *ngIf="this.isMultiSelect" class="central-info-slot">
<!--<div *ngIf="this.isMultiSelect" class="central-info-slot">
<button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
<span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span>
</div>
</div>-->
</os-head-bar>
<os-list-view-table
[repo]="repo"
[filterService]="filterService"
[sortService]="sortService"
[columns]="tableColumnDefinition"
[multiSelect]="isMultiSelect"
[restricted]="restrictedColumns"
[filterProps]="filterProps"
scrollKey="user"
[(selectedRows)]="selectedRows"
(dataSourceChange)="onDataSourceChange($event)"
>
<!-- File title column -->
<div *pblNgridCellDef="'title'; row as file" class="cell-slot fill">
<a class="detail-link" [routerLink]="file.downloadUrl" target="_blank" *ngIf="!isMultiSelect"></a>
<span *ngIf="file.is_hidden">
<mat-icon matTooltip="{{ 'is hidden' | translate }}">lock</mat-icon>
&nbsp;
</span>
<div>
{{ file.title }}
</div>
</div>
<!-- TODO: Sort bar -->
<!-- Info column -->
<div *pblNgridCellDef="'info'; row as file" class="cell-slot fill">
<div class="file-info-cell">
<os-icon-container icon="insert_drive_file">{{ file.type }}</os-icon-container>
<os-icon-container icon="data_usage">{{ file.size }}</os-icon-container>
</div>
</div>
<!-- Indicator column -->
<div *pblNgridCellDef="'indicator'; row as file" class="cell-slot fill">
<div
*ngIf="getFileSettings(file).length > 0"
[matMenuTriggerFor]="singleFileMenu"
[matMenuTriggerData]="{ file: file }"
[matTooltip]="formatIndicatorTooltip(file)"
>
<mat-icon *ngIf="file.isFont()">text_fields</mat-icon>
<mat-icon *ngIf="file.isImage()">insert_photo</mat-icon>
</div>
</div>
<!-- Menu column -->
<div *pblNgridCellDef="'menu'; row as file" class="cell-slot fill">
<button
mat-icon-button
[matMenuTriggerFor]="singleFileMenu"
[matMenuTriggerData]="{ file: file }"
[disabled]="isMultiSelect"
>
<mat-icon>more_vert</mat-icon>
<div>
<button mat-button (click)="changeDirectory(null)">
<span translate>Base folder</span>
</button>
<span *ngFor="let directory of directoryChain; let last=last">
<mat-icon>chevron_right</mat-icon>
<button *ngIf="!last" mat-button (click)="changeDirectory(directory.id)">
{{ directory.title }}
</button>
</div>
</os-list-view-table>
<button *ngIf="last" mat-button (click)="onEditFile(directory)">
{{ 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 -->
<ng-template #manageButton let-file="file" let-action="action">
<button mat-menu-item (click)="onManageButton($event, file, action)">
<mat-icon color="accent"> {{ isUsedAs(file, action) ? 'check_box' : 'check_box_outline_blank' }} </mat-icon>
<ng-template #manageButton let-mediafile="mediafile" let-action="action">
<button mat-menu-item (click)="onManageButton($event, mediafile, action)">
<mat-icon color="accent"> {{ isUsedAs(mediafile, action) ? 'check_box' : 'check_box_outline_blank' }} </mat-icon>
<span>{{ getNameOfAction(action) }}</span>
</button>
</ng-template>
<!-- Menu for single files in the list -->
<mat-menu #singleFileMenu="matMenu">
<ng-template matMenuContent let-file="file">
<mat-menu #singleMediafileMenu="matMenu">
<ng-template matMenuContent let-mediafile="mediafile">
<!-- Exclusive for images -->
<div *ngIf="file.isImage()">
<div *ngIf="mediafile.isImage()">
<div *ngFor="let action of logoActions">
<ng-container *ngTemplateOutlet="manageButton; context: { file: file, action: action }"></ng-container>
<ng-container *ngTemplateOutlet="manageButton; context: { mediafile: mediafile, action: action }"></ng-container>
</div>
</div>
<!-- Exclusive for fonts -->
<div *ngIf="file.isFont()">
<div *ngIf="mediafile.isFont()">
<div *ngFor="let action of fontActions">
<ng-container *ngTemplateOutlet="manageButton; context: { file: file, action: action }"></ng-container>
<ng-container *ngTemplateOutlet="manageButton; context: { mediafile: mediafile, action: action }"></ng-container>
</div>
</div>
<!-- Edit and delete for all images -->
<mat-divider></mat-divider>
<mat-divider *ngIf="mediafile.isFont() || mediafile.isImage()"></mat-divider>
<os-speaker-button [object]="file" [menuItem]="true"></os-speaker-button>
<button mat-menu-item (click)="onEditFile(file)">
<os-speaker-button [object]="mediafile" [menuItem]="true"></os-speaker-button>
<button mat-menu-item (click)="onEditFile(mediafile)">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
<button mat-menu-item class="red-warning-text" (click)="onDelete(file)">
<button mat-menu-item (click)="move(moveDialog, mediafile)">
<mat-icon>near_me</mat-icon>
<span translate>Move</span>
</button>
<button mat-menu-item class="red-warning-text" (click)="onDelete(mediafile)">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
@ -117,7 +158,7 @@
</mat-menu>
<!-- Menu for Mediafiles -->
<mat-menu #mediafilesMenu="matMenu">
<!--<mat-menu #mediafilesMenu="matMenu">
<div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
@ -145,7 +186,7 @@
<span translate>Delete</span>
</button>
</div>
</mat-menu>
</mat-menu>-->
<ng-template #fileEditDialog>
<h1 mat-dialog-title>{{ 'Edit details for' | translate }}</h1>
@ -163,12 +204,13 @@
<mat-error *ngIf="fileEditForm.invalid" translate>Required</mat-error>
</mat-form-field>
<mat-form-field>
<mat-select formControlName="hidden" placeholder="{{ 'Visibility' | translate }}">
<mat-option [value]="true"> <span translate>Hidden</span> </mat-option>
<mat-option [value]="false"><span translate>Visible</span></mat-option>
</mat-select>
</mat-form-field>
<os-search-value-selector
ngDefaultControl
[formControl]="fileEditForm.get('access_groups_id')"
[multiple]="true"
listname="{{ 'Access groups' | translate }}"
[InputListValues]="groupsBehaviorSubject"
></os-search-value-selector>
</form>
</div>
<div mat-dialog-actions>
@ -186,3 +228,58 @@
</button>
</div>
</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 {
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 { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableDataSource } from '@angular/material';
import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid';
import { BehaviorSubject, Subscription } from 'rxjs';
import { ColumnRestriction } from 'app/shared/components/list-view-table/list-view-table.component';
import { BaseListViewComponent } from 'app/site/base/base-list-view';
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service';
import { MediaManageService } from 'app/core/ui-services/media-manage.service';
import { MediafileFilterListService } from '../../services/mediafile-filter.service';
import { MediafilesSortListService } from '../../services/mediafiles-sort-list.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { StorageService } from 'app/core/core-services/storage.service';
import { ViewportService } from 'app/core/ui-services/viewport.service';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { ViewGroup } from 'app/site/users/models/view-group';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view';
/**
* Lists all the uploaded files.
@ -28,7 +29,9 @@ import { ViewportService } from 'app/core/ui-services/viewport.service';
templateUrl: './mediafile-list.component.html',
styleUrls: ['./mediafile-list.component.scss']
})
export class MediafileListComponent extends BaseListViewComponent<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
*/
@ -39,16 +42,18 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
*/
public fontActions: string[];
/**
* Show or hide the edit mode
*/
public editFile = false;
/**
* Holds the file to edit
*/
public fileToEdit: ViewMediafile;
public newDirectoryForm: FormGroup;
public moveForm: FormGroup;
public directoryBehaviorSubject: BehaviorSubject<ViewMediafile[]>;
public groupsBehaviorSubject: BehaviorSubject<ViewGroup[]>;
/**
* @returns true if the user can manage media files
*/
@ -75,46 +80,14 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
@ViewChild('fileEditDialog', { static: true })
public fileEditDialog: TemplateRef<string>;
/**
* Define the columns to show
*/
public tableColumnDefinition: PblColumnDefinition[] = [
{
prop: 'title',
width: 'auto'
},
{
prop: 'info',
width: '20%'
},
{
prop: 'indicator',
width: this.singleButtonWidth
},
{
prop: 'menu',
width: this.singleButtonWidth
}
];
public displayedColumns = ['projector', 'icon', 'title', 'info', 'indicator', 'menu'];
/**
* Restricted Columns
*/
public restrictedColumns: ColumnRestriction[] = [
{
columnName: 'indicator',
permission: 'mediafiles.can_manage'
},
{
columnName: 'menu',
permission: 'mediafiles.can_manage'
}
];
public isMultiselect = false; // TODO
/**
* Define extra filter properties
*/
public filterProps = ['title', 'type'];
private folderSubscription: Subscription;
private directorySubscription: Subscription;
public directory: ViewMediafile | null;
public directoryChain: ViewMediafile[];
/**
* Constructs the component
@ -137,20 +110,29 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
protected translate: TranslateService,
matSnackBar: MatSnackBar,
private route: ActivatedRoute,
storage: StorageService,
private router: Router,
public repo: MediafileRepositoryService,
private mediaManage: MediaManageService,
private promptService: PromptService,
public vp: ViewportService,
public filterService: MediafileFilterListService,
public sortService: MediafilesSortListService,
private operator: OperatorService,
private dialog: MatDialog,
private fb: FormBuilder
private fb: FormBuilder,
private formBuilder: FormBuilder,
private groupRepo: GroupRepositoryService
) {
super(titleService, translate, matSnackBar, storage);
this.canMultiSelect = true;
super(titleService, translate, matSnackBar);
this.newDirectoryForm = this.formBuilder.group({
title: ['', Validators.required],
access_groups_id: []
});
this.moveForm = this.formBuilder.group({
directory_id: []
});
this.directoryBehaviorSubject = this.repo.getDirectoryBehaviorSubject();
this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject();
}
/**
@ -160,6 +142,10 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
public ngOnInit(): void {
super.setTitle('Files');
this.repo.getDirectoryIdByPath(this.route.snapshot.url.map(x => x.path)).then(directoryId => {
this.changeDirectory(directoryId);
});
// Observe the logo actions
this.mediaManage.getLogoActions().subscribe(action => {
this.logoActions = action;
@ -171,17 +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 {
if (!this.editFile) {
this.router.navigate(['./upload'], { relativeTo: this.route });
} else {
this.editFile = false;
}
const path = '/mediafiles/upload/' + (this.directory ? this.directory.path : '');
this.router.navigate([path]);
}
/**
@ -194,7 +207,7 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
this.fileEditForm = this.fb.group({
title: [file.title, Validators.required],
hidden: [file.hidden]
access_groups_id: [file.access_groups_id]
});
const dialogRef = this.dialog.open(this.fileEditDialog, {
@ -214,7 +227,7 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
/**
* Click on the save button in edit mode
*/
public onSaveEditedFile(value: { title: string; hidden: any }): void {
public onSaveEditedFile(value: Partial<Mediafile>): void {
this.repo.update(value, this.fileToEdit).then(() => {
this.dialog.closeAll();
}, this.raiseError);
@ -233,19 +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
*
@ -278,7 +278,7 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
*/
public isUsedAs(file: ViewMediafile, mediaFileAction: string): boolean {
const config = this.mediaManage.getMediaConfig(mediaFileAction);
return config ? config.path === file.downloadUrl : false;
return config ? config.path === file.url : false;
}
/**
@ -312,14 +312,50 @@ export class MediafileListComponent extends BaseListViewComponent<ViewMediafile>
this.mediaManage.setAs(file, action);
}
/**
* Clicking escape while in editFileForm should deactivate edit mode.
*
* @param event The key that was pressed
*/
public keyDownFunction(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.editFile = false;
public createNewFolder(templateRef: TemplateRef<string>): void {
this.newDirectoryForm.reset();
const dialogRef = this.dialog.open(templateRef, {
width: '400px'
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
const mediafile = new Mediafile({
...this.newDirectoryForm.value,
parent_id: this.directory ? this.directory.id : null,
is_directory: true
});
this.repo.create(mediafile).then(null, this.raiseError);
}
});
}
public move(templateRef: TemplateRef<string>, mediafile: ViewMediafile): void {
this.newDirectoryForm.reset();
const dialogRef = this.dialog.open(templateRef, {
width: '400px'
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.repo.move([mediafile], this.moveForm.value.directory_id).then(null, this.raiseError);
}
});
}
private clearSubscriptions(): void {
if (this.folderSubscription) {
this.folderSubscription.unsubscribe();
this.folderSubscription = null;
}
if (this.directorySubscription) {
this.directorySubscription.unsubscribe();
this.directorySubscription = null;
}
}
public ngOnDestroy(): void {
super.ngOnDestroy();
this.clearSubscriptions();
}
}

View File

@ -17,7 +17,7 @@ export const MediafileAppConfig: AppConfig = {
],
mainMenuEntries: [
{
route: '/mediafiles',
route: '/mediafiles/files',
displayName: 'Files',
icon: 'attach_file',
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';
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({

View File

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

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

View File

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

View File

@ -40,7 +40,7 @@
<span translate>Attachments</span>:
<mat-list dense>
<mat-list-item *ngFor="let file of topic.attachments">
<a [routerLink]="file.downloadUrl" target="_blank">{{ file.getTitle() }}</a>
<a [routerLink]="file.url" target="_blank">{{ file.getTitle() }}</a>
</mat-list-item>
</mat-list>
</h3>

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.models import RESTModelMixin
from openslides.utils.utils import to_roman
from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE
from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE
from .access_permissions import ItemAccessPermissions, ListOfSpeakersAccessPermissions
@ -445,7 +445,7 @@ class Speaker(RESTModelMixin, models.Model):
objects = SpeakerManager()
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUPDATE)
"""
ForeinKey to the user who speaks.
"""

View File

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

View File

@ -22,7 +22,7 @@ from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.models import RESTModelMixin
from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE
from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE
from .access_permissions import AssignmentAccessPermissions
@ -38,7 +38,7 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model):
ForeinKey to the assignment.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUPDATE)
"""
ForeinKey to the user who is related to the assignment.
"""

View File

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

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

View File

@ -1,6 +1,8 @@
from typing import Any, Dict, Set
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class MediafilesAppConfig(AppConfig):
@ -17,6 +19,14 @@ class MediafilesAppConfig(AppConfig):
from . import serializers # noqa
from ..utils.access_permissions import required_user
# Validate, that the media_url is correct formatted:
# Must begin and end with a slash. It has to be at least "/".
media_url = settings.MEDIA_URL
if not media_url.startswith("/") or not media_url.endswith("/"):
raise ImproperlyConfigured(
"The MEDIA_URL setting must start and end with a slash"
)
# Define projector elements.
register_projector_slides()

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.db import models
from ..agenda.mixins import ListOfSpeakersMixin
from ..core.config import config
from ..utils.autoupdate import inform_changed_data
from ..utils.models import SET_NULL_AND_AUTOUPDATE, RESTModelMixin
from ..utils.models import RESTModelMixin
from ..utils.rest_api import ValidationError
from .access_permissions import MediafileAccessPermissions
@ -18,7 +22,21 @@ class MediafileManager(models.Manager):
Returns the normal queryset with all mediafiles. In the background
all related list of speakers are prefetched from the database.
"""
return self.get_queryset().prefetch_related("lists_of_speakers")
return self.get_queryset().prefetch_related(
"lists_of_speakers", "parent", "access_groups"
)
def delete(self, *args, **kwargs):
raise RuntimeError(
"Do not use the querysets delete function. Please delete every mediafile on it's own."
)
def get_file_path(mediafile, filename):
mediafile.original_filename = filename
ext = filename.split(".")[-1]
filename = "%s.%s" % (uuid.uuid4(), ext)
return os.path.join("file", filename)
class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model):
@ -30,55 +48,140 @@ class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model):
access_permissions = MediafileAccessPermissions()
can_see_permission = "mediafiles.can_see"
mediafile = models.FileField(upload_to="file")
mediafile = models.FileField(upload_to=get_file_path, null=True)
"""
See https://docs.djangoproject.com/en/dev/ref/models/fields/#filefield
for more information.
"""
title = models.CharField(max_length=255, unique=True)
title = models.CharField(max_length=255)
"""A string representing the title of the file."""
uploader = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True
)
"""A user the uploader of a file."""
original_filename = models.CharField(max_length=255)
hidden = models.BooleanField(default=False)
"""Whether or not this mediafile should be marked as hidden"""
timestamp = models.DateTimeField(auto_now_add=True)
create_timestamp = models.DateTimeField(auto_now_add=True)
"""A DateTimeField to save the upload date and time."""
is_directory = models.BooleanField(default=False)
parent = models.ForeignKey(
"self",
# The on_delete should be CASCADE_AND_AUTOUPDATE, but we do
# have to delete the actual file from every mediafile to ensure
# cleaning up the server files. This is ensured by the custom delete
# method of every mediafile. Do not use the delete method of the
# mediafile manager.
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="children",
)
access_groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True)
class Meta:
"""
Meta class for the mediafile model.
"""
ordering = ["title"]
ordering = ("title",)
default_permissions = ()
permissions = (
("can_see", "Can see the list of files"),
("can_see_hidden", "Can see hidden files"),
("can_upload", "Can upload files"),
("can_manage", "Can manage files"),
)
def create(self, *args, **kwargs):
self.validate_unique()
return super().create(*args, **kwargs)
def save(self, *args, **kwargs):
self.validate_unique()
return super().save(*args, **kwargs)
def validate_unique(self):
"""
`unique_together` is not working with foreign keys with possible `null` values.
So we do need to check this here.
"""
if (
Mediafile.objects.exclude(pk=self.pk)
.filter(title=self.title, parent=self.parent)
.exists()
):
raise ValidationError(
{"detail": "A mediafile with this title already exists in this folder."}
)
def __str__(self):
"""
Method for representation.
"""
return self.title
def save(self, *args, **kwargs):
def delete(self, skip_autoupdate=False):
mediafiles_to_delete = self.get_children_deep()
mediafiles_to_delete.append(self)
for mediafile in mediafiles_to_delete:
if mediafile.is_file:
# To avoid Django calling save() and triggering autoupdate we do not
# use the builtin method mediafile.mediafile.delete() but call
# mediafile.mediafile.storage.delete(...) directly. This may have
# unattended side effects so be careful especially when accessing files
# on server via Django methods (file, open(), save(), ...).
mediafile.mediafile.storage.delete(mediafile.mediafile.name)
mediafile._db_delete(skip_autoupdate=skip_autoupdate)
def _db_delete(self, *args, **kwargs):
""" Captures the original .delete() method. """
return super().delete(*args, **kwargs)
def get_children_deep(self):
""" Returns all children and all children of childrens and so forth. """
children = []
for child in self.children.all():
children.append(child)
children.extend(child.get_children_deep())
return children
@property
def path(self):
name = (self.title + "/") if self.is_directory else self.original_filename
if self.parent:
return self.parent.path + name
else:
return name
@property
def url(self):
return settings.MEDIA_URL + self.path
@property
def inherited_access_groups_id(self):
"""
Saves mediafile (mainly on create and update requests).
True: all groups
False: no permissions
List[int]: Groups with permissions
"""
result = super().save(*args, **kwargs)
# Send uploader via autoupdate because users without permission
# to see users may not have it but can get it now.
inform_changed_data(self.uploader)
return result
own_access_groups = [group.id for group in self.access_groups.all()]
if not self.parent:
return own_access_groups or True # either some groups or all
access_groups = self.parent.inherited_access_groups_id
if len(own_access_groups) > 0:
if isinstance(access_groups, bool) and access_groups:
return own_access_groups
elif isinstance(access_groups, bool) and not access_groups:
return False
else: # List[int]
access_groups = [
id
for id in cast(List[int], access_groups)
if id in own_access_groups
]
return access_groups or False
else:
return access_groups # We do not have restrictions, copy from parent.
def get_filesize(self):
"""
@ -89,6 +192,9 @@ class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model):
size = self.mediafile.size
except OSError:
size_string = "unknown"
except ValueError:
# happens, if this is a directory and no file exists
return None
else:
if size < 1024:
size_string = "< 1 kB"
@ -100,17 +206,31 @@ class Mediafile(RESTModelMixin, ListOfSpeakersMixin, models.Model):
size_string = "%d kB" % kB
return size_string
@property
def is_logo(self):
if self.is_directory:
return False
for key in config["logos_available"]:
if config[key]["path"] == self.mediafile.url:
if config[key]["path"] == self.url:
return True
return False
@property
def is_font(self):
if self.is_directory:
return False
for key in config["fonts_available"]:
if config[key]["path"] == self.mediafile.url:
if config[key]["path"] == self.url:
return True
return False
@property
def is_special_file(self):
return self.is_logo or self.is_font
@property
def is_file(self):
return not self.is_directory
def get_list_of_speakers_title_information(self):
return {"title": self.title}

View File

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

View File

@ -5,7 +5,14 @@ from django.db import models as dbmodels
from PyPDF2 import PdfFileReader
from PyPDF2.utils import PdfReadError
from ..utils.rest_api import FileField, ModelSerializer, SerializerMethodField
from ..utils.auth import get_group_model
from ..utils.rest_api import (
FileField,
IdPrimaryKeyRelatedField,
ModelSerializer,
SerializerMethodField,
ValidationError,
)
from .models import Mediafile
@ -16,13 +23,22 @@ class AngularCompatibleFileField(FileField):
return super(AngularCompatibleFileField, self).to_internal_value(data)
def to_representation(self, value):
if value is None:
if value is None or value.name is None:
return None
filetype = mimetypes.guess_type(value.path)[0]
filetype = mimetypes.guess_type(value.name)[0]
result = {"name": value.name, "type": filetype}
if filetype == "application/pdf":
try:
result["pages"] = PdfFileReader(open(value.path, "rb")).getNumPages()
if (
settings.DEFAULT_FILE_STORAGE
== "storages.backends.sftpstorage.SFTPStorage"
):
remote_path = value.storage._remote_path(value.name)
file_handle = value.storage.sftp.open(remote_path, mode="rb")
else:
file_handle = open(value.path, "rb")
result["pages"] = PdfFileReader(file_handle).getNumPages()
except FileNotFoundError:
# File was deleted from server. Set 'pages' to 0.
result["pages"] = 0
@ -40,6 +56,9 @@ class MediafileSerializer(ModelSerializer):
media_url_prefix = SerializerMethodField()
filesize = SerializerMethodField()
access_groups = IdPrimaryKeyRelatedField(
many=True, required=False, queryset=get_group_model().objects.all()
)
def __init__(self, *args, **kwargs):
"""
@ -48,6 +67,8 @@ class MediafileSerializer(ModelSerializer):
"""
super(MediafileSerializer, self).__init__(*args, **kwargs)
self.serializer_field_mapping[dbmodels.FileField] = AngularCompatibleFileField
# Make some fields read-oinly for updates (not creation)
if self.instance is not None:
self.fields["mediafile"].read_only = True
@ -58,13 +79,49 @@ class MediafileSerializer(ModelSerializer):
"title",
"mediafile",
"media_url_prefix",
"uploader",
"filesize",
"hidden",
"timestamp",
"access_groups",
"create_timestamp",
"is_directory",
"path",
"parent",
"list_of_speakers_id",
"inherited_access_groups_id",
)
read_only_fields = ("path",)
def validate(self, data):
title = data.get("title")
if title is not None and not title:
raise ValidationError({"detail": "The title must not be empty"})
parent = data.get("parent")
if parent and not parent.is_directory:
raise ValidationError({"detail": "parent must be a directory."})
if data.get("is_directory") and "/" in data.get("title", ""):
raise ValidationError(
{"detail": 'The name contains invalid characters: "/"'}
)
return super().validate(data)
def create(self, validated_data):
access_groups = validated_data.pop("access_groups", [])
mediafile = super().create(validated_data)
mediafile.access_groups.set(access_groups)
mediafile.save()
return mediafile
def update(self, instance, validated_data):
# remove is_directory, create_timestamp and parent from validated_data
# to prevent updating them (mediafile is ensured in the constructor)
validated_data.pop("is_directory", None)
validated_data.pop("create_timestamp", None)
validated_data.pop("parent", None)
return super().update(instance, validated_data)
def get_filesize(self, mediafile):
return mediafile.get_filesize()

View File

@ -1,10 +1,14 @@
from django.http import HttpResponseForbidden, HttpResponseNotFound
from django.http.request import QueryDict
from django.views.static import serve
from ..core.config import config
from ..utils.auth import has_perm
from ..utils.rest_api import ModelViewSet, ValidationError
from openslides.core.models import Projector
from ..utils.auth import has_perm, in_some_groups
from ..utils.autoupdate import inform_changed_data
from ..utils.rest_api import ModelViewSet, Response, ValidationError, list_route
from .access_permissions import MediafileAccessPermissions
from .config import watch_and_update_configs
from .models import Mediafile
@ -26,21 +30,9 @@ class MediafileViewSet(ModelViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ("list", "retrieve"):
if self.action in ("list", "retrieve", "metadata"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == "metadata":
result = has_perm(self.request.user, "mediafiles.can_see")
elif self.action == "create":
result = has_perm(self.request.user, "mediafiles.can_see") and has_perm(
self.request.user, "mediafiles.can_upload"
)
elif self.action in ("partial_update", "update"):
result = (
has_perm(self.request.user, "mediafiles.can_see")
and has_perm(self.request.user, "mediafiles.can_upload")
and has_perm(self.request.user, "mediafiles.can_manage")
)
elif self.action == "destroy":
elif self.action in ("create", "partial_update", "update", "move", "destroy"):
result = has_perm(self.request.user, "mediafiles.can_see") and has_perm(
self.request.user, "mediafiles.can_manage"
)
@ -52,62 +44,166 @@ class MediafileViewSet(ModelViewSet):
"""
Customized view endpoint to upload a new file.
"""
# Check permission to check if the uploader has to be changed.
uploader_id = self.request.data.get("uploader_id")
if (
uploader_id
and not has_perm(request.user, "mediafiles.can_manage")
and str(self.request.user.pk) != str(uploader_id)
):
self.permission_denied(request)
if not self.request.data.get("mediafile"):
# The form data may send the groups_id
if isinstance(request.data, QueryDict):
request.data._mutable = True
# convert formdata string "<id, <id>, id>" to a list of numbers.
if "access_groups_id" in request.data and isinstance(request.data, QueryDict):
access_groups_id = request.data.get("access_groups_id")
if access_groups_id:
request.data.setlist(
"access_groups_id", [int(x) for x in access_groups_id.split(", ")]
)
else:
del request.data["access_groups_id"]
is_directory = bool(request.data.get("is_directory", False))
if is_directory and request.data.get("mediafile"):
raise ValidationError(
{"detail": "Either create a path or a file, but not both"}
)
if not request.data.get("mediafile") and not is_directory:
raise ValidationError({"detail": "You forgot to provide a file."})
return super().create(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
"""
Customized view endpoint to delete uploaded files.
def destroy(self, *args, **kwargs):
with watch_and_update_configs():
response = super().destroy(*args, **kwargs)
return response
Does also delete the file from filesystem.
"""
# To avoid Django calling save() and triggering autoupdate we do not
# use the builtin method mediafile.mediafile.delete() but call
# mediafile.mediafile.storage.delete(...) directly. This may have
# unattended side effects so be careful especially when accessing files
# on server via Django methods (file, open(), save(), ...).
mediafile = self.get_object()
mediafile.mediafile.storage.delete(mediafile.mediafile.name)
def update(self, *args, **kwargs):
with watch_and_update_configs():
response = super().update(*args, **kwargs)
inform_changed_data(self.get_object().get_children_deep())
return response
# check if the file was used as a logo or font
for logo in config["logos_available"]:
if config[logo]["path"] == mediafile.mediafile.url:
config[logo] = {
"display_name": config[logo]["display_name"],
"path": "",
}
for font in config["fonts_available"]:
if config[font]["path"] == mediafile.mediafile.url:
config[font] = {
"display_name": config[font]["display_name"],
"default": config[font]["default"],
"path": "",
}
return super().destroy(request, *args, **kwargs)
@list_route(methods=["post"])
def move(self, request):
"""
{
ids: [<id>, <id>, ...],
directory_id: <id>
}
Move <ids> to the given directory_id. This will raise an error, if
the move would be recursive.
"""
# Validate data:
if not isinstance(request.data, dict):
raise ValidationError({"detail": "The data must be a dict"})
ids = request.data.get("ids")
if not isinstance(ids, list):
raise ValidationError({"detail": "The ids must be a list"})
for id in ids:
if not isinstance(id, int):
raise ValidationError({"detail": "All ids must be an int"})
directory_id = request.data.get("directory_id")
if directory_id is not None and not isinstance(directory_id, int):
raise ValidationError({"detail": "The directory_id must be an int"})
if directory_id is None:
directory = None
else:
try:
directory = Mediafile.objects.get(pk=directory_id, is_directory=True)
except Mediafile.DoesNotExist:
raise ValidationError({"detail": "The directory does not exist"})
ids_set = set(ids) # keep them in a set for fast lookup
ids = list(ids_set) # make ids unique
mediafiles = []
for id in ids:
try:
mediafiles.append(Mediafile.objects.get(pk=id))
except Mediafile.DoesNotExist:
raise ValidationError(
{"detail": f"The mediafile with id {id} does not exist"}
)
# Search for valid parents (None is not included, but also safe!)
if directory is not None:
valid_parent_ids = set()
queue = list(Mediafile.objects.filter(parent=None, is_directory=True))
for mediafile in queue:
if mediafile.pk in ids_set:
continue # not valid, because this is in the input data
valid_parent_ids.add(mediafile.pk)
queue.extend(
list(Mediafile.objects.filter(parent=mediafile, is_directory=True))
)
if directory_id not in valid_parent_ids:
raise ValidationError({"detail": "The directory is not valid"})
# Ok, update all mediafiles
with watch_and_update_configs():
for mediafile in mediafiles:
mediafile.parent = directory
mediafile.save(skip_autoupdate=True)
if directory is None:
inform_changed_data(Mediafile.objects.all())
else:
inform_changed_data(directory.get_children_deep())
return Response()
def get_mediafile(request, path):
"""
returnes the mediafile for the requested path and checks, if the user is
valid to retrieve the mediafile. If not, None will be returned.
A user must have all access permissions for all folders the the file itself,
or the file is a special file (logo or font), then it is always returned.
If the mediafile cannot be found, a Mediafile.DoesNotExist will be raised.
"""
if not path:
raise Mediafile.DoesNotExist()
parts = path.split("/")
parent = None
can_see = has_perm(request.user, "mediafiles.can_see")
for i, part in enumerate(parts):
is_directory = i < len(parts) - 1
if is_directory:
mediafile = Mediafile.objects.get(
parent=parent, is_directory=is_directory, title=part
)
else:
mediafile = Mediafile.objects.get(
parent=parent, is_directory=is_directory, original_filename=part
)
if mediafile.access_groups.exists() and not in_some_groups(
request.user.id, [group.id for group in mediafile.access_groups.all()]
):
can_see = False
parent = mediafile
# Check, if this file is projected
is_projected = False
for projector in Projector.objects.all():
for element in projector.elements:
name = element.get("name")
id = element.get("id")
if name == "mediafiles/mediafile" and id == mediafile.id:
is_projected = True
break
if not can_see and not mediafile.is_special_file and not is_projected:
mediafile = None
return mediafile
def protected_serve(request, path, document_root=None, show_indexes=False):
try:
mediafile = Mediafile.objects.get(mediafile=path)
mediafile = get_mediafile(request, path)
except Mediafile.DoesNotExist:
return HttpResponseNotFound(content="Not found.")
can_see = has_perm(request.user, "mediafiles.can_see")
is_special_file = mediafile.is_logo() or mediafile.is_font()
is_hidden_but_no_perms = mediafile.hidden and not has_perm(
request.user, "mediafiles.can_see_hidden"
)
if not is_special_file and (not can_see or is_hidden_but_no_perms):
return HttpResponseForbidden(content="Forbidden.")
if mediafile:
return serve(request, mediafile.mediafile.name, document_root, show_indexes)
else:
return serve(request, path, document_root, show_indexes)
return HttpResponseForbidden(content="Forbidden.")

View File

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

View File

@ -19,7 +19,7 @@ from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.models import RESTModelMixin
from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE
from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE
from .access_permissions import (
CategoryAccessPermissions,
MotionAccessPermissions,
@ -657,7 +657,7 @@ class Submitter(RESTModelMixin, models.Model):
Use custom Manager.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUPDATE)
"""
ForeignKey to the user who is the submitter.
"""
@ -707,7 +707,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model):
objects = MotionChangeRecommendationManager()
motion = models.ForeignKey(
Motion, on_delete=CASCADE_AND_AUTOUODATE, related_name="change_recommendations"
Motion, on_delete=CASCADE_AND_AUTOUPDATE, related_name="change_recommendations"
)
"""The motion to which the change recommendation belongs."""

View File

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

View File

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

View File

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

View File

@ -191,7 +191,7 @@ def SET_NULL_AND_AUTOUPDATE(
models.SET_NULL(collector, field, sub_objs, using)
def CASCADE_AND_AUTOUODATE(
def CASCADE_AND_AUTOUPDATE(
collector: Any, field: Any, sub_objs: Any, using: Any
) -> None:
"""

View File

@ -1,7 +1,11 @@
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from openslides.mediafiles.models import Mediafile
from openslides.utils.test import TestCase
from ..helpers import count_queries
@ -12,6 +16,8 @@ def test_mediafiles_db_queries():
Tests that only the following db queries are done:
* 1 requests to get the list of all files
* 1 request to get all lists of speakers.
* 1 request to get all groups
* 1 request to prefetch parents
"""
for index in range(10):
Mediafile.objects.create(
@ -19,4 +25,190 @@ def test_mediafiles_db_queries():
mediafile=SimpleUploadedFile(f"some_file{index}", b"some content."),
)
assert count_queries(Mediafile.get_elements) == 2
assert count_queries(Mediafile.get_elements) == 4
class TestCreation(TestCase):
def setUp(self):
self.client = APIClient()
self.client.login(username="admin", password="admin")
self.file = SimpleUploadedFile("some_file.ext", b"some content.")
def test_simple_file(self):
response = self.client.post(
reverse("mediafile-list"),
{"title": "test_title_ahyo1uifoo9Aiph2av5a", "mediafile": self.file},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
mediafile = Mediafile.objects.get()
self.assertEqual(mediafile.title, "test_title_ahyo1uifoo9Aiph2av5a")
self.assertFalse(mediafile.is_directory)
self.assertTrue(mediafile.mediafile.name)
self.assertEqual(mediafile.path, mediafile.original_filename)
def test_simple_directory(self):
response = self.client.post(
reverse("mediafile-list"),
{"title": "test_title_ahyo1uifoo9Aiph2av5a", "is_directory": True},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
mediafile = Mediafile.objects.get()
self.assertEqual(mediafile.title, "test_title_ahyo1uifoo9Aiph2av5a")
self.assertTrue(mediafile.is_directory)
self.assertEqual(mediafile.mediafile.name, "")
self.assertEqual(mediafile.path, mediafile.title + "/")
def test_file_and_directory(self):
response = self.client.post(
reverse("mediafile-list"),
{
"title": "test_title_ahyo1uifoo9Aiph2av5a",
"is_directory": True,
"mediafile": self.file,
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(Mediafile.objects.exists())
def test_mediafile_twice(self):
title = "test_title_kFJq83fjmqo2babfqk3f"
Mediafile.objects.create(is_directory=True, title=title)
response = self.client.post(
reverse("mediafile-list"), {"title": title, "is_directory": True}
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Mediafile.objects.count(), 1)
def test_without_mediafile(self):
response = self.client.post(reverse("mediafile-list"), {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(Mediafile.objects.exists())
def test_without_title(self):
response = self.client.post(reverse("mediafile-list"), {"is_directory": True})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(Mediafile.objects.exists())
def test_with_empty_title(self):
response = self.client.post(
reverse("mediafile-list"), {"is_directory": True, "title": ""}
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(Mediafile.objects.exists())
def test_directory_with_slash(self):
response = self.client.post(
reverse("mediafile-list"),
{"title": "test_title_with_/", "is_directory": True},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(Mediafile.objects.exists())
def test_with_parent(self):
parent_title = "test_title_3q0cqghZRFewocjwferT"
title = "test_title_gF3if8jmvrbnwdksg4je"
Mediafile.objects.create(is_directory=True, title=parent_title)
response = self.client.post(
reverse("mediafile-list"),
{"title": title, "is_directory": True, "parent_id": 1},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Mediafile.objects.count(), 2)
mediafile = Mediafile.objects.get(title="test_title_gF3if8jmvrbnwdksg4je")
self.assertEqual(mediafile.parent.title, "test_title_3q0cqghZRFewocjwferT")
self.assertEqual(mediafile.path, parent_title + "/" + title + "/")
def test_with_file_as_parent(self):
Mediafile.objects.create(
title="test_title_qejOVM84gw8ghwpKnqeg", mediafile=self.file
)
response = self.client.post(
reverse("mediafile-list"),
{
"title": "test_title_poejvvlmmorsgeroemr9",
"is_directory": True,
"parent_id": 1,
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Mediafile.objects.count(), 1)
def test_with_access_groups(self):
response = self.client.post(
reverse("mediafile-list"),
{
"title": "test_title_dggjwevBnUngelkdviom",
"is_directory": True,
# This is the format, if it would be provided by JS `FormData`.
"access_groups_id": "2, 4",
},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertTrue(Mediafile.objects.exists())
mediafile = Mediafile.objects.get()
self.assertEqual(
sorted([group.id for group in mediafile.access_groups.all()]), [2, 4]
)
# TODO: List and retrieve
class TestUpdate(TestCase):
"""
Tree:
-dir
-mediafileA
-mediafileB
"""
def setUp(self):
self.client = APIClient()
self.client.login(username="admin", password="admin")
self.dir = Mediafile.objects.create(title="dir", is_directory=True)
self.fileA = SimpleUploadedFile("some_fileA.ext", b"some content.")
self.mediafileA = Mediafile.objects.create(
title="mediafileA", mediafile=self.fileA, parent=self.dir
)
self.fileB = SimpleUploadedFile("some_fileB.ext", b"some content.")
self.mediafileB = Mediafile.objects.create(
title="mediafileB", mediafile=self.fileB
)
def test_update(self):
response = self.client.put(
reverse("mediafile-detail", args=[self.mediafileA.pk]),
{"title": "test_title_gpasgrmg*miGUM)EAyGO", "access_groups_id": [2, 4]},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
mediafile = Mediafile.objects.get(pk=self.mediafileA.pk)
self.assertEqual(mediafile.title, "test_title_gpasgrmg*miGUM)EAyGO")
self.assertEqual(mediafile.path, "dir/some_fileA.ext")
self.assertEqual(
sorted([group.id for group in mediafile.access_groups.all()]), [2, 4]
)
def test_update_directory(self):
response = self.client.put(
reverse("mediafile-detail", args=[self.dir.pk]),
{"title": "test_title_seklMOIGGihdjJBNaflkklnlg"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
dir = Mediafile.objects.get(pk=self.dir.pk)
self.assertEqual(dir.title, "test_title_seklMOIGGihdjJBNaflkklnlg")
mediafile = Mediafile.objects.get(pk=self.mediafileA.pk)
self.assertEqual(
mediafile.path, "test_title_seklMOIGGihdjJBNaflkklnlg/some_fileA.ext"
)
def test_update_parent_id(self):
""" Assert, that the parent id does not change """
response = self.client.put(
reverse("mediafile-detail", args=[self.mediafileA.pk]),
{"title": self.mediafileA.title, "parent_id": None},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
mediafile = Mediafile.objects.get(pk=self.mediafileA.pk)
self.assertTrue(mediafile.parent)
self.assertEqual(mediafile.parent.pk, self.dir.pk)

View File

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