support deleting extensions #69
65
README.md
65
README.md
@ -61,53 +61,46 @@ log into http://extensions.local:8111/admin/ with `admin`/`admin`.
|
|||||||
|
|
||||||
### Blender ID
|
### Blender ID
|
||||||
|
|
||||||
All the configuration of this web app is done via environment variables
|
|
||||||
(there's only one `settings.py` regardless of an environment).
|
|
||||||
|
|
||||||
Blender Extensions, as all other Blender web services, uses Blender ID.
|
Blender Extensions, as all other Blender web services, uses Blender ID.
|
||||||
To configure OAuth login, first create a new OAuth2 application in Blender ID with the following settings:
|
Blender Extensions can also receive Blender ID account modifications such as badge updates
|
||||||
|
via a webhook.
|
||||||
|
|
||||||
* Redirect URIs: `http://extensions.local:8111/oauth/authorized`
|
For development, Blender ID's code contains a fixture with an OAuth app and a webhook
|
||||||
* Client type: "Confidential";
|
that should work without any changes to default configuration.
|
||||||
* Authorization grant type: "Authorization code";
|
To load this fixture, go to your development Blender ID and run the following:
|
||||||
* Name: "Blender Extensions Dev";
|
|
||||||
|
|
||||||
Then copy client ID and secret and save them as `BID_OAUTH_CLIENT` and `BID_OAUTH_SECRET` into a `.env` file:
|
./manage.py loaddata blender_extensions_devserver
|
||||||
|
|
||||||
export BID_OAUTH_CLIENT=<CLIENT ID HERE>
|
|
||||||
export BID_OAUTH_SECRET=<SECRET HERE>
|
|
||||||
|
|
||||||
Run the dev server using the following command:
|
|
||||||
|
|
||||||
source .env && ./manage.py runserver 8111
|
|
||||||
|
|
||||||
#### Webhook
|
|
||||||
|
|
||||||
Blender Extensions can receive account modifications such as badge updates via a webhook,
|
|
||||||
which has to be configured in Blender ID admin separately from the OAuth app.
|
|
||||||
|
|
||||||
In Admin › Blender-ID API › Webhooks click `Add Webhook` and set the following:
|
|
||||||
|
|
||||||
* Name: "Blender Extensions Dev";
|
|
||||||
* URL: `http://extensions.local:8111/webhooks/user-modified/`;
|
|
||||||
* App: choose the app created in the previous step;
|
|
||||||
|
|
||||||
Then copy webhook's secret into the `.env` file as `BID_WEBHOOK_USER_MODIFIED_SECRET`:
|
|
||||||
|
|
||||||
export BID_WEBHOOK_USER_MODIFIED_SECRET=<WEBHOOK SECRET HERE>
|
|
||||||
|
|
||||||
**N.B.**: the webhook view delegates the actual updating of the user profile
|
**N.B.**: the webhook view delegates the actual updating of the user profile
|
||||||
to a background task, so in order to see the updates locally, start the processing of
|
to a background task, so in order to see the updates locally, start the processing of
|
||||||
tasks using the following:
|
tasks using the following:
|
||||||
|
|
||||||
source .env && ./manage.py process_tasks
|
./manage.py process_tasks
|
||||||
|
|
||||||
#### Blender ID and staging/production
|
#### Blender ID and staging/production
|
||||||
|
|
||||||
The above steps use local development setup as example.
|
For staging/production, create an OAuth2 application in Blender ID using
|
||||||
For staging/production the steps are the same, the only differences being
|
Admin › Blender-ID › OAuth2 applications -> Add:
|
||||||
the names of the app and the webhook,
|
|
||||||
and `http://extensions.local:8111` being replaced with the appropriate base URL.
|
* Redirect URIs: `https://staging.extensions.blender.org/oauth/authorized` (`https://extensions.blender.org` for production);
|
||||||
|
* Client type: "Confidential";
|
||||||
|
* Authorization grant type: "Authorization code";
|
||||||
|
* Name: "Blender Extensions Staging" (or "Blender Extensions" for production);
|
||||||
|
|
||||||
|
Copy client ID and secret and save them as `BID_OAUTH_CLIENT` and `BID_OAUTH_SECRET` into a `.env` file:
|
||||||
|
|
||||||
|
export BID_OAUTH_CLIENT=<CLIENT ID HERE>
|
||||||
|
export BID_OAUTH_SECRET=<SECRET HERE>
|
||||||
|
|
||||||
|
Create a webhook using Admin › Blender-ID API › Webhooks > Add:
|
||||||
|
|
||||||
|
* Name: "Blender Extensions Staging" (or "Blender Extensions" for production)";
|
||||||
|
* URL: `https://staging.extensions.blender.org/webhooks/user-modified/` (or `https://extensions.blender.org/webhooks/user-modified/` for production);
|
||||||
|
* App: choose the app created in the previous step;
|
||||||
|
|
||||||
|
Copy webhook's secret into the `.env` file as `BID_WEBHOOK_USER_MODIFIED_SECRET`:
|
||||||
|
|
||||||
|
export BID_WEBHOOK_USER_MODIFIED_SECRET=<WEBHOOK SECRET HERE>
|
||||||
|
|
||||||
## Pre-commit hooks
|
## Pre-commit hooks
|
||||||
|
|
||||||
|
21
abuse/tests/test_abuse.py
Normal file
21
abuse/tests/test_abuse.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from common.tests.factories.extensions import create_approved_version
|
||||||
|
from common.tests.factories.users import UserFactory
|
||||||
|
|
||||||
|
POST_DATA = {
|
||||||
|
'message': 'test message',
|
||||||
|
'reason': '127',
|
||||||
|
'version': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReportTest(TestCase):
|
||||||
|
def test_report_twice(self):
|
||||||
|
version = create_approved_version()
|
||||||
|
user = UserFactory()
|
||||||
|
self.client.force_login(user)
|
||||||
|
url = version.extension.get_report_url()
|
||||||
|
_ = self.client.post(url, POST_DATA)
|
||||||
|
response = self.client.get(url, follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
@ -5,7 +5,7 @@ from django.http import Http404
|
|||||||
from django.views.generic import DetailView
|
from django.views.generic import DetailView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
from django.views.generic.edit import CreateView
|
from django.views.generic.edit import CreateView
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
|
||||||
from .forms import ReportForm
|
from .forms import ReportForm
|
||||||
from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_REVIEW
|
from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_REVIEW
|
||||||
@ -37,15 +37,20 @@ class ReportList(
|
|||||||
class ReportExtensionView(
|
class ReportExtensionView(
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
extensions.views.mixins.ListedExtensionMixin,
|
extensions.views.mixins.ListedExtensionMixin,
|
||||||
UserPassesTestMixin,
|
|
||||||
CreateView,
|
CreateView,
|
||||||
):
|
):
|
||||||
model = AbuseReport
|
model = AbuseReport
|
||||||
form_class = ReportForm
|
form_class = ReportForm
|
||||||
|
|
||||||
def test_func(self) -> bool:
|
def get(self, request, *args, **kwargs):
|
||||||
# TODO: best to redirect to existing report or show a friendly message
|
extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug'])
|
||||||
return not AbuseReport.exists(user_id=self.request.user.pk, extension_id=self.extension.id)
|
report = AbuseReport.objects.filter(
|
||||||
|
reporter_id=self.request.user.pk, extension_id=extension.id
|
||||||
|
).first()
|
||||||
|
if report is not None:
|
||||||
|
return redirect('abuse:view-report', pk=report.pk)
|
||||||
|
else:
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
"""Link newly created rating to latest version and current user."""
|
"""Link newly created rating to latest version and current user."""
|
||||||
|
@ -253,9 +253,13 @@ if SENTRY_DSN:
|
|||||||
BLENDER_ID = {
|
BLENDER_ID = {
|
||||||
# MUST end in a slash:
|
# MUST end in a slash:
|
||||||
'BASE_URL': os.environ.get('BID_BASE_URL', 'http://id.local:8000/'),
|
'BASE_URL': os.environ.get('BID_BASE_URL', 'http://id.local:8000/'),
|
||||||
'OAUTH_CLIENT': os.environ.get('BID_OAUTH_CLIENT'),
|
'OAUTH_CLIENT': os.environ.get('BID_OAUTH_CLIENT', 'BLENDER-EXTENSIONS-DEV'),
|
||||||
'OAUTH_SECRET': os.environ.get('BID_OAUTH_SECRET'),
|
'OAUTH_SECRET': os.environ.get(
|
||||||
'WEBHOOK_USER_MODIFIED_SECRET': os.environ.get('BID_WEBHOOK_USER_MODIFIED_SECRET'),
|
'BID_OAUTH_SECRET', 'DEVELOPMENT-ONLY NON SECRET NEVER USE IN PRODUCTION'
|
||||||
|
),
|
||||||
|
'WEBHOOK_USER_MODIFIED_SECRET': os.environ.get(
|
||||||
|
'BID_WEBHOOK_USER_MODIFIED_SECRET', 'DEVELOPMENT-ONLY NON SECRET NEVER USE IN PRODUCTION'
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
TAGGIT_CASE_INSENSITIVE = True
|
TAGGIT_CASE_INSENSITIVE = True
|
||||||
@ -316,3 +320,7 @@ EMAIL_HOST = os.getenv('EMAIL_HOST')
|
|||||||
EMAIL_PORT = os.getenv('EMAIL_PORT', '587')
|
EMAIL_PORT = os.getenv('EMAIL_PORT', '587')
|
||||||
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
|
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
|
||||||
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
|
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
|
||||||
|
|
||||||
|
# FIXME: this controls the initial widget rendered server-side, and server-side validation.
|
||||||
|
# If this list changes, the "accept" attribute has to be also updated in appendImageUploadForm.
|
||||||
|
ALLOWED_PREVIEW_MIMETYPES = ('image/jpg', 'image/jpeg', 'image/png', 'image/webp', 'video/mp4')
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import extensions.models
|
import extensions.models
|
||||||
import files.models
|
import files.models
|
||||||
|
from files.utils import guess_mimetype_from_ext, guess_mimetype_from_content
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -33,11 +37,15 @@ EditPreviewFormSet = forms.inlineformset_factory(
|
|||||||
|
|
||||||
|
|
||||||
class AddPreviewFileForm(forms.ModelForm):
|
class AddPreviewFileForm(forms.ModelForm):
|
||||||
|
msg_unexpected_file_type = _('Choose a JPEG, PNG or WebP image, or an MP4 video')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = files.models.File
|
model = files.models.File
|
||||||
fields = ('caption', 'source')
|
fields = ('caption', 'source')
|
||||||
widgets = {
|
widgets = {
|
||||||
'source': forms.ClearableFileInput(attrs={'accept': 'image/*,video/*'}),
|
'source': forms.ClearableFileInput(
|
||||||
|
attrs={'accept': ','.join(settings.ALLOWED_PREVIEW_MIMETYPES)}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
caption = forms.CharField(max_length=255, required=False)
|
caption = forms.CharField(max_length=255, required=False)
|
||||||
@ -48,6 +56,20 @@ class AddPreviewFileForm(forms.ModelForm):
|
|||||||
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
|
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean_source(self, *args, **kwargs):
|
||||||
|
# Here source is a file-like object
|
||||||
|
source = self.cleaned_data['source']
|
||||||
|
# Guess MIME-type based on extension first
|
||||||
|
mimetype_from_ext = guess_mimetype_from_ext(source.name)
|
||||||
|
if mimetype_from_ext not in settings.ALLOWED_PREVIEW_MIMETYPES:
|
||||||
|
raise ValidationError(self.msg_unexpected_file_type, code='invalid')
|
||||||
|
# Guess MIME-type based on file's content
|
||||||
|
mimetype_from_bytes = guess_mimetype_from_content(source)
|
||||||
|
if mimetype_from_bytes not in settings.ALLOWED_PREVIEW_MIMETYPES:
|
||||||
|
raise ValidationError(self.msg_unexpected_file_type, code='invalid')
|
||||||
|
# TODO: handle scenario when guessed MIME-types don't match
|
||||||
|
return source
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Save Preview from the cleaned form data."""
|
"""Save Preview from the cleaned form data."""
|
||||||
# If file with this hash was already uploaded by the same user, return it
|
# If file with this hash was already uploaded by the same user, return it
|
||||||
|
@ -41,7 +41,7 @@ function appendImageUploadForm() {
|
|||||||
<input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Describe the preview">
|
<input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Describe the preview">
|
||||||
</div>
|
</div>
|
||||||
<div class="align-items-center d-flex justify-content-between">
|
<div class="align-items-center d-flex justify-content-between">
|
||||||
<input accept="image/*" class="form-control js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
|
<input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
|
||||||
<ul class="pt-0">
|
<ul class="pt-0">
|
||||||
<li>
|
<li>
|
||||||
<button class="btn btn-link btn-sm js-btn-reset-img-upload-form pl-2 pr-0"><i class="i-refresh"></i> Reset</button>
|
<button class="btn btn-link btn-sm js-btn-reset-img-upload-form pl-2 pr-0"><i class="i-refresh"></i> Reset</button>
|
||||||
|
BIN
extensions/tests/files/test_preview_image_0001.gif
Normal file
BIN
extensions/tests/files/test_preview_image_0001.gif
Normal file
Binary file not shown.
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
BIN
extensions/tests/files/test_preview_image_0001.webp
Normal file
BIN
extensions/tests/files/test_preview_image_0001.webp
Normal file
Binary file not shown.
Before Width: | Height: | Size: 154 B After Width: | Height: | Size: 154 B |
BIN
extensions/tests/files/test_preview_image_renamed_gif.png
Normal file
BIN
extensions/tests/files/test_preview_image_renamed_gif.png
Normal file
Binary file not shown.
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
@ -100,7 +100,7 @@ class UpdateTest(TestCase):
|
|||||||
'form-TOTAL_FORMS': ['2'],
|
'form-TOTAL_FORMS': ['2'],
|
||||||
}
|
}
|
||||||
file_name1 = 'test_preview_image_0001.png'
|
file_name1 = 'test_preview_image_0001.png'
|
||||||
file_name2 = 'test_preview_image_0002.png'
|
file_name2 = 'test_preview_image_0001.webp'
|
||||||
url = extension.get_manage_url()
|
url = extension.get_manage_url()
|
||||||
user = extension.authors.first()
|
user = extension.authors.first()
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
@ -134,15 +134,15 @@ class UpdateTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
file2.original_hash,
|
file2.original_hash,
|
||||||
'sha256:f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3',
|
'sha256:213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616',
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
file2.hash, 'sha256:f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3'
|
file2.hash, 'sha256:213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616'
|
||||||
)
|
)
|
||||||
self.assertEqual(file1.original_name, file_name1)
|
self.assertEqual(file1.original_name, file_name1)
|
||||||
self.assertEqual(file2.original_name, file_name2)
|
self.assertEqual(file2.original_name, file_name2)
|
||||||
self.assertEqual(file1.size_bytes, 1163)
|
self.assertEqual(file1.size_bytes, 1163)
|
||||||
self.assertEqual(file2.size_bytes, 1693)
|
self.assertEqual(file2.size_bytes, 154)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
file1.source.url.startswith(
|
file1.source.url.startswith(
|
||||||
'/media/images/64/643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
'/media/images/64/643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
||||||
@ -151,10 +151,10 @@ class UpdateTest(TestCase):
|
|||||||
self.assertTrue(file1.source.url.endswith('.png'))
|
self.assertTrue(file1.source.url.endswith('.png'))
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
file2.source.url.startswith(
|
file2.source.url.startswith(
|
||||||
'/media/images/f8/f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3',
|
'/media/images/21/213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertTrue(file2.source.url.endswith('.png'))
|
self.assertTrue(file2.source.url.endswith('.webp'))
|
||||||
for f in (file1, file2):
|
for f in (file1, file2):
|
||||||
self.assertEqual(f.user_id, user.pk)
|
self.assertEqual(f.user_id, user.pk)
|
||||||
|
|
||||||
@ -203,3 +203,80 @@ class UpdateTest(TestCase):
|
|||||||
response.context['add_preview_formset'].forms[0].errors,
|
response.context['add_preview_formset'].forms[0].errors,
|
||||||
{'source': ['File with this Hash already exists.']},
|
{'source': ['File with this Hash already exists.']},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_post_upload_validation_error_unexpected_preview_format_gif(self):
|
||||||
|
extension = create_approved_version().extension
|
||||||
|
|
||||||
|
data = {
|
||||||
|
**POST_DATA,
|
||||||
|
'form-TOTAL_FORMS': ['2'],
|
||||||
|
}
|
||||||
|
file_name1 = 'test_preview_image_0001.gif'
|
||||||
|
file_name2 = 'test_preview_image_0001.png'
|
||||||
|
url = extension.get_manage_url()
|
||||||
|
user = extension.authors.first()
|
||||||
|
self.client.force_login(user)
|
||||||
|
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
|
||||||
|
TEST_FILES_DIR / file_name2, 'rb'
|
||||||
|
) as fp2:
|
||||||
|
files = {
|
||||||
|
'form-0-source': fp1,
|
||||||
|
'form-1-source': fp2,
|
||||||
|
}
|
||||||
|
response = self.client.post(url, {**data, **files})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.context['add_preview_formset'].forms[0].errors,
|
||||||
|
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_upload_validation_error_unexpected_preview_format_tif(self):
|
||||||
|
extension = create_approved_version().extension
|
||||||
|
|
||||||
|
data = {
|
||||||
|
**POST_DATA,
|
||||||
|
'form-TOTAL_FORMS': ['2'],
|
||||||
|
}
|
||||||
|
file_name1 = 'test_preview_image_0001.png'
|
||||||
|
file_name2 = 'test_preview_image_0001.tif'
|
||||||
|
url = extension.get_manage_url()
|
||||||
|
user = extension.authors.first()
|
||||||
|
self.client.force_login(user)
|
||||||
|
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
|
||||||
|
TEST_FILES_DIR / file_name2, 'rb'
|
||||||
|
) as fp2:
|
||||||
|
files = {
|
||||||
|
'form-0-source': fp1,
|
||||||
|
'form-1-source': fp2,
|
||||||
|
}
|
||||||
|
response = self.client.post(url, {**data, **files})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.context['add_preview_formset'].forms[1].errors,
|
||||||
|
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_upload_validation_error_unexpected_preview_format_renamed_gif(self):
|
||||||
|
extension = create_approved_version().extension
|
||||||
|
|
||||||
|
data = {
|
||||||
|
**POST_DATA,
|
||||||
|
'form-TOTAL_FORMS': ['1'],
|
||||||
|
}
|
||||||
|
file_name1 = 'test_preview_image_renamed_gif.png'
|
||||||
|
url = extension.get_manage_url()
|
||||||
|
user = extension.authors.first()
|
||||||
|
self.client.force_login(user)
|
||||||
|
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1:
|
||||||
|
files = {
|
||||||
|
'form-0-source': fp1,
|
||||||
|
}
|
||||||
|
response = self.client.post(url, {**data, **files})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.context['add_preview_formset'].forms[0].errors,
|
||||||
|
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
|
||||||
|
)
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin
|
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin
|
||||||
from files.utils import get_sha256
|
from files.utils import get_sha256, guess_mimetype_from_ext
|
||||||
from constants.base import (
|
from constants.base import (
|
||||||
FILE_STATUS_CHOICES,
|
FILE_STATUS_CHOICES,
|
||||||
FILE_TYPE_CHOICES,
|
FILE_TYPE_CHOICES,
|
||||||
@ -137,8 +136,7 @@ class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mode
|
|||||||
self.original_name = self.source.name
|
self.original_name = self.source.name
|
||||||
|
|
||||||
if not self.content_type:
|
if not self.content_type:
|
||||||
content_type, _ = mimetypes.guess_type(self.original_name)
|
self.content_type = guess_mimetype_from_ext(self.original_name)
|
||||||
self.content_type = content_type
|
|
||||||
if self.content_type:
|
if self.content_type:
|
||||||
if 'image' in self.content_type:
|
if 'image' in self.content_type:
|
||||||
self.type = self.TYPES.IMAGE
|
self.type = self.TYPES.IMAGE
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import zipfile
|
import mimetypes
|
||||||
|
import os
|
||||||
import toml
|
import toml
|
||||||
|
import zipfile
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
import magic
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
MODULE_DIR = Path(__file__).resolve().parent
|
MODULE_DIR = Path(__file__).resolve().parent
|
||||||
@ -81,3 +83,17 @@ def read_manifest_from_zip(archive_path):
|
|||||||
logger.error(f"Error extracting from archive: {e}")
|
logger.error(f"Error extracting from archive: {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def guess_mimetype_from_ext(file_name: str) -> str:
|
||||||
|
"""Guess MIME-type from the extension of the given file name."""
|
||||||
|
mimetype_from_ext, _ = mimetypes.guess_type(file_name)
|
||||||
|
return mimetype_from_ext
|
||||||
|
|
||||||
|
|
||||||
|
def guess_mimetype_from_content(file_obj) -> str:
|
||||||
|
"""Guess MIME-type based on a portion of the given file's bytes."""
|
||||||
|
mimetype_from_bytes = magic.from_buffer(file_obj.read(2048), mime=True)
|
||||||
|
# This file might be read again by validation or other utilities
|
||||||
|
file_obj.seek(0)
|
||||||
|
return mimetype_from_bytes
|
||||||
|
@ -38,6 +38,7 @@ mistune==2.0.4
|
|||||||
multidict==6.0.2
|
multidict==6.0.2
|
||||||
oauthlib==3.2.0
|
oauthlib==3.2.0
|
||||||
Pillow==9.2.0
|
Pillow==9.2.0
|
||||||
|
python-magic==0.4.27
|
||||||
requests==2.28.1
|
requests==2.28.1
|
||||||
requests-oauthlib==1.3.1
|
requests-oauthlib==1.3.1
|
||||||
semantic-version==2.10.0
|
semantic-version==2.10.0
|
||||||
|
30
setup.cfg
30
setup.cfg
@ -1,17 +1,27 @@
|
|||||||
[flake8]
|
[flake8]
|
||||||
ignore=
|
ignore=
|
||||||
D100, #: Missing docstring in public module
|
#: Missing docstring in public module
|
||||||
D101, #: Missing docstring in public class
|
D100,
|
||||||
D102, #: Missing docstring in public method
|
#: Missing docstring in public class
|
||||||
|
D101,
|
||||||
|
#: Missing docstring in public method
|
||||||
|
D102,
|
||||||
D103,
|
D103,
|
||||||
D107, #: Missing docstring in __init__
|
#: Missing docstring in __init__
|
||||||
|
D107,
|
||||||
D205,
|
D205,
|
||||||
D105, #: Missing docstring in magic method
|
#: Missing docstring in magic method
|
||||||
D203, #: 1 blank line required before class docstring
|
D105,
|
||||||
D213, #: We adhere to D212 instead
|
#: 1 blank line required before class docstring
|
||||||
D4, #: All section formatting, which seems to be impossible to comply
|
D203,
|
||||||
W503, #: We adhere to W504 instead: line break should be before binary operator
|
#: We adhere to D212 instead
|
||||||
D106, #: No docstring in public nested class (e.g. Meta)
|
D213,
|
||||||
|
#: All section formatting, which seems to be impossible to comply
|
||||||
|
D4,
|
||||||
|
#: We adhere to W504 instead: line break should be before binary operator
|
||||||
|
W503,
|
||||||
|
#: No docstring in public nested class (e.g. Meta)
|
||||||
|
D106,
|
||||||
extend-ignore = E203
|
extend-ignore = E203
|
||||||
max-line-length = 100
|
max-line-length = 100
|
||||||
per-file-ignores =
|
per-file-ignores =
|
||||||
|
Loading…
Reference in New Issue
Block a user