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 ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ """
2
+ This module provide utilities and tools to work with Django.
3
+ """
@@ -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,7 @@
1
+ __all__ = ("FileTypeError",)
2
+
3
+
4
+ class FileTypeError(ValueError):
5
+ """Invalid file format."""
6
+
7
+ pass
@@ -0,0 +1,4 @@
1
+ from .folder import BaseFolder, FolderQuerySet
2
+ from .file import BaseFile, FileQuerySet
3
+
4
+ __all__ = ("BaseFolder", "FolderQuerySet", "BaseFile", "FileQuerySet")
@@ -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}.")