oxylus-utils 0.0.1__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ox_utils/__init__.py +0 -0
- ox_utils/django/__init__.py +3 -0
- ox_utils/django/conf.py +111 -0
- ox_utils/django/files/__init__.py +21 -0
- ox_utils/django/files/conf.py +71 -0
- ox_utils/django/files/exceptions.py +7 -0
- ox_utils/django/files/models/__init__.py +4 -0
- ox_utils/django/files/models/file.py +262 -0
- ox_utils/django/files/models/folder.py +116 -0
- ox_utils/django/files/processors/__init__.py +12 -0
- ox_utils/django/files/processors/image_processor.py +56 -0
- ox_utils/django/files/processors/libreoffice_processor.py +58 -0
- ox_utils/django/files/processors/pdf_processor.py +57 -0
- ox_utils/django/files/processors/processor.py +58 -0
- ox_utils/django/files/processors/registry.py +63 -0
- ox_utils/django/functional.py +41 -0
- ox_utils/django/jwt.py +126 -0
- ox_utils/django/models/__init__.py +65 -0
- ox_utils/django/models/cls.py +108 -0
- ox_utils/django/models/color.py +41 -0
- ox_utils/django/models/common.py +121 -0
- ox_utils/django/models/drf.py +57 -0
- ox_utils/django/models/owned.py +78 -0
- ox_utils/django/models/save_hook.py +76 -0
- ox_utils/django/models/tree.py +253 -0
- ox_utils/django/pydantic.py +45 -0
- ox_utils/django/serializers.py +233 -0
- ox_utils/django/tests.py +32 -0
- oxylus_utils-0.0.1.dist-info/METADATA +57 -0
- oxylus_utils-0.0.1.dist-info/RECORD +32 -0
- oxylus_utils-0.0.1.dist-info/WHEEL +5 -0
- oxylus_utils-0.0.1.dist-info/licenses/LICENSE +373 -0
ox_utils/__init__.py
ADDED
|
File without changes
|
ox_utils/django/conf.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from functools import cached_property
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings:
|
|
10
|
+
"""
|
|
11
|
+
This class provide settings configuration with default values.
|
|
12
|
+
|
|
13
|
+
Configuration keys are declared and document under subclass of this
|
|
14
|
+
one as UPPERCASED attributes. This allows to document them.
|
|
15
|
+
|
|
16
|
+
User can still import the class in ``settings.py`` in order to
|
|
17
|
+
reuse already existing values.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
Example ``myapp.conf``
|
|
21
|
+
|
|
22
|
+
.. code-block:: python
|
|
23
|
+
|
|
24
|
+
from ox_utils.django import conf
|
|
25
|
+
|
|
26
|
+
__all__ = ("MyAppSettings", "app_settings")
|
|
27
|
+
|
|
28
|
+
class MyAppSettings(conf.Settings):
|
|
29
|
+
ALLOWED_TAGS = ["a", "b", "br"]
|
|
30
|
+
\""" Tags allowed for this application. \"""
|
|
31
|
+
SOME_VALUE = 123
|
|
32
|
+
\""" Other documented setting. \"""
|
|
33
|
+
|
|
34
|
+
# this is the actual instance used to retrieve settings
|
|
35
|
+
app_settings = MyAppSettings("MYAPP")
|
|
36
|
+
|
|
37
|
+
In project ``settings.py``:
|
|
38
|
+
|
|
39
|
+
.. code-block:: python
|
|
40
|
+
|
|
41
|
+
from myapp.settings import MyAppSettings
|
|
42
|
+
|
|
43
|
+
MYAPP = {
|
|
44
|
+
"ALLOWED_TAGS": [*MyAppSettings.ALLOWED_TAGS, "p"],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
|
|
50
|
+
.. code-block:: python
|
|
51
|
+
|
|
52
|
+
from myapp.conf import app_settings
|
|
53
|
+
|
|
54
|
+
# Just print it out...
|
|
55
|
+
print(app_settings.ALLOWED_TAGS, app_settings.SOME_VALUE)
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, key, source=settings):
|
|
60
|
+
self._overrides = getattr(source, key, None)
|
|
61
|
+
|
|
62
|
+
def __getattribute__(self, name):
|
|
63
|
+
if name[0] != "_" and name.isupper():
|
|
64
|
+
overrides = object.__getattribute__(self, "_overrides")
|
|
65
|
+
if overrides and name in overrides:
|
|
66
|
+
return overrides[name]
|
|
67
|
+
return object.__getattribute__(self, name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SettingsEditor:
|
|
71
|
+
"""
|
|
72
|
+
Helper class to work with dynaconf settings files, one per setting file.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
""" Settings name """
|
|
77
|
+
dir: Path
|
|
78
|
+
""" Path to settings directory. """
|
|
79
|
+
|
|
80
|
+
def __init__(self, name, dir):
|
|
81
|
+
self.name = name
|
|
82
|
+
self.dir = Path(dir)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_app(cls, app, *args, **kwargs):
|
|
86
|
+
"""Return SettingsEditor for the provided file."""
|
|
87
|
+
name = f"{app.label}.yaml"
|
|
88
|
+
return cls(name, *args, **kwargs)
|
|
89
|
+
|
|
90
|
+
@cached_property
|
|
91
|
+
def path(self):
|
|
92
|
+
""" """
|
|
93
|
+
return self.dir / self.name
|
|
94
|
+
|
|
95
|
+
def _ensure(self):
|
|
96
|
+
"""Ensure directory exists."""
|
|
97
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
def read(self) -> dict[str, Any] | None:
|
|
100
|
+
"""Read data from settings."""
|
|
101
|
+
if not self.path.exists():
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
with open(self.path, "r") as stream:
|
|
105
|
+
return yaml.load(stream, Loader=yaml.Loader)
|
|
106
|
+
|
|
107
|
+
def write(self, data):
|
|
108
|
+
"""Write data to settings file."""
|
|
109
|
+
self._ensure()
|
|
110
|
+
with open(self.path, "w") as stream:
|
|
111
|
+
yaml.dump(data, stream)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides files utilities for Django.
|
|
3
|
+
|
|
4
|
+
It provides:
|
|
5
|
+
|
|
6
|
+
- Files mime-type detection based on file content.
|
|
7
|
+
- Reusable File and Folder abstract models and configuration.
|
|
8
|
+
|
|
9
|
+
Required dependencies (with dependency group: ``files``):
|
|
10
|
+
|
|
11
|
+
- PIL: used for ImageProcessor & PDFProcessor
|
|
12
|
+
- libreoffice: LibreOfficeProcessor
|
|
13
|
+
- pymupdf: used PDFProcessor
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
from . import processors, models
|
|
17
|
+
|
|
18
|
+
__all__ = (
|
|
19
|
+
"processors", "models"
|
|
20
|
+
)
|
|
21
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from .. import conf
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = ("FileSettings", "files_settings", "resolve")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileSettings(conf.Settings):
|
|
12
|
+
"""
|
|
13
|
+
Setting class used by the files module.
|
|
14
|
+
"""
|
|
15
|
+
MEDIA_DIR: Path = settings.MEDIA_ROOT / "ox_files"
|
|
16
|
+
""" MEDIA directory """
|
|
17
|
+
UPLOAD_DIR: str = "ox_files/uploads"
|
|
18
|
+
""" Subdirectory in media dir where to upload files. """
|
|
19
|
+
PREVIEW_DIR: str = "ox_files/previews"
|
|
20
|
+
""" Directory in media dir which to save previews. """
|
|
21
|
+
|
|
22
|
+
FILE_SIZE_LIMIT = 15 * 1024 * 1024
|
|
23
|
+
""" Set maximum file size. """
|
|
24
|
+
|
|
25
|
+
MAGIC_BUFFER: int = 2048
|
|
26
|
+
""" Buffer size used by Python-Magic to read mime types. """
|
|
27
|
+
THUMBNAIL_SIZE: tuple[int, int] = (600, 800)
|
|
28
|
+
""" Size for thumbnails """
|
|
29
|
+
|
|
30
|
+
PROCESSORS: list[str] = [
|
|
31
|
+
"ox_utils.django.files.processors.ImageProcessor",
|
|
32
|
+
"ox_utils.django.files.processors.PDFProcessor",
|
|
33
|
+
"ox_utils.django.files.processors.LibreOfficeProcessor",
|
|
34
|
+
]
|
|
35
|
+
""" List of file processors. """
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def upload_dir(self) -> Path:
|
|
39
|
+
"""Return full path to upload dir."""
|
|
40
|
+
# We enforce media to be protected
|
|
41
|
+
return self.MEDIA_DIR / self.UPLOAD_DIR
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def preview_dir(self) -> Path:
|
|
45
|
+
"""Return full path to preview dir."""
|
|
46
|
+
# We enforce media to be protected
|
|
47
|
+
return self.MEDIA_DIR / self.PREVIEW_DIR
|
|
48
|
+
|
|
49
|
+
def resolve(self, path: str, relative: bool = False):
|
|
50
|
+
"""Resolve path under file upload directory.
|
|
51
|
+
|
|
52
|
+
:param path: path to resolve.
|
|
53
|
+
:param relative: if True, return path relative to MEDIA_ROOT.
|
|
54
|
+
"""
|
|
55
|
+
if path.startswith("/"):
|
|
56
|
+
path = path[1:]
|
|
57
|
+
|
|
58
|
+
path = self.upload_dir / path
|
|
59
|
+
if relative:
|
|
60
|
+
return path.relative_to(settings.MEDIA_ROOT)
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
files_settings: FileSettings = FileSettings("OX_FILES")
|
|
65
|
+
"""
|
|
66
|
+
Settings used by file models by default, using key ``OX_FILES``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
resolve = files_settings.resolve
|
|
70
|
+
""" Shortcut to ``files_settings`` 's :py:meth:`Settings.resolve`. """
|
|
71
|
+
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from typing import Type
|
|
8
|
+
|
|
9
|
+
from django.db import models, transaction
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.core.exceptions import ValidationError, PermissionDenied, FieldDoesNotExist
|
|
12
|
+
from django.utils.translation import gettext_lazy as _
|
|
13
|
+
from django.urls import reverse
|
|
14
|
+
|
|
15
|
+
from ox_utils.django.models import Described, Timestamped, UUID
|
|
16
|
+
|
|
17
|
+
from ..conf import files_settings, FileSettings
|
|
18
|
+
from .. import processors
|
|
19
|
+
from .folder import BaseFolder, validate_name
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ("FileQuerySet", "BaseFile", "file_upload_to")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FileQuerySet(models.QuerySet):
|
|
26
|
+
def delete(self, clear_files: bool | None = None):
|
|
27
|
+
if clear_files is None:
|
|
28
|
+
clear_files = self.model._settings.CLEAR_FILES_ON_DELETE
|
|
29
|
+
|
|
30
|
+
if clear_files:
|
|
31
|
+
self.clear_files(update=False)
|
|
32
|
+
return super().delete()
|
|
33
|
+
|
|
34
|
+
def clear_files(self, update: bool = True):
|
|
35
|
+
"""
|
|
36
|
+
Delete files from filesystem and optionally update fields.
|
|
37
|
+
|
|
38
|
+
:param update: if True (default), update queryset fields.
|
|
39
|
+
"""
|
|
40
|
+
for file, preview in self.values_list("file", "preview"):
|
|
41
|
+
(settings.MEDIA_ROOT / file).unlink(missing_ok=True)
|
|
42
|
+
preview and (settings.MEDIA_ROOT / preview).unlink(missing_ok=True)
|
|
43
|
+
|
|
44
|
+
if update:
|
|
45
|
+
self.update(file=None, preview=None)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def file_upload_to(instance, filename) -> str:
|
|
49
|
+
"""Return target upload file depending on whether the folder is synchronized or not."""
|
|
50
|
+
if instance.folder_model() and instance.folder:
|
|
51
|
+
path = f"{instance.folder.path}/{filename}"
|
|
52
|
+
else:
|
|
53
|
+
path = f"/{filename}"
|
|
54
|
+
return instance._settings.resolve(path, relative=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BaseFile(Described, Timestamped, UUID):
|
|
58
|
+
"""
|
|
59
|
+
This class represent a file.
|
|
60
|
+
|
|
61
|
+
Files are looked up for a matching :py:class:`~.processors.FileProcessor` (reading mime-type). A processors
|
|
62
|
+
handles different tasks such as creating preview (thumbnails) or getting metadata. In order to do so, it uses
|
|
63
|
+
:py:data:`~.processors.registry` that provides helpers to read mime types and register file processors.
|
|
64
|
+
|
|
65
|
+
Folders
|
|
66
|
+
-------
|
|
67
|
+
|
|
68
|
+
A file can be nested under a :py:attr:`parent` folder. If ``None`` is provided, then it will be
|
|
69
|
+
at the root of the filesystem.
|
|
70
|
+
|
|
71
|
+
If you want to add folder, you must add the ``folder`` foreign key:
|
|
72
|
+
|
|
73
|
+
- Reverse name as ``files``.
|
|
74
|
+
- One Folder concrete subclass per File subclass.
|
|
75
|
+
|
|
76
|
+
Settings
|
|
77
|
+
--------
|
|
78
|
+
|
|
79
|
+
The model uses by default the :py:attr:`~ox_utils.django.files.conf.files_settings`. However
|
|
80
|
+
you might want to integrate your own FileSettings instance by assigning :py:attr:`_settings`.
|
|
81
|
+
|
|
82
|
+
At deletion, related files and previews are deleted based on ``.conf.files_settings`` (``CLEAR_FILES_ON_DELETE=True`` option).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
_settings = files_settings
|
|
86
|
+
""" Specify settings to use for files. """
|
|
87
|
+
|
|
88
|
+
# When folder is null, it is at root
|
|
89
|
+
# folder = models.ForeignKey(Folder, models.CASCADE, related_name="files", verbose_name=_("Folder"))
|
|
90
|
+
|
|
91
|
+
name = models.CharField(_("Name"), max_length=128, validators=[validate_name])
|
|
92
|
+
file = models.FileField(_("File"), upload_to=file_upload_to, null=True)
|
|
93
|
+
preview = models.FileField(_("Preview"), null=True, blank=True)
|
|
94
|
+
mime_type = models.CharField(_("Mime Type"), max_length=127, blank=True)
|
|
95
|
+
file_size = models.PositiveIntegerField(_("File Size"), blank=True, default=0)
|
|
96
|
+
preview_size = models.PositiveIntegerField(_("Preview Size"), blank=True, default=0)
|
|
97
|
+
|
|
98
|
+
caption = models.TextField(
|
|
99
|
+
_("Caption"),
|
|
100
|
+
default="",
|
|
101
|
+
help_text=_("Displayed below object when rendered in page or documents."),
|
|
102
|
+
blank=True,
|
|
103
|
+
null=True,
|
|
104
|
+
)
|
|
105
|
+
alternate = models.TextField(
|
|
106
|
+
_("Alternate text"),
|
|
107
|
+
default="",
|
|
108
|
+
help_text=_("Displayed as replacement text when object is not displayed."),
|
|
109
|
+
blank=True,
|
|
110
|
+
null=True,
|
|
111
|
+
)
|
|
112
|
+
ariaDescription = models.TextField(
|
|
113
|
+
_("ARIA Description"),
|
|
114
|
+
default="",
|
|
115
|
+
blank=True,
|
|
116
|
+
null=True,
|
|
117
|
+
help_text=_("Provide a description of the content for visually impaired people."),
|
|
118
|
+
)
|
|
119
|
+
metadata = models.JSONField(_("Metadata"), default=dict, blank=True)
|
|
120
|
+
|
|
121
|
+
objects = FileQuerySet.as_manager()
|
|
122
|
+
|
|
123
|
+
class Meta:
|
|
124
|
+
abstract = True
|
|
125
|
+
verbose_name = _("File")
|
|
126
|
+
verbose_name_plural = _("Files")
|
|
127
|
+
# FIXME: add unique constraint
|
|
128
|
+
|
|
129
|
+
@cached_property
|
|
130
|
+
def file_url(self):
|
|
131
|
+
"""Return file url."""
|
|
132
|
+
uuid = self.access and self.access.uuid or self.uuid
|
|
133
|
+
return reverse("serve-file", kwargs={"uuid": uuid})
|
|
134
|
+
|
|
135
|
+
@cached_property
|
|
136
|
+
def preview_url(self):
|
|
137
|
+
"""Return file preview url."""
|
|
138
|
+
if not self.preview:
|
|
139
|
+
return None
|
|
140
|
+
uuid = self.access and self.access.uuid or self.uuid
|
|
141
|
+
return reverse("serve-file-preview", kwargs={"uuid": uuid})
|
|
142
|
+
|
|
143
|
+
def __init__(self, *args, **kwargs):
|
|
144
|
+
super().__init__(*args, **kwargs)
|
|
145
|
+
self._initial_path = self.file and self.file.path
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def folder_model(cls) -> Type[BaseFolder]:
|
|
149
|
+
"""
|
|
150
|
+
Return folder class used by this model.
|
|
151
|
+
|
|
152
|
+
It assumes that either it is a foreignkey to "folder" or not declared.
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
return cls.get_field("folder").target.remote_field.model
|
|
156
|
+
except FieldDoesNotExist:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def abs_path(self):
|
|
160
|
+
return self.folder.abs_path() / self.name
|
|
161
|
+
|
|
162
|
+
def on_save(self, fields=None):
|
|
163
|
+
"""Ensure mime type and file validation."""
|
|
164
|
+
super().on_save(fields)
|
|
165
|
+
self.validate_node()
|
|
166
|
+
|
|
167
|
+
def validate_node(self):
|
|
168
|
+
"""
|
|
169
|
+
Ensure file name is unique within folder.
|
|
170
|
+
|
|
171
|
+
:yield ValidationError: if file name is already present in folder (file or folder).
|
|
172
|
+
"""
|
|
173
|
+
if Folder := self.folder_model():
|
|
174
|
+
# TODO: handle file move
|
|
175
|
+
kw = self._get_validation_lookup()
|
|
176
|
+
|
|
177
|
+
query = type(self).objects.filter(folder_id=self.folder_id, **kw)
|
|
178
|
+
if self.pk:
|
|
179
|
+
query = query.exclude(pk=self.pk)
|
|
180
|
+
|
|
181
|
+
if query.exists():
|
|
182
|
+
raise ValidationError({"name": "Another file exists for this path."})
|
|
183
|
+
|
|
184
|
+
if Folder.objects.filter(parent_id=self.folder_id, **kw):
|
|
185
|
+
raise ValidationError({"name": "A folder exists for this path."})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _get_validation_lookup(self):
|
|
189
|
+
return {"name": self.name}
|
|
190
|
+
|
|
191
|
+
def read_mime_type(self, save: bool = True) -> str:
|
|
192
|
+
"""Read mime-type from file and update corresponding field.
|
|
193
|
+
|
|
194
|
+
:param save: save object instance
|
|
195
|
+
:return the mime type
|
|
196
|
+
"""
|
|
197
|
+
self.file_size = Path(self.file.path).stat().st_size
|
|
198
|
+
self.mime_type = processors.registry.read_mime_type(self.file.path)
|
|
199
|
+
save and self.save()
|
|
200
|
+
return self.mime_type
|
|
201
|
+
|
|
202
|
+
def get_processor(
|
|
203
|
+
self, save: bool = True, registry: processors.FileProcessors = processors.registry
|
|
204
|
+
) -> processors.Processor:
|
|
205
|
+
"""Return processor to use for this file type.
|
|
206
|
+
|
|
207
|
+
Read mime-type from file if not already set.
|
|
208
|
+
|
|
209
|
+
:param save: save model if mime type is updated (default is True)
|
|
210
|
+
:param registry: use this processors instead of :py:data:`.processors.processors`
|
|
211
|
+
"""
|
|
212
|
+
if not self.mime_type:
|
|
213
|
+
self.read_mime_type(save)
|
|
214
|
+
return registry.get(self.mime_type)
|
|
215
|
+
|
|
216
|
+
def create_preview(self, force: bool = True) -> tuple[bool,Path]:
|
|
217
|
+
"""
|
|
218
|
+
Generate and assign a preview to this file instance.
|
|
219
|
+
|
|
220
|
+
:param force: if True (default), create preview even if already present.
|
|
221
|
+
:return a tuple of ``created, preview_path``.
|
|
222
|
+
"""
|
|
223
|
+
if self.preview:
|
|
224
|
+
if not force:
|
|
225
|
+
return (False, self.preview.path)
|
|
226
|
+
self.preview.delete()
|
|
227
|
+
self.preview = None
|
|
228
|
+
self.save(update_fields=["preview"])
|
|
229
|
+
|
|
230
|
+
with transaction.atomic():
|
|
231
|
+
size = self._settings.THUMBNAIL_SIZE
|
|
232
|
+
path = self._settings.preview_dir / f"{self.uuid}-{size}.jpg"
|
|
233
|
+
|
|
234
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
created = processor.create_preview(Path(self.file.path), path, size, force=True)
|
|
236
|
+
|
|
237
|
+
if created:
|
|
238
|
+
self.preview.name = str(path.relative_to(settings.MEDIA_ROOT))
|
|
239
|
+
self.save()
|
|
240
|
+
return (created, path)
|
|
241
|
+
|
|
242
|
+
def delete(self, *args, clear_files: bool | None = None, **kwargs):
|
|
243
|
+
"""
|
|
244
|
+
Ensure file deletion.
|
|
245
|
+
|
|
246
|
+
:param *args: forward to super's ``delete()``
|
|
247
|
+
:param clear_files: if True or False, overrides default settings.
|
|
248
|
+
:param **kwargs: forward to super's ``delete()``
|
|
249
|
+
"""
|
|
250
|
+
if clear_files is None:
|
|
251
|
+
clear_files = self._settings.CLEAR_FILES_ON_DELETE
|
|
252
|
+
|
|
253
|
+
if clear_files:
|
|
254
|
+
if self.preview:
|
|
255
|
+
self.preview.delete(False)
|
|
256
|
+
if self.file:
|
|
257
|
+
self.file.delete(False)
|
|
258
|
+
|
|
259
|
+
self.preview = None
|
|
260
|
+
self.file = None
|
|
261
|
+
return super().delete(*args, **kwargs)
|
|
262
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import shutil
|
|
5
|
+
|
|
6
|
+
from django.db import models
|
|
7
|
+
from django.core.exceptions import ValidationError
|
|
8
|
+
from django.utils.translation import gettext_lazy as _
|
|
9
|
+
|
|
10
|
+
from ox_utils.django.models import Described, Timestamped
|
|
11
|
+
from ox_utils.django.models.tree import TreeNode, TreeNodeQuerySet
|
|
12
|
+
from ..conf import FileSettings, files_settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = (
|
|
16
|
+
"FolderQuerySet",
|
|
17
|
+
"BaseFolder",
|
|
18
|
+
"validate_name",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_name(value):
|
|
23
|
+
"""Validate folder or file name."""
|
|
24
|
+
if "/" in value:
|
|
25
|
+
raise ValidationError(_("The character `/` is forbidden in name."))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FolderQuerySet(TreeNodeQuerySet):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BaseFolder(Described, Timestamped, TreeNode):
|
|
33
|
+
"""
|
|
34
|
+
Represent a folder in which files are stored.
|
|
35
|
+
|
|
36
|
+
.. important::
|
|
37
|
+
|
|
38
|
+
Updating :py:attr:`parent`, :py:attr:`name` and :py:attr:`path` should not be done manually. Instead use
|
|
39
|
+
:py:meth:`rename` and :py:meth:`move_to` methods to ensure that these values are correctly set.
|
|
40
|
+
|
|
41
|
+
When thoses values raise a ValidationError, user should assume that new values of the model are invalid.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
name = models.CharField(_("Name"), max_length=64, validators=[validate_name])
|
|
46
|
+
|
|
47
|
+
objects = FolderQuerySet.as_manager()
|
|
48
|
+
|
|
49
|
+
class Meta:
|
|
50
|
+
abstract = True
|
|
51
|
+
verbose_name = _("Folder")
|
|
52
|
+
verbose_name_plural = _("Folders")
|
|
53
|
+
constraints = [models.UniqueConstraint("parent", "name", name="unique_folder_name")]
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def file_model(cls):
|
|
57
|
+
""" Return the file model related to this folder (assuming the reverse name is ``files``). """
|
|
58
|
+
return cls.get_field("files").related_model
|
|
59
|
+
|
|
60
|
+
def abs_path(self) -> Path:
|
|
61
|
+
"""File system path of the folder."""
|
|
62
|
+
return self.file_model()._settings.resolve(self.path)
|
|
63
|
+
|
|
64
|
+
def rename(self, name: str, save: bool = True):
|
|
65
|
+
"""Rename folder."""
|
|
66
|
+
if name != self.name:
|
|
67
|
+
self.name = name
|
|
68
|
+
if save:
|
|
69
|
+
self.save()
|
|
70
|
+
else:
|
|
71
|
+
self.on_save()
|
|
72
|
+
|
|
73
|
+
def move_to(self, parent: BaseFolder | None = None, name: str | None = None, save: bool = True):
|
|
74
|
+
"""Move folder into provided parent folder or root.
|
|
75
|
+
|
|
76
|
+
:param parent: parent folder
|
|
77
|
+
:param name: if provided rename folder
|
|
78
|
+
:param save: save node
|
|
79
|
+
"""
|
|
80
|
+
if (name and name != self.name) or parent.id != self.parent_id:
|
|
81
|
+
if name:
|
|
82
|
+
self.name = name
|
|
83
|
+
super().move_to(parent, save)
|
|
84
|
+
|
|
85
|
+
def validate_node(self):
|
|
86
|
+
"""
|
|
87
|
+
Validate node for name collision (folder & file).
|
|
88
|
+
|
|
89
|
+
:yield ValidationError: a file or folder already exists with this name in parent.
|
|
90
|
+
"""
|
|
91
|
+
super().validate_node()
|
|
92
|
+
|
|
93
|
+
if self.file_model().objects.filter(folder=self.parent, name=self.name):
|
|
94
|
+
raise ValidationError({"name": f"A file `{self.name}` already exists in {self.parent.name}."})
|
|
95
|
+
|
|
96
|
+
def sync_node(self, source_path: str | None = None, target_path: str | None = None):
|
|
97
|
+
"""Synchronize folder with filesystem (move or create it)."""
|
|
98
|
+
source_p = source_path and self.file_model()._settings.resolve(source_path)
|
|
99
|
+
target_p = self.file_model()._settings.resolve(target_path)
|
|
100
|
+
|
|
101
|
+
if target_p.exists():
|
|
102
|
+
raise ValueError(f"A file or directory already exists at `{target_p}`.")
|
|
103
|
+
|
|
104
|
+
if source_p and source_p.exists():
|
|
105
|
+
shutil.move(source_p, target_p)
|
|
106
|
+
else:
|
|
107
|
+
target_p.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
super().sync_node(source_path, target_path)
|
|
110
|
+
|
|
111
|
+
def delete(self):
|
|
112
|
+
path = self.file_model()._settings.resolve(self.path, self.owner.uuid)
|
|
113
|
+
if path.exists():
|
|
114
|
+
path.rmdir()
|
|
115
|
+
super().delete()
|
|
116
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .processor import Processor
|
|
2
|
+
from .image_processor import ImageProcessor
|
|
3
|
+
from .pdf_processor import PDFProcessor
|
|
4
|
+
from .libreoffice_processor import LibreOfficeProcessor
|
|
5
|
+
from .registry import Registry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
registry = Registry(Processor())
|
|
9
|
+
""" Default registry of file processors. """
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = ("Processor", "ImageProcessor", "PDFProcessor", "LibreOfficeProcessor", "Registry", "registry")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from django.utils.translation import gettext_lazy as _
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from ..exceptions import FileTypeError
|
|
8
|
+
from .processor import Processor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ("ImageProcessor",)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ImageProcessor(Processor):
|
|
15
|
+
mime_types = {
|
|
16
|
+
"image/jpeg",
|
|
17
|
+
"image/png",
|
|
18
|
+
"image/gif",
|
|
19
|
+
"image/bmp",
|
|
20
|
+
"image/webp",
|
|
21
|
+
"image/tiff",
|
|
22
|
+
"image/x-ms-bmp",
|
|
23
|
+
"image/x-pcx",
|
|
24
|
+
"image/x-tga",
|
|
25
|
+
"image/x-xbitmap",
|
|
26
|
+
"image/x-xpixmap",
|
|
27
|
+
"image/x-portable-pixmap",
|
|
28
|
+
"image/x-portable-bitmap",
|
|
29
|
+
"image/x-portable-graymap",
|
|
30
|
+
"image/x-portable-anymap",
|
|
31
|
+
"image/x-rgb",
|
|
32
|
+
"image/x-icns",
|
|
33
|
+
"image/vnd.microsoft.icon",
|
|
34
|
+
"image/avif",
|
|
35
|
+
"image/heif",
|
|
36
|
+
"image/x-exr",
|
|
37
|
+
}
|
|
38
|
+
type = "image"
|
|
39
|
+
display_type = _("Image")
|
|
40
|
+
|
|
41
|
+
def _create_preview(self, path: Path, out: Path, size: tuple[int, int]) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Create thumbnail for the input image.
|
|
44
|
+
:yield: :py:class:`.exceptions.FileTypeError` when file type is not supported.
|
|
45
|
+
:yield: ``FileNotFound`` if file cannot be found
|
|
46
|
+
"""
|
|
47
|
+
import PIL
|
|
48
|
+
from PIL import Image
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
with Image.open(path) as im:
|
|
52
|
+
im.thumbnail(size)
|
|
53
|
+
im.save(out)
|
|
54
|
+
return True
|
|
55
|
+
except (PIL.UnidentifiedImageError, ValueError, TypeError):
|
|
56
|
+
raise FileTypeError(f"File format invalid for {path}.")
|