Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 0.9.10 #100

Merged
merged 16 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ OSIS_DOCUMENT_UPLOAD_LIMIT = '10/minute'
OSIS_DOCUMENT_TOKEN_MAX_AGE = 60 * 15
# A temporary upload max age (in seconds) after which it may be deleted by the celery task
OSIS_DOCUMENT_TEMP_UPLOAD_MAX_AGE = 60 * 15
# Upload max age (in seconds) for export expiration policy (default = 15 days)
OSIS_DOCUMENT_EXPORT_EXPIRATION_POLICY_AGE = 60 * 60 * 24 * 15
# When used on multiple servers, set the domains on which raw files may be displayed (for Content Security Policy)
OSIS_DOCUMENT_DOMAIN_LIST = [
'127.0.0.1:8001',
Expand Down Expand Up @@ -462,11 +464,28 @@ element.addEventListener('numPages', ({detail: {numPages}}) => {
});
```

## Cropping images before uploading them

You can add `with_cropping=True` to `FileField`, `FileUploadField` or `FileUploadWidget` to add the ability to crop
any image with [Cropper.js](https://fengyuanchen.github.io/cropperjs/) before they are uploaded. You can also pass custom options through the `cropping_options` parameter:
```python
class ModelName(models.Model):
...
model_field_name = FileField(
...
with_cropping=True,
cropping_options={"aspectRatio": 16 / 9}
...
)
...
```


# Contributing to OSIS-Document

## Frontend

To contribute to the frontend part of this module, install `npm` > 6 (included in [https://nodejs.org/en/download/](nodejs)), and run:
To contribute to the frontend part of this module, install `npm` > 6 (included in [nodejs](https://nodejs.org/en/download/)), and run:
```console
cd osis_document
npm clean-install
Expand Down
13 changes: 12 additions & 1 deletion frontend/DocumentUploader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
:max-size="maxSize"
:mimetypes="mimetypes"
:automatic="automaticUpload"
:with-cropping="withCropping"
:cropping-options="croppingOptions"
@delete="delete fileList[index]; delete tokens[index];"
@set-token="tokens[index] = $event; delete fileList[index];"
/>
Expand Down Expand Up @@ -163,6 +165,14 @@ export default defineComponent({
type: Number,
default: 0,
},
withCropping: {
type: Boolean,
default: false,
},
croppingOptions: {
type: Object,
default: () => {return {};},
},
},
data() {
let indexGenerated = 0;
Expand Down Expand Up @@ -241,12 +251,13 @@ export default defineComponent({
},
onFilePicked(e: Event) {
const files = (e.target as HTMLInputElement).files;

this.isDragging = false;
files && Array.from(files).forEach(file => {
this.indexGenerated++;
this.fileList[this.indexGenerated] = file;
this.tokens[this.indexGenerated] = null;
});
this.isDragging = false;
},
},
});
Expand Down
37 changes: 37 additions & 0 deletions frontend/components/UploadEntry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ it('should mount', async () => {
window.URL.revokeObjectURL = revokeUrl;

const wrapper = mount(UploadEntry, {props});
await nextTick();
expect(wrapper.text()).toContain('image/png');
expect(wrapper.text()).toContain('foobar.png');
expect(createUrl).toHaveBeenCalled();
Expand Down Expand Up @@ -167,3 +168,39 @@ it('should fail upload displaying message', async () => {
expect(wrapper.find('.btn-danger').exists()).toBe(true);
expect(wrapper.find('.text-danger').text()).toBe("request_error");
});

it('should not upload with cropping enabled', async () => {
vi.useFakeTimers();
// Mocks
const server = newServer({
post: ['http://dummyhost/request-upload', function (xhr) {
xhr.uploadProgress(512); // 50 % of 1024 bytes
setTimeout(() => {
xhr.uploadProgress(1024);// 100 % of 1024 bytes
setTimeout(() => {
xhr.respond(
200,
{'Content-Type': 'application/json'},
'{"token": "0123456789"}',
);
});
});
}],
}).install();

const wrapper = mount(UploadEntry, {
props: {
file: new File([new ArrayBuffer(1024)], 'foobar.png', {
type: 'image/png',
}),
baseUrl: 'http://dummyhost/',
withCropping: true,
},
unmounted() {
server.remove();
},
});
await flushPromises();

expect(server.getRequestLog()).toHaveLength(0);
});
123 changes: 117 additions & 6 deletions frontend/components/UploadEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@
<template>
<li class="media">
<div
v-if="isImage"
v-if="isImage && !!uploadFile && (!withCropping || isCropped)"
class="media-left"
>
<img
class="media-object img-thumbnail"
:src="fileUrl"
:alt="file.name"
:alt="uploadFile.name"
width="80"
@load="revokeUrl(fileUrl)"
>
Expand Down Expand Up @@ -74,6 +74,51 @@
</button>
</div>
</li>

<div
v-if="showCropper"
ref="cropperModal"
class="modal fade"
tabindex="-1"
role="dialog"
data-backdrop="static"
>
<div
class="modal-dialog modal-lg"
role="document"
>
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ $t('upload_entry.crop_header') }}</h4>
</div>
<div class="modal-body">
<img
ref="image"
:src="fileUrl"
:alt="file.name"
@load="initializeCropper"
>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-primary"
@click="crop"
>
{{ $t('upload_entry.crop') }}
</button>
<button
type="button"
class="btn btn-danger"
data-dismiss="modal"
@click="$emit('delete')"
>
{{ $t('upload_entry.cancel') }}
</button>
</div>
</div>
</div>
</div>
</template>

<script lang="ts">
Expand All @@ -84,6 +129,9 @@ import {humanizedSize} from '../utils';
import EventBus from '../event-bus';
import {defineComponent} from 'vue';
import type {ErrorReponse, TokenReponse} from "../interfaces";
import Cropper from 'cropperjs';

import 'cropperjs/dist/cropper.min.css';

export default defineComponent({
name: 'UploadEntry',
Expand All @@ -108,6 +156,14 @@ export default defineComponent({
type: Boolean,
default: true,
},
withCropping: {
type: Boolean,
default: false,
},
croppingOptions: {
type: Object,
default: () => {return {};},
},
},
emits: {
setToken(token: string) {
Expand All @@ -119,38 +175,87 @@ export default defineComponent({
},
data() {
return {
uploadFile: null as null | File,
progress: 0,
token: '',
error: '',
isCropped: false,
cropper: null as null | Cropper,
};
},
computed: {
showCropper() {
return this.withCropping && this.isImage && !this.isCropped;
},
isImage: function () {
return this.file.type.split('/')[0] === 'image';
},
fileUrl: function () {
return URL.createObjectURL(this.file);
if (!this.uploadFile) {
return '';
}
return URL.createObjectURL(this.uploadFile);
},
humanizedSize: function () {
return humanizedSize(this.file.size);
},
},
mounted() {
// It might be overwritten if cropping is used
this.uploadFile = this.file;

if (this.maxSize && this.file.size > this.maxSize) {
this.error = this.$t('upload_entry.too_large');
} else if (this.mimetypes.length && !this.mimetypes.includes(this.file.type)) {
this.error = this.$tc('upload_entry.wrong_type', this.mimetypes.length, {types: this.mimetypes.join(', ')});
} else if (this.automatic) {
} else if (this.automatic && !this.withCropping) {
this.sendFile();
} else {
} else if (!this.withCropping) {
EventBus.on('upload', this.sendFile);
}
},
methods: {
crop() {
if (this.cropper === null) {
console.error('cropper is null.');
return ;
}
this.cropper.getCroppedCanvas().toBlob((blob) => {
if (blob === null) {
console.error('blob is null.');
return ;
}
this.uploadFile = new File([blob], this.file.name, {type: this.file.type});
jQuery(this.$refs['cropperModal']).modal('hide');
this.isCropped = true;
if (this.automatic) {
this.sendFile();
}
});
},
initializeCropper() {
this.cropper = new Cropper(this.$refs['image'] as HTMLImageElement, {...{
minContainerWidth: 600,
minContainerHeight: 300,
viewMode: 2,
autoCropArea: 1,
movable: false,
rotatable: false,
scalable: false,
zoomable: false,
toggleDragModeOnDblclick: false,
}, ...this.croppingOptions});
jQuery(this.$refs['cropperModal']).modal('show');
},
revokeUrl: function (url: string) {
URL.revokeObjectURL(url);
},
sendFile: function () {
if (this.uploadFile === null) {
console.error('uploadFile is null.');
return ;
}

const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
this.progress = Math.round((e.loaded * 100) / e.total);
Expand All @@ -173,10 +278,16 @@ export default defineComponent({
});

const fd = new FormData();
fd.append('file', this.file);
fd.append('file', this.uploadFile);
// Initiate a multipart/form-data upload
xhr.send(fd);
},
},
});
</script>

<style>
.cropper-container {
margin: auto;
}
</style>
3 changes: 3 additions & 0 deletions frontend/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export default {
completion: '{progress}% uploaded',
too_large: 'File is too large',
wrong_type: 'The file must have the following type: "{types}" | The file must have one of the following types: "{types}"',
crop_header: 'Please choose the part you want to keep.',
crop: 'Crop',
cancel: 'Cancel',
},
view_entry: {
rotate_left: 'Rotate image left',
Expand Down
3 changes: 3 additions & 0 deletions frontend/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export default {
completion: '{progress}% transférés',
too_large: 'Le fichier est trop lourd',
wrong_type: 'Le fichier doit être du type "{types}" | Le fichier doit être d\'un des types suivants : "{types}"',
crop_header: 'Veuillez choisir la partie à garder.',
crop: 'Recadrer',
cancel: 'Annuler',
},
view_entry: {
rotate_left: "Faire pivoter l'image à gauche",
Expand Down
7 changes: 7 additions & 0 deletions frontend/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface UploaderProps extends Record<string, unknown> {
values?: string[],
automaticUpload?: boolean,
editableFilename?: boolean,
withCropping?: boolean,
}

interface VisualizerProps extends Record<string, unknown> {
Expand Down Expand Up @@ -74,6 +75,12 @@ function initDocumentComponents() {
if (typeof elem.dataset.editableFilename !== 'undefined') {
props.editableFilename = elem.dataset.editableFilename === 'true';
}
if (typeof elem.dataset.withCropping !== 'undefined') {
props.withCropping = elem.dataset.withCropping === 'true';
}
if (typeof elem.dataset.croppingOptions !== 'undefined') {
props.croppingOptions = JSON.parse(elem.dataset.croppingOptions);
}
createApp(Uploader, props).use(i18n).mount(elem);
});

Expand Down
7 changes: 7 additions & 0 deletions osis_document/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from django.core.exceptions import FieldDoesNotExist, FieldError
from django.utils.translation import gettext_lazy as _
from osis_document.models import Token, Upload, OsisDocumentFileExtensionValidator, OsisDocumentMimeMatchValidator
from osis_document.enums import DocumentExpirationPolicy
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

Expand Down Expand Up @@ -124,6 +125,11 @@ class ConfirmUploadRequestSerializer(serializers.Serializer):
help_text="This attribute provides a way of setting the upload directory",
required=False,
)
document_expiration_policy = serializers.ChoiceField(
help_text="This attribute provides a way of setting the expiration policy of the file",
choices=DocumentExpirationPolicy.choices(),
required=False,
)

def to_internal_value(self, data):
internal_value = super().to_internal_value(data)
Expand Down Expand Up @@ -186,6 +192,7 @@ class Meta:
'upload_id',
'access',
'expires_at',
'for_modified_upload',
]
list_serializer_class = TokenListSerializer

Expand Down
Loading
Loading