├── .github
└── workflows
│ ├── django-tests.yml
│ ├── publish-to-pypi.yml
│ └── publish-to-test-pypi.yml
├── .gitignore
├── CONTRIBUTE.md
├── DjangoExampleApplication
├── __init__.py
├── admin.py
├── apps.py
├── assets
│ ├── COPYRIGHT
│ └── audience-868074_1920.jpg
├── migrations
│ ├── 0001_initial.py
│ ├── 0002_auto_20210313_1049.py
│ ├── 0003_genericattachment.py
│ └── __init__.py
├── models.py
└── tests.py
├── DjangoExampleProject
├── __init__.py
├── asgi.py
├── settings.py
├── urls.py
└── wsgi.py
├── Dockerfile
├── LICENSE
├── MANIFEST.in
├── README.Docker.md
├── README.md
├── django_minio_backend
├── __init__.py
├── apps.py
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ ├── initialize_buckets.py
│ │ └── is_minio_available.py
├── models.py
├── tests.py
└── utils.py
├── docker-compose.develop.yml
├── docker-compose.yml
├── examples
└── policy_hook.example.py
├── manage.py
├── nginx.conf
├── requirements.txt
├── setup.py
└── version.py
/.github/workflows/django-tests.yml:
--------------------------------------------------------------------------------
1 | name: Django Unit Tests
2 |
3 | on:
4 | push:
5 | branches:
6 | - '**'
7 |
8 | jobs:
9 | job-run-django-app-tests:
10 | name: Deploy DjangoExampleProject and run its integrated tests
11 | runs-on: ubuntu-latest
12 | steps:
13 | # Checkout the repository
14 | - uses: actions/checkout@v2
15 | # Start the minIO container
16 | - name: Start the minIO container
17 | run: docker run --name miniotest -p 9000:9000 -d minio/minio server /data
18 | # Setup Python
19 | - name: Set up Python 3.12
20 | uses: actions/setup-python@v1
21 | with:
22 | python-version: 3.12
23 | # Install Dependencies
24 | - name: Install pypa/build
25 | run: >-
26 | python -m
27 | pip install
28 | -r
29 | requirements.txt
30 | # Setup Django
31 | - name: Deploy DjangoExampleProject
32 | run: python manage.py migrate
33 | # Run Django Tests
34 | - name: Run Django unit tests
35 | run: python manage.py test
36 |
--------------------------------------------------------------------------------
/.github/workflows/publish-to-pypi.yml:
--------------------------------------------------------------------------------
1 | name: PyPI Publish
2 |
3 | on:
4 | push:
5 | branches-ignore:
6 | - 'develop'
7 | - 'feature/*'
8 | tags:
9 | - '**'
10 |
11 | jobs:
12 | build-n-publish:
13 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
14 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
15 | runs-on: ubuntu-22.04
16 | steps:
17 | - uses: actions/checkout@master
18 | # Setup Python
19 | - name: Set up Python 3.12
20 | uses: actions/setup-python@v1
21 | with:
22 | python-version: 3.12
23 | # Install Dependencies
24 | - name: Install pypa/build
25 | run: >-
26 | python -m
27 | pip install
28 | build wheel setuptools
29 | --user
30 | # Build Package
31 | - name: Build a binary wheel and a source tarball
32 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
33 | run: >-
34 | python
35 | setup.py
36 | bdist_wheel
37 | sdist
38 | # Publish
39 | - name: Publish distribution 📦 to PyPI
40 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
41 | uses: pypa/gh-action-pypi-publish@release/v1
42 | with:
43 | user: __token__
44 | password: ${{ secrets.PYPI_DJANGO_MINIO_BACKEND }}
45 |
--------------------------------------------------------------------------------
/.github/workflows/publish-to-test-pypi.yml:
--------------------------------------------------------------------------------
1 | name: publish-py-dist-to-test-pypi
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | tags:
8 | - '**'
9 |
10 | jobs:
11 | build-n-publish:
12 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
13 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
14 | runs-on: ubuntu-22.04
15 | steps:
16 | - uses: actions/checkout@master
17 | # Setup Python
18 | - name: Set up Python 3.12
19 | uses: actions/setup-python@v1
20 | with:
21 | python-version: 3.12
22 | # Install Dependencies
23 | - name: Install pypa/build
24 | run: >-
25 | python -m
26 | pip install
27 | build wheel setuptools
28 | --user
29 | # Build Package
30 | - name: Build a binary wheel and a source tarball
31 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
32 | run: >-
33 | python
34 | setup.py
35 | bdist_wheel
36 | sdist
37 | # Publish
38 | - name: Publish distribution 📦 to Test PyPI
39 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
40 | uses: pypa/gh-action-pypi-publish@release/v1
41 | with:
42 | user: __token__
43 | password: ${{ secrets.PYPI_TEST }}
44 | repository_url: https://test.pypi.org/legacy/
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 | MANIFEST
27 | RELEASE-VERSION
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .coverage
43 | .coverage.*
44 | .cache
45 | nosetests.xml
46 | coverage.xml
47 | *.cover
48 | .hypothesis/
49 | .pytest_cache/
50 |
51 | # Translations
52 | *.mo
53 | *.pot
54 |
55 | # Django stuff:
56 | *.log
57 | local_settings.py
58 | db.sqlite3
59 | example_app/db.sqlite3
60 |
61 | # Flask stuff:
62 | instance/
63 | .webassets-cache
64 |
65 | # Scrapy stuff:
66 | .scrapy
67 |
68 | # Sphinx documentation
69 | docs/_build/
70 |
71 | # PyBuilder
72 | target/
73 |
74 | # Jupyter Notebook
75 | .ipynb_checkpoints
76 |
77 | # pyenv
78 | .python-version
79 |
80 | # celery beat schedule file
81 | celerybeat-schedule
82 |
83 | # SageMath parsed files
84 | *.sage.py
85 |
86 | # Environments
87 | .env
88 | .venv
89 | env/
90 | venv/
91 | ENV/
92 | env.bak/
93 | venv.bak/
94 |
95 | # Spyder project settings
96 | .spyderproject
97 | .spyproject
98 |
99 | # Rope project settings
100 | .ropeproject
101 |
102 | # mkdocs documentation
103 | /site
104 |
105 | # mypy
106 | .mypy_cache/
107 |
108 | # PyCharm
109 | .idea
110 |
111 | # macOS
112 | .DS_Store
113 |
--------------------------------------------------------------------------------
/CONTRIBUTE.md:
--------------------------------------------------------------------------------
1 | Contributing to Django Minio Backend
2 | ------------------------------
3 |
4 | You can find a reference implementation of a Django app using **django-minio-backend** as a storage backend in
5 | [DjangoExampleApplication/models.py](DjangoExampleApplication/models.py).
6 |
7 | When you're finished with your changes, please open a pull request!
8 |
9 | # Development Environment
10 | Execute the following steps to prepare your development environment:
11 | 1. Clone the library:
12 | ```bash
13 | git clone https://github.com/theriverman/django-minio-backend.git
14 | cd django-minio-backend
15 | ```
16 | 1. Create a virtual environment and activate it:
17 | ```bash
18 | python3 -m venv .venv
19 | source .venv/bin/activate
20 | ```
21 | 1. Install Python Dependencies:
22 | ```bash
23 | pip install -r requirements.txt
24 | ```
25 | 1. Execute Django Migrations:
26 | ```bash
27 | python manage.py migrate
28 | ```
29 | 1. Create Admin Account (optional):
30 | ```bash
31 | python manage.py createsuperuser
32 | ```
33 | 1. Run the Project:
34 | ```bash
35 | python manage.py runserver
36 | ```
37 |
38 | # Testing
39 | You can run tests by executing the following command (in the repository root):
40 | ```bash
41 | python manage.py test
42 | ```
43 |
44 | **Note:** Tests are quite poor at the moment.
45 |
--------------------------------------------------------------------------------
/DjangoExampleApplication/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/DjangoExampleApplication/__init__.py
--------------------------------------------------------------------------------
/DjangoExampleApplication/admin.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 | from django.db.models.query import QuerySet
3 | from django.contrib import admin
4 | from django.core.handlers.wsgi import WSGIRequest
5 | from .models import PublicAttachment, PrivateAttachment, Image, GenericAttachment
6 |
7 |
8 | # https://docs.djangoproject.com/en/2.2/ref/contrib/admin/actions/#writing-action-functions
9 | def delete_everywhere(model_admin: Union[PublicAttachment, PrivateAttachment],
10 | request: WSGIRequest,
11 | queryset: QuerySet):
12 | """
13 | Delete object both in Django and in MinIO too.
14 | :param model_admin: unused
15 | :param request: unused
16 | :param queryset: A QuerySet containing the set of objects selected by the user
17 | :return:
18 | """
19 | del model_admin, request # We don't need these
20 | for obj in queryset:
21 | obj.delete()
22 |
23 |
24 | delete_everywhere.short_description = "Delete selected objects in Django and MinIO"
25 |
26 |
27 | @admin.register(Image)
28 | class ImageAdmin(admin.ModelAdmin):
29 | list_display = ('id', 'image',)
30 | readonly_fields = ('id', )
31 | model = Image
32 | actions = [delete_everywhere, ]
33 |
34 |
35 | @admin.register(GenericAttachment)
36 | class GenericAttachmentAdmin(admin.ModelAdmin):
37 | list_display = ('id', 'file',)
38 | readonly_fields = ('id', )
39 | model = GenericAttachment
40 | actions = [delete_everywhere, ]
41 |
42 |
43 | # Register your models here.
44 | @admin.register(PublicAttachment)
45 | class PublicAttachmentAdmin(admin.ModelAdmin):
46 | list_display = ('id', 'content_type',)
47 | readonly_fields = ('id', 'content_object', 'file_name', 'file_size', )
48 | model = PublicAttachment
49 | actions = [delete_everywhere, ]
50 |
51 | fieldsets = [
52 |
53 | ('General Information',
54 | {'fields': ('id',)}),
55 | ('S3 Object',
56 | {'fields': ('file_name', 'file_size', 'file',)}),
57 | ('S3 Object Details',
58 | {'fields': ('content_object', 'content_type', 'object_id',)}),
59 | ]
60 |
61 | def get_actions(self, request):
62 | actions = super().get_actions(request)
63 | if 'delete_selected' in actions:
64 | del actions['delete_selected']
65 | return actions
66 |
67 |
68 | @admin.register(PrivateAttachment)
69 | class PrivateAttachmentAdmin(admin.ModelAdmin):
70 | list_display = ('id', 'content_type',)
71 | readonly_fields = ('id', 'content_object', 'file_name', 'file_size')
72 | model = PrivateAttachment
73 | actions = [delete_everywhere, ]
74 |
75 | fieldsets = [
76 |
77 | ('General Information',
78 | {'fields': ('id',)}),
79 | ('S3 Object',
80 | {'fields': ('file_name', 'file_size', 'file',)}),
81 | ('S3 Object Details',
82 | {'fields': ('content_object', 'content_type', 'object_id',)}),
83 | ]
84 |
85 | def get_actions(self, request):
86 | actions = super().get_actions(request)
87 | if 'delete_selected' in actions:
88 | del actions['delete_selected']
89 | return actions
90 |
--------------------------------------------------------------------------------
/DjangoExampleApplication/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class DjangoExampleApplicationConfig(AppConfig):
5 | name = 'DjangoExampleApplication'
6 |
--------------------------------------------------------------------------------
/DjangoExampleApplication/assets/COPYRIGHT:
--------------------------------------------------------------------------------
1 | https://pixabay.com/photos/audience-concert-music-868074/
2 |
3 | Simplified Pixabay License
4 | https://pixabay.com/service/license/
5 |
6 | Free for commercial use
7 | No attribution required
8 |
--------------------------------------------------------------------------------
/DjangoExampleApplication/assets/audience-868074_1920.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/DjangoExampleApplication/assets/audience-868074_1920.jpg
--------------------------------------------------------------------------------
/DjangoExampleApplication/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.3 on 2020-11-15 20:23
2 |
3 | import DjangoExampleApplication.models
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 | import django_minio_backend.models
7 | import uuid
8 |
9 |
10 | class Migration(migrations.Migration):
11 |
12 | initial = True
13 |
14 | dependencies = [
15 | ('contenttypes', '0002_remove_content_type_name'),
16 | ]
17 |
18 | operations = [
19 | migrations.CreateModel(
20 | name='Image',
21 | fields=[
22 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
23 | ('image', models.ImageField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-public'), upload_to=django_minio_backend.models.iso_date_prefix)),
24 | ],
25 | ),
26 | migrations.CreateModel(
27 | name='PublicAttachment',
28 | fields=[
29 | ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='Public Attachment ID')),
30 | ('object_id', models.PositiveIntegerField(verbose_name="Related Object's ID")),
31 | ('file', models.FileField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-public'), upload_to=django_minio_backend.models.iso_date_prefix, verbose_name='Object Upload')),
32 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')),
33 | ],
34 | ),
35 | migrations.CreateModel(
36 | name='PrivateAttachment',
37 | fields=[
38 | ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='Public Attachment ID')),
39 | ('object_id', models.PositiveIntegerField(verbose_name="Related Object's ID")),
40 | ('file', models.FileField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-private'), upload_to=DjangoExampleApplication.models.PrivateAttachment.set_file_path_name, verbose_name='Object Upload')),
41 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')),
42 | ],
43 | ),
44 | ]
45 |
--------------------------------------------------------------------------------
/DjangoExampleApplication/migrations/0002_auto_20210313_1049.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.3 on 2021-03-13 10:49
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('contenttypes', '0002_remove_content_type_name'),
11 | ('DjangoExampleApplication', '0001_initial'),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name='privateattachment',
17 | name='content_type',
18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'),
19 | ),
20 | migrations.AlterField(
21 | model_name='privateattachment',
22 | name='object_id',
23 | field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Related Object's ID"),
24 | ),
25 | migrations.AlterField(
26 | model_name='publicattachment',
27 | name='content_type',
28 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'),
29 | ),
30 | migrations.AlterField(
31 | model_name='publicattachment',
32 | name='object_id',
33 | field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Related Object's ID"),
34 | ),
35 | ]
36 |
--------------------------------------------------------------------------------
/DjangoExampleApplication/migrations/0003_genericattachment.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.3 on 2021-07-18 22:07
2 |
3 | from django.db import migrations, models
4 | import uuid
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('DjangoExampleApplication', '0002_auto_20210313_1049'),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name='GenericAttachment',
16 | fields=[
17 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
18 | ('file', models.FileField(upload_to='', verbose_name='Object Upload (to default storage)')),
19 | ],
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/DjangoExampleApplication/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/DjangoExampleApplication/migrations/__init__.py
--------------------------------------------------------------------------------
/DjangoExampleApplication/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | import datetime
3 | from django.db import models
4 | from django.db.models.fields.files import FieldFile
5 | from django.contrib.contenttypes.models import ContentType
6 | from django.contrib.contenttypes.fields import GenericForeignKey
7 | from django_minio_backend import MinioBackend, iso_date_prefix
8 |
9 |
10 | def get_iso_date() -> str:
11 | """Get current date in ISO8601 format [year-month-day] as string"""
12 | now = datetime.datetime.now(datetime.UTC)
13 | return f"{now.year}-{now.month}-{now.day}"
14 |
15 |
16 | class Image(models.Model):
17 | """
18 | This is just for uploaded image
19 | """
20 | objects = models.Manager()
21 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
22 | image = models.ImageField(upload_to=iso_date_prefix, storage=MinioBackend(bucket_name='django-backend-dev-public'))
23 |
24 | def delete(self, *args, **kwargs):
25 | """
26 | Delete must be overridden because the inherited delete method does not call `self.image.delete()`.
27 | """
28 | # noinspection PyUnresolvedReferences
29 | self.image.delete()
30 | super(Image, self).delete(*args, **kwargs)
31 |
32 |
33 | class GenericAttachment(models.Model):
34 | """
35 | This is for demonstrating uploads to the default file storage
36 | """
37 | objects = models.Manager()
38 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
39 | file = models.FileField(verbose_name="Object Upload (to default storage)")
40 |
41 | def delete(self, *args, **kwargs):
42 | """
43 | Delete must be overridden because the inherited delete method does not call `self.image.delete()`.
44 | """
45 | # noinspection PyUnresolvedReferences
46 | self.file.delete()
47 | super(GenericAttachment, self).delete(*args, **kwargs)
48 |
49 |
50 | # Create your models here.
51 | class PublicAttachment(models.Model):
52 | def set_file_path_name(self, file_name_ext: str) -> str:
53 | """
54 | Defines the full absolute path to the file in the bucket. The original content's type is used as parent folder.
55 | :param file_name_ext: (str) File name + extension. ie.: cat.png OR images/animals/2019/cat.png
56 | :return: (str) Absolute path to file in Minio Bucket
57 | """
58 | return f"{get_iso_date()}/{self.content_type.name}/{file_name_ext}"
59 |
60 | def delete(self, *args, **kwargs):
61 | """
62 | Delete must be overridden because the inherited delete method does not call `self.file.delete()`.
63 | """
64 | self.file.delete()
65 | super(PublicAttachment, self).delete(*args, **kwargs)
66 |
67 | @property
68 | def file_name(self):
69 | try:
70 | return self.file.name.split("/")[-1]
71 | except AttributeError:
72 | return "[Deleted Object]"
73 |
74 | @property
75 | def file_size(self):
76 | return self.file.size
77 |
78 | def __str__(self):
79 | return str(self.file)
80 |
81 | id = models.AutoField(primary_key=True, verbose_name="Public Attachment ID")
82 | content_type: ContentType = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE,
83 | verbose_name="Content Type")
84 | object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Related Object's ID")
85 | content_object = GenericForeignKey("content_type", "object_id")
86 |
87 | file: FieldFile = models.FileField(verbose_name="Object Upload",
88 | storage=MinioBackend( # Configure MinioBackend as storage backend here
89 | bucket_name='django-backend-dev-public',
90 | ),
91 | upload_to=iso_date_prefix)
92 |
93 |
94 | class PrivateAttachment(models.Model):
95 | def set_file_path_name(self, file_name_ext: str) -> str:
96 | """
97 | Defines the full absolute path to the file in the bucket. The original content's type is used as parent folder.
98 | :param file_name_ext: (str) File name + extension. ie.: cat.png OR images/animals/2019/cat.png
99 | :return: (str) Absolute path to file in Minio Bucket
100 | """
101 | return f"{get_iso_date()}/{self.content_type.name}/{file_name_ext}"
102 |
103 | def delete(self, *args, **kwargs):
104 | """
105 | Delete must be overridden because the inherited delete method does not call `self.file.delete()`.
106 | """
107 | self.file.delete()
108 | super(PrivateAttachment, self).delete(*args, **kwargs)
109 |
110 | @property
111 | def file_name(self):
112 | try:
113 | return self.file.name.split("/")[-1]
114 | except AttributeError:
115 | return "[Deleted Object]"
116 |
117 | @property
118 | def file_size(self):
119 | return self.file.size
120 |
121 | def __str__(self):
122 | return str(self.file)
123 |
124 | id = models.AutoField(primary_key=True, verbose_name="Public Attachment ID")
125 | content_type: ContentType = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE,
126 | verbose_name="Content Type")
127 | object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Related Object's ID")
128 | content_object = GenericForeignKey("content_type", "object_id")
129 |
130 | file: FieldFile = models.FileField(verbose_name="Object Upload",
131 | storage=MinioBackend( # Configure MinioBackend as storage backend here
132 | bucket_name='django-backend-dev-private',
133 | ),
134 | upload_to=set_file_path_name)
135 |
--------------------------------------------------------------------------------
/DjangoExampleApplication/tests.py:
--------------------------------------------------------------------------------
1 | import time
2 | from pathlib import Path
3 | from django.conf import settings
4 | from django.core.files import File
5 | from django.contrib.contenttypes.models import ContentType
6 | from django.test import TestCase
7 | from django.core.validators import URLValidator
8 |
9 | from DjangoExampleApplication.models import Image, PublicAttachment, PrivateAttachment
10 |
11 |
12 | test_file_path = Path(settings.BASE_DIR) / "DjangoExampleApplication" / "assets" / "audience-868074_1920.jpg"
13 | test_file_size = 339085
14 |
15 |
16 | class ImageTestCase(TestCase):
17 | obj: Image = None
18 |
19 | def setUp(self):
20 | # Open a test file from disk and upload to minIO as an image
21 | with open(test_file_path, 'rb') as f:
22 | self.obj = Image.objects.create()
23 | self.obj.image.save(name='audience-868074_1920.jpg', content=f)
24 |
25 | def tearDown(self):
26 | # Remove uploaded file from minIO and remove the Image entry from Django's database
27 | self.obj.delete() # deletes from both locations
28 |
29 | def test_url_generation_works(self):
30 | """Accessing the value of obj.image.url"""
31 | val = URLValidator()
32 | val(self.obj.image.url) # 1st make sure it's a URL
33 | self.assertTrue('audience-868074_1920' in self.obj.image.url) # 2nd make sure our filename matches
34 |
35 | def test_read_image_size(self):
36 | self.assertEqual(self.obj.image.size, test_file_size)
37 |
38 |
39 | class PublicAttachmentTestCase(TestCase):
40 | obj: PublicAttachment = None
41 | filename = f'public_audience-868074_1920_{int(time.time())}.jpg' # adding unix time makes our filename unique
42 |
43 | def setUp(self):
44 | ct = ContentType.objects.get(app_label='auth', model='user') # PublicAttachment is generic so this is needed
45 | with open(test_file_path, 'rb') as f:
46 | # noinspection PyUnresolvedReferences
47 | self.obj = PublicAttachment.objects.create()
48 | self.obj.ct = ct
49 | self.obj.object_id = 1 # we associate this uploaded file to user with pk=1
50 | self.obj.file.save(name=self.filename, content=File(f), save=True)
51 |
52 | def test_url_generation_works(self):
53 | """Accessing the value of obj.file.url"""
54 | val = URLValidator()
55 | val(self.obj.file.url) # 1st make sure it's a URL
56 | self.assertTrue('public_audience-868074_1920' in self.obj.file.url) # 2nd make sure our filename matches
57 |
58 | def test_read_file_size(self):
59 | self.assertEqual(self.obj.file_size, test_file_size)
60 |
61 | def test_read_file_name(self):
62 | self.assertEqual(self.obj.file_name, self.filename)
63 |
64 |
65 | class PrivateAttachmentTestCase(TestCase):
66 | obj: PrivateAttachment = None
67 | filename = f'private_audience-868074_1920_{int(time.time())}.jpg' # adding unix time makes our filename unique
68 |
69 | def setUp(self):
70 | ct = ContentType.objects.get(app_label='auth', model='user') # PublicAttachment is generic so this is needed
71 | with open(test_file_path, 'rb') as f:
72 | # noinspection PyUnresolvedReferences
73 | self.obj = PublicAttachment.objects.create()
74 | self.obj.ct = ct
75 | self.obj.object_id = 1 # we associate this uploaded file to user with pk=1
76 | self.obj.file.save(name=self.filename, content=File(f), save=True)
77 |
78 | def test_url_generation_works(self):
79 | """Accessing the value of obj.file.url"""
80 | val = URLValidator()
81 | val(self.obj.file.url) # 1st make sure it's a URL
82 | self.assertTrue('private_audience-868074_1920' in self.obj.file.url) # 2nd make sure our filename matches
83 |
84 | def test_read_file_size(self):
85 | self.assertEqual(self.obj.file_size, test_file_size)
86 |
87 | def test_read_file_name(self):
88 | self.assertEqual(self.obj.file_name, self.filename)
89 |
--------------------------------------------------------------------------------
/DjangoExampleProject/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/DjangoExampleProject/__init__.py
--------------------------------------------------------------------------------
/DjangoExampleProject/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for DjangoExampleProject project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings')
15 |
16 | application = get_asgi_application()
17 |
--------------------------------------------------------------------------------
/DjangoExampleProject/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for DjangoExampleProject project.
3 |
4 | Generated by 'django-admin startproject' using Django 3.0.6.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.0/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/3.0/ref/settings/
11 | """
12 |
13 | import os
14 | import distutils.util
15 | from datetime import timedelta
16 | from typing import List, Tuple
17 |
18 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
19 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20 |
21 | # Quick-start development settings - unsuitable for production
22 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
23 |
24 | # SECURITY WARNING: keep the secret key used in production secret!
25 | SECRET_KEY = 'sp1d7_7z9))q58(6k&1)9m_@!8e420*m+3dasq-*711fu8)y!6'
26 |
27 | # SECURITY WARNING: don't run with debug turned on in production!
28 | DEBUG = True
29 |
30 | ALLOWED_HOSTS = []
31 |
32 | # Application definition
33 |
34 | INSTALLED_APPS = [
35 | 'django.contrib.admin',
36 | 'django.contrib.auth',
37 | 'django.contrib.contenttypes',
38 | 'django.contrib.sessions',
39 | 'django.contrib.messages',
40 | 'django.contrib.staticfiles',
41 | 'django_minio_backend.apps.DjangoMinioBackendConfig', # Driver
42 | 'DjangoExampleApplication', # Test App
43 | ]
44 |
45 | MIDDLEWARE = [
46 | 'django.middleware.security.SecurityMiddleware',
47 | 'django.contrib.sessions.middleware.SessionMiddleware',
48 | 'django.middleware.common.CommonMiddleware',
49 | 'django.middleware.csrf.CsrfViewMiddleware',
50 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
51 | 'django.contrib.messages.middleware.MessageMiddleware',
52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
53 | ]
54 |
55 | ROOT_URLCONF = 'DjangoExampleProject.urls'
56 |
57 | TEMPLATES = [
58 | {
59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
60 | 'DIRS': [],
61 | 'APP_DIRS': True,
62 | 'OPTIONS': {
63 | 'context_processors': [
64 | 'django.template.context_processors.debug',
65 | 'django.template.context_processors.request',
66 | 'django.contrib.auth.context_processors.auth',
67 | 'django.contrib.messages.context_processors.messages',
68 | ],
69 | },
70 | },
71 | ]
72 |
73 | WSGI_APPLICATION = 'DjangoExampleProject.wsgi.application'
74 |
75 | # Database
76 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
77 |
78 | DATABASES = {
79 | 'default': {
80 | 'ENGINE': 'django.db.backends.sqlite3',
81 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
82 | }
83 | }
84 |
85 | # Password validation
86 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
87 |
88 | AUTH_PASSWORD_VALIDATORS = [
89 | {
90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
91 | },
92 | {
93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
94 | },
95 | {
96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
97 | },
98 | {
99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
100 | },
101 | ]
102 |
103 | # Internationalization
104 | # https://docs.djangoproject.com/en/3.0/topics/i18n/
105 |
106 | LANGUAGE_CODE = 'en-us'
107 |
108 | TIME_ZONE = 'UTC'
109 |
110 | USE_I18N = True
111 |
112 | USE_L10N = True
113 |
114 | USE_TZ = True
115 |
116 | # Static files (CSS, JavaScript, Images)
117 | # https://docs.djangoproject.com/en/3.0/howto/static-files/
118 | STATIC_URL = '/static/'
119 |
120 | # #################### #
121 | # django_minio_backend #
122 | # #################### #
123 |
124 | dummy_policy = {"Version": "2012-10-17",
125 | "Statement": [
126 | {
127 | "Sid": "",
128 | "Effect": "Allow",
129 | "Principal": {"AWS": "*"},
130 | "Action": "s3:GetBucketLocation",
131 | "Resource": f"arn:aws:s3:::django-backend-dev-private"
132 | },
133 | {
134 | "Sid": "",
135 | "Effect": "Allow",
136 | "Principal": {"AWS": "*"},
137 | "Action": "s3:ListBucket",
138 | "Resource": f"arn:aws:s3:::django-backend-dev-private"
139 | },
140 | {
141 | "Sid": "",
142 | "Effect": "Allow",
143 | "Principal": {"AWS": "*"},
144 | "Action": "s3:GetObject",
145 | "Resource": f"arn:aws:s3:::django-backend-dev-private/*"
146 | }
147 | ]}
148 |
149 | MINIO_ENDPOINT = os.getenv("GH_MINIO_ENDPOINT", "play.min.io")
150 | MINIO_EXTERNAL_ENDPOINT = os.getenv("GH_MINIO_EXTERNAL_ENDPOINT", "externalplay.min.io")
151 | MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = bool(distutils.util.strtobool(os.getenv("GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", "true")))
152 | MINIO_ACCESS_KEY = os.getenv("GH_MINIO_ACCESS_KEY", "Q3AM3UQ867SPQQA43P2F")
153 | MINIO_SECRET_KEY = os.getenv("GH_MINIO_SECRET_KEY", "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG")
154 | MINIO_USE_HTTPS = bool(distutils.util.strtobool(os.getenv("GH_MINIO_USE_HTTPS", "true")))
155 | MINIO_REGION = os.getenv("GH_MINIO_REGION", "us-east-1")
156 | MINIO_PRIVATE_BUCKETS = [
157 | 'django-backend-dev-private',
158 | 'my-media-files-bucket',
159 | ]
160 | MINIO_PUBLIC_BUCKETS = [
161 | 'django-backend-dev-public',
162 | 't5p2g08k31',
163 | '7xi7lx9rjh',
164 | 'my-static-files-bucket',
165 | ]
166 | MINIO_URL_EXPIRY_HOURS = timedelta(days=1) # Default is 7 days (longest) if not defined
167 | MINIO_CONSISTENCY_CHECK_ON_START = True
168 | MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [
169 | # ('django-backend-dev-private', dummy_policy)
170 | ]
171 | MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for STATIC_ROOT
172 | MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for MEDIA_ROOT
173 | MINIO_BUCKET_CHECK_ON_SAVE = False # Create bucket if missing, then save
174 |
175 | STORAGES = { # -- ADDED IN Django 5.1
176 | "default": {
177 | "BACKEND": "django_minio_backend.models.MinioBackend",
178 | },
179 | "staticfiles": {
180 | "BACKEND": "django_minio_backend.models.MinioBackendStatic",
181 | },
182 | }
183 |
--------------------------------------------------------------------------------
/DjangoExampleProject/urls.py:
--------------------------------------------------------------------------------
1 | """DjangoExampleProject URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/
5 | Examples:
6 | Function views
7 | 1. Add an import: from my_app import views
8 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
9 | Class-based views
10 | 1. Add an import: from other_app.views import Home
11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
12 | Including another URLconf
13 | 1. Import the include() function: from django.urls import include, path
14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
15 | """
16 | from django.contrib import admin
17 | from django.urls import path
18 |
19 | urlpatterns = [
20 | path('admin/', admin.site.urls),
21 | ]
22 |
--------------------------------------------------------------------------------
/DjangoExampleProject/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for DjangoExampleProject project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1
2 | FROM python:3
3 | ENV PYTHONUNBUFFERED=1
4 | WORKDIR /code
5 |
6 | # Copy Demo Project
7 | COPY ./manage.py /code/manage.py
8 | COPY ./django_minio_backend /code/django_minio_backend
9 | COPY ./DjangoExampleProject /code/DjangoExampleProject
10 | COPY ./DjangoExampleApplication /code/DjangoExampleApplication
11 |
12 | # Copy and install requirements.txt
13 | COPY requirements.txt /code/
14 | RUN pip install -r /code/requirements.txt
15 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019-2025 github.com/theriverman
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README*.md
3 | include RELEASE-VERSION
4 | include version.py
5 | # recursive-include docs *
6 | recursive-include django_minio_backend/management *
7 |
--------------------------------------------------------------------------------
/README.Docker.md:
--------------------------------------------------------------------------------
1 | # Docker Compose Description for django-minio-backend
2 | Execute the following step to start a demo environment using Docker Compose:
3 |
4 | **Start the Docker Compose services:**
5 | ```shell
6 | docker compose up -d
7 | docker compose exec web python manage.py createsuperuser --noinput
8 | docker compose exec web python manage.py collectstatic --noinput
9 | ```
10 |
11 | ## About docker-compose.yml
12 | Note the following lines in `docker-compose.yml`:
13 | ```yaml
14 | environment:
15 | GH_MINIO_ENDPOINT: "nginx:9000"
16 | GH_MINIO_USE_HTTPS: "false"
17 | GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000"
18 | GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false"
19 | ```
20 |
21 | MinIO is load balanced by nginx, so all connections made from Django towards MinIO happens through the internal `nginx` FQDN.
22 | Therefore, the value of `GH_MINIO_ENDPOINT` is `nginx:9000`.
23 |
24 | # Web Access
25 | Both Django(:8000) and MinIO(:9001) expose a Web GUI and their ports are mapped to the host machine.
26 |
27 | ## Django Admin
28 | Open your browser at http://localhost:8000/admin to access the Django admin portal:
29 | * username: `admin`
30 | * password: `123123`
31 |
32 | ## MinIO Console
33 | Open your browser at http://localhost:9001 to access the MiniIO Console:
34 | * username: `minio`
35 | * password: `minio123`
36 |
37 | # Developer Environment
38 | An alternative docker-compose file is available for **django-minio-backend** which does not copy the source files into the container, but maps them as a volume.
39 | **Input file**: `docker-compose.develop.yml`
40 |
41 | If you would like to develop in a Docker Compose environment, execute the following commands:
42 | ```shell
43 | docker compose -f docker-compose.develop.yml up -d
44 | docker compose -f docker-compose.develop.yml exec web python manage.py createsuperuser --noinput
45 | docker compose -f docker-compose.develop.yml exec web python manage.py collectstatic --noinput
46 | ```
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/theriverman/django-minio-backend/actions/workflows/django-tests.yml)
2 | [](https://github.com/theriverman/django-minio-backend/actions/workflows/publish-to-pypi.yml)
3 | [](https://pypi.python.org/pypi/django-minio-backend)
4 |
5 | # django-minio-backend
6 | The **django-minio-backend** provides a wrapper around the
7 | [MinIO Python SDK](https://docs.min.io/docs/python-client-quickstart-guide.html).
8 | See [minio/minio-py](https://github.com/minio/minio-py) for the source.
9 |
10 | ## Requirements & Compatibility
11 | * Django 4.2 or later
12 | * Python 3.11.0 or later
13 | * MinIO SDK 7.2.8 or later (installed automatically)
14 |
15 | ## What's in the box?
16 | The following set of features are available in **django-minio-backend**:
17 | * Django File Storage System Integration
18 | * Compliance with the `django.core.files.storage.Storage` class
19 | * Static Files Support
20 | * Utilise/manage private and public buckets
21 | * Create buckets with custom policy hooks (`MINIO_POLICY_HOOKS`)
22 | * Consistency Check on Start (`MINIO_CONSISTENCY_CHECK_ON_START`)
23 | * Bucket Check on Upload (`MINIO_BUCKET_CHECK_ON_SAVE`)
24 | * Health Check (`MinioBackend.is_minio_available()`)
25 | * Docker Networking Support
26 | * Management Commands:
27 | * initialize_buckets
28 | * is_minio_available
29 |
30 | ## Integration
31 | 1. Get and install the package:
32 | ```bash
33 | pip install django-minio-backend
34 | ```
35 |
36 | 2. Add `django_minio_backend` to `INSTALLED_APPS`:
37 | ```python
38 | INSTALLED_APPS = [
39 | # '...'
40 | 'django_minio_backend', # https://github.com/theriverman/django-minio-backend
41 | ]
42 | ```
43 |
44 | If you would like to enable on-start consistency check, install via `DjangoMinioBackendConfig`:
45 | ```python
46 | INSTALLED_APPS = [
47 | # '...'
48 | 'django_minio_backend.apps.DjangoMinioBackendConfig', # https://github.com/theriverman/django-minio-backend
49 | ]
50 | ```
51 |
52 | Then add the following parameter to your settings file:
53 | ```python
54 | MINIO_CONSISTENCY_CHECK_ON_START = True
55 | ```
56 |
57 | **Note:** The on-start consistency check equals to manually calling `python manage.py initialize_buckets`.
58 | It is recommended to turn *off* this feature during development by setting `MINIO_CONSISTENCY_CHECK_ON_START` to `False`,
59 | because this operation can noticeably slow down Django's boot time when many buckets are configured.
60 |
61 | 3. Add the following parameters to your `settings.py`:
62 | ```python
63 | from datetime import timedelta
64 | from typing import List, Tuple
65 |
66 | STORAGES = { # -- ADDED IN Django 5.1
67 | "default": {
68 | "BACKEND": "django_minio_backend.models.MinioBackend",
69 | },
70 | # "staticfiles": { # -- OPTIONAL
71 | # "BACKEND": "django_minio_backend.models.MinioBackendStatic",
72 | # },
73 | }
74 |
75 | MINIO_ENDPOINT = 'minio.your-company.co.uk'
76 | MINIO_EXTERNAL_ENDPOINT = "external-minio.your-company.co.uk" # Default is same as MINIO_ENDPOINT
77 | MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = True # Default is same as MINIO_USE_HTTPS
78 | MINIO_REGION = 'us-east-1' # Default is set to None
79 | MINIO_ACCESS_KEY = 'yourMinioAccessKey'
80 | MINIO_SECRET_KEY = 'yourVeryS3cr3tP4ssw0rd'
81 | MINIO_USE_HTTPS = True
82 | MINIO_URL_EXPIRY_HOURS = timedelta(days=1) # Default is 7 days (longest) if not defined
83 | MINIO_CONSISTENCY_CHECK_ON_START = True
84 | MINIO_PRIVATE_BUCKETS = [
85 | 'django-backend-dev-private',
86 | ]
87 | MINIO_PUBLIC_BUCKETS = [
88 | 'django-backend-dev-public',
89 | ]
90 | MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = []
91 | # MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for MEDIA_ROOT
92 | # MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT
93 | MINIO_BUCKET_CHECK_ON_SAVE = True # Default: True // Creates bucket if missing, then save
94 |
95 | # Custom HTTP Client (OPTIONAL)
96 | import os
97 | import certifi
98 | import urllib3
99 | timeout = timedelta(minutes=5).seconds
100 | ca_certs = os.environ.get('SSL_CERT_FILE') or certifi.where()
101 | MINIO_HTTP_CLIENT: urllib3.poolmanager.PoolManager = urllib3.PoolManager(
102 | timeout=urllib3.util.Timeout(connect=timeout, read=timeout),
103 | maxsize=10,
104 | cert_reqs='CERT_REQUIRED',
105 | ca_certs=ca_certs,
106 | retries=urllib3.Retry(
107 | total=5,
108 | backoff_factor=0.2,
109 | status_forcelist=[500, 502, 503, 504]
110 | )
111 | )
112 | ```
113 |
114 | 4. Implement your own Attachment handler and integrate **django-minio-backend**:
115 | ```python
116 | from django.db import models
117 | from django_minio_backend import MinioBackend, iso_date_prefix
118 |
119 | class PrivateAttachment(models.Model):
120 | file = models.FileField(verbose_name="Object Upload",
121 | storage=MinioBackend(bucket_name='django-backend-dev-private'),
122 | upload_to=iso_date_prefix)
123 | ```
124 |
125 | 5. Initialize the buckets & set their public policy (OPTIONAL):
126 | This `django-admin` command creates both the private and public buckets in case one of them does not exist,
127 | and sets the *public* bucket's privacy policy from `private`(default) to `public`.
128 | ```bash
129 | python manage.py initialize_buckets
130 | ```
131 |
132 | Code reference: [initialize_buckets.py](django_minio_backend/management/commands/initialize_buckets.py).
133 |
134 | ### Static Files Support
135 | **django-minio-backend** allows serving static files from MinIO.
136 | To learn more about Django static files, see [Managing static files](https://docs.djangoproject.com/en/5.1/howto/static-files/), [STATICFILES_STORAGE](https://docs.djangoproject.com/en/5.1/ref/settings/#static-files) and [STORAGES](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-STORAGES).
137 |
138 | To enable static files support, update your `settings.py`:
139 | ```python
140 | STORAGES = { # -- ADDED IN Django 5.1
141 | "default": {
142 | "BACKEND": "django_minio_backend.models.MinioBackend",
143 | },
144 | "staticfiles": { # -- ADD THESE LINES FOR STATIC FILES SUPPORT
145 | "BACKEND": "django_minio_backend.models.MinioBackendStatic",
146 | },
147 | }
148 | MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT
149 | # Add the value of MINIO_STATIC_FILES_BUCKET to one of the pre-configured bucket lists. e.g.:
150 | # MINIO_PRIVATE_BUCKETS.append(MINIO_STATIC_FILES_BUCKET)
151 | # MINIO_PUBLIC_BUCKETS.append(MINIO_STATIC_FILES_BUCKET)
152 | ```
153 |
154 | The value of `STATIC_URL` is ignored, but it must be defined otherwise Django will throw an error.
155 |
156 | **IMPORTANT**
157 | The value set in `MINIO_STATIC_FILES_BUCKET` must be added either to `MINIO_PRIVATE_BUCKETS` or `MINIO_PUBLIC_BUCKETS`,
158 | otherwise **django-minio-backend** will raise an exception. This setting determines the privacy of generated file URLs which can be unsigned public or signed private.
159 |
160 | **Note:** If `MINIO_STATIC_FILES_BUCKET` is not set, the default value (`auto-generated-bucket-static-files`) will be used. Policy setting for default buckets is **private**.
161 |
162 | ### Default File Storage Support
163 | **django-minio-backend** can be configured as a default file storage.
164 | To learn more, see [STORAGES](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-STORAGES).
165 |
166 | To configure **django-minio-backend** as the default file storage, update your `settings.py`:
167 | ```python
168 | STORAGES = { # -- ADDED IN Django 5.1
169 | "default": {
170 | "BACKEND": "django_minio_backend.models.MinioBackend",
171 | }
172 | }
173 | MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for MEDIA_ROOT
174 | # Add the value of MINIO_STATIC_FILES_BUCKET to one of the pre-configured bucket lists. e.g.:
175 | # MINIO_PRIVATE_BUCKETS.append(MINIO_STATIC_FILES_BUCKET)
176 | # MINIO_PUBLIC_BUCKETS.append(MINIO_STATIC_FILES_BUCKET)
177 | ```
178 |
179 | The value of `MEDIA_URL` is ignored, but it must be defined otherwise Django will throw an error.
180 |
181 | **IMPORTANT**
182 | The value set in `MINIO_MEDIA_FILES_BUCKET` must be added either to `MINIO_PRIVATE_BUCKETS` or `MINIO_PUBLIC_BUCKETS`,
183 | otherwise **django-minio-backend** will raise an exception. This setting determines the privacy of generated file URLs which can be unsigned public or signed private.
184 |
185 | **Note:** If `MINIO_MEDIA_FILES_BUCKET` is not set, the default value (`auto-generated-bucket-media-files`) will be used. Policy setting for default buckets is **private**.
186 |
187 | ### Health Check
188 | To check the connection link between Django and MinIO, use the provided `MinioBackend.is_minio_available()` method.
189 | It returns a `MinioServerStatus` instance which can be quickly evaluated as boolean.
190 |
191 | **Example:**
192 | ```python
193 | from django_minio_backend import MinioBackend
194 |
195 | minio_available = MinioBackend().is_minio_available() # An empty string is fine this time
196 | if minio_available:
197 | print("OK")
198 | else:
199 | print("NOK")
200 | print(minio_available.details)
201 | ```
202 |
203 | ### Policy Hooks
204 | You can configure **django-minio-backend** to automatically execute a set of pre-defined policy hooks.
205 | Policy hooks can be defined in `settings.py` by adding `MINIO_POLICY_HOOKS` which must be a list of tuples.
206 | Policy hooks are automatically picked up by the `initialize_buckets` management command.
207 |
208 | For an exemplary policy, see the implementation of `set_bucket_to_public()`
209 | in [django_minio_backend/models.py](django_minio_backend/models.py) or the contents
210 | of [examples/policy_hook.example.py](examples/policy_hook.example.py).
211 |
212 | ### Consistency Check On Start
213 | When enabled, the `initialize_buckets` management command gets called automatically when Django starts.
214 | This command connects to the configured MinIO server and checks if all buckets defined in `settings.py`.
215 | In case a bucket is missing or its configuration differs, it gets created and corrected.
216 |
217 | ### Reference Implementation
218 | For a reference implementation, see [Examples](examples).
219 |
220 | ## Behaviour
221 | The following list summarises the key characteristics of **django-minio-backend**:
222 | * Bucket existence is **not** checked on a save by default.
223 | To enable this guard, set `MINIO_BUCKET_CHECK_ON_SAVE = True` in your `settings.py`.
224 | * Bucket existences are **not** checked on Django start by default.
225 | To enable this guard, set `MINIO_CONSISTENCY_CHECK_ON_START = True` in your `settings.py`.
226 | * Many configuration errors are validated through `AppConfig` but not every error can be captured there.
227 | * Files with the same name in the same bucket are **not** replaced on save by default. Django will store the newer file with an altered file name
228 | To allow replacing existing files, pass the `replace_existing=True` kwarg to `MinioBackend`.
229 | For example:
230 | ```python
231 | image = models.ImageField(storage=MinioBackend(bucket_name='images-public', replace_existing=True))
232 | ```
233 | * Depending on your configuration, **django-minio-backend** may communicate over two kind of interfaces: internal and external.
234 | If your `settings.py` defines a different value for `MINIO_ENDPOINT` and `MINIO_EXTERNAL_ENDPOINT`, then the former will be used for internal communication
235 | between Django and MinIO, and the latter for generating URLs for users. This behaviour optimises the network communication.
236 | See **Networking** below for a thorough explanation
237 | * The uploaded object's content-type is guessed during save. If `mimetypes.guess_type` fails to determine the correct content-type, then it falls back to `application/octet-stream`.
238 |
239 | ## Networking and Docker
240 | If your Django application is running on a shared host with your MinIO instance, you should consider using the `MINIO_EXTERNAL_ENDPOINT` and `MINIO_EXTERNAL_ENDPOINT_USE_HTTPS` parameters.
241 | This way most traffic will happen internally between Django and MinIO. The external endpoint parameters are required for external pre-signed URL generation.
242 |
243 | If your Django application and MinIO instance are running on different hosts, you can omit the `MINIO_EXTERNAL_ENDPOINT` and `MINIO_EXTERNAL_ENDPOINT_USE_HTTPS` parameters,
244 | and **django-minio-backend** will default to the value of `MINIO_ENDPOINT`.
245 |
246 | Setting up and configuring custom networks in Docker is not in the scope of this document.
247 | To learn more about Docker networking, see [Networking overview](https://docs.docker.com/network/) and [Networking in Compose](https://docs.docker.com/compose/networking/).
248 |
249 | See [README.Docker.md](README.Docker.md) for a real-life Docker Compose demonstration.
250 |
251 | ## Contribution
252 | Please find the details in [CONTRIBUTE.md](CONTRIBUTE.md)
253 |
254 | ## Copyright
255 | * theriverman/django-minio-backend licensed under the MIT License
256 | * minio/minio-py is licensed under the Apache License 2.0
257 |
--------------------------------------------------------------------------------
/django_minio_backend/__init__.py:
--------------------------------------------------------------------------------
1 | from .apps import *
2 | from .models import *
3 |
--------------------------------------------------------------------------------
/django_minio_backend/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 | from .utils import get_setting, ConfigurationError
3 | from .models import MinioBackend, MinioBackendStatic
4 |
5 |
6 | __all__ = ['DjangoMinioBackendConfig', ]
7 |
8 |
9 | class DjangoMinioBackendConfig(AppConfig):
10 | name = 'django_minio_backend'
11 |
12 | def ready(self):
13 | # Validate configuration for Django 5.1=< projects
14 | if STATICFILES_STORAGE := get_setting('STATICFILES_STORAGE'):
15 | if STATICFILES_STORAGE.endswith(MinioBackendStatic.__name__):
16 | raise ConfigurationError("STATICFILES_STORAGE and DEFAULT_FILE_STORAGE were replaced by STORAGES. "
17 | "See django-minio-backend's README for more information.")
18 |
19 | mb = MinioBackend()
20 | mb.validate_settings()
21 |
22 | consistency_check_on_start = get_setting('MINIO_CONSISTENCY_CHECK_ON_START', False)
23 | if consistency_check_on_start:
24 | from django.core.management import call_command
25 | print("Executing consistency checks...")
26 | call_command('initialize_buckets', silenced=True)
27 |
28 | # Validate configuration combinations for EXTERNAL ENDPOINT
29 | external_address = bool(get_setting('MINIO_EXTERNAL_ENDPOINT'))
30 | external_use_https = get_setting('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS')
31 | if (external_address and external_use_https is None) or (not external_address and external_use_https):
32 | raise ConfigurationError('MINIO_EXTERNAL_ENDPOINT must be configured together with MINIO_EXTERNAL_ENDPOINT_USE_HTTPS')
33 |
34 | # Validate static storage and default storage configurations
35 | storages = get_setting('STORAGES')
36 | staticfiles_backend = storages["staticfiles"]["BACKEND"]
37 | if staticfiles_backend.endswith(MinioBackendStatic.__name__):
38 | mbs = MinioBackendStatic()
39 | mbs.check_bucket_existence()
40 |
--------------------------------------------------------------------------------
/django_minio_backend/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/django_minio_backend/management/__init__.py
--------------------------------------------------------------------------------
/django_minio_backend/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theriverman/django-minio-backend/389b08e3d27cc5f3396abc521269cc887ed68c73/django_minio_backend/management/commands/__init__.py
--------------------------------------------------------------------------------
/django_minio_backend/management/commands/initialize_buckets.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 | from django.core.management.base import BaseCommand
3 | from django_minio_backend.models import MinioBackend
4 | from django_minio_backend.utils import get_setting
5 |
6 |
7 | class Command(BaseCommand):
8 | help = 'Helps initializing Minio buckets by creating them and setting their policies.'
9 |
10 | def add_arguments(self, parser):
11 | parser.add_argument('--silenced', action='store_true', default=False, help='No console messages')
12 |
13 | def handle(self, *args, **options):
14 | silenced = options.get('silenced')
15 | self.stdout.write(f"Initializing Minio buckets...\n") if not silenced else None
16 | private_buckets: List[str] = get_setting("MINIO_PRIVATE_BUCKETS", [])
17 | public_buckets: List[str] = get_setting("MINIO_PUBLIC_BUCKETS", [])
18 |
19 | for bucket in [*public_buckets, *private_buckets]:
20 | m = MinioBackend(bucket)
21 | m.check_bucket_existence()
22 | self.stdout.write(f"Bucket ({bucket}) OK", ending='\n') if not silenced else None
23 | if m.is_bucket_public: # Based on settings.py configuration
24 | m.set_bucket_to_public()
25 | self.stdout.write(
26 | f"Bucket ({m.bucket}) policy has been set to public", ending='\n') if not silenced else None
27 |
28 | c = MinioBackend() # Client
29 | for policy_tuple in get_setting('MINIO_POLICY_HOOKS', []):
30 | bucket, policy = policy_tuple
31 | c.set_bucket_policy(bucket, policy)
32 | self.stdout.write(
33 | f"Bucket ({m.bucket}) policy has been set via policy hook", ending='\n') if not silenced else None
34 |
35 | self.stdout.write('\nAll private & public buckets have been verified.\n', ending='\n') if not silenced else None
36 | self.stdout.flush()
37 |
--------------------------------------------------------------------------------
/django_minio_backend/management/commands/is_minio_available.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand, CommandError
2 | from django_minio_backend.models import MinioBackend
3 |
4 |
5 | class Command(BaseCommand):
6 | help = 'Checks if the configured MinIO service is available.'
7 |
8 | def add_arguments(self, parser):
9 | parser.add_argument('--silenced', action='store_true', default=False, help='No console messages')
10 |
11 | def handle(self, *args, **options):
12 | m = MinioBackend() # use default storage
13 | silenced = options.get('silenced')
14 | self.stdout.write(f"Checking the availability of MinIO at {m.base_url}\n") if not silenced else None
15 |
16 | available = m.is_minio_available()
17 | if not available:
18 | self.stdout.flush()
19 | raise CommandError(f'MinIO is NOT available at {m.base_url}\n'
20 | f'Reason: {available.details}')
21 |
22 | self.stdout.write(f'MinIO is available at {m.base_url}', ending='\n') if not silenced else None
23 | self.stdout.flush()
24 |
--------------------------------------------------------------------------------
/django_minio_backend/models.py:
--------------------------------------------------------------------------------
1 | """
2 | django-minio-backend
3 | A MinIO-compatible custom storage backend for Django
4 |
5 | References:
6 | * https://github.com/minio/minio-py
7 | * https://docs.djangoproject.com/en/3.2/howto/custom-file-storage/
8 | """
9 | import io
10 | import json
11 | import logging
12 | import mimetypes
13 | import ssl
14 | import datetime
15 | from pathlib import Path
16 | from typing import Union, List
17 |
18 | # noinspection PyPackageRequirements MinIO_requirement
19 | import certifi
20 | import minio
21 | import minio.datatypes
22 | import minio.error
23 | import minio.helpers
24 | # noinspection PyPackageRequirements MinIO_requirement
25 | import urllib3
26 | from django.core.files import File
27 | from django.core.files.storage import Storage
28 | from django.core.files.uploadedfile import InMemoryUploadedFile
29 | from django.utils.deconstruct import deconstructible
30 |
31 | from .utils import MinioServerStatus, PrivatePublicMixedError, ConfigurationError, get_setting
32 |
33 |
34 | __all__ = ['MinioBackend', 'MinioBackendStatic', 'get_iso_date', 'iso_date_prefix', ]
35 | logger = logging.getLogger(__name__)
36 |
37 |
38 | def get_iso_date() -> str:
39 | """Get current date in ISO8601 format [year-month-day] as string"""
40 | now = datetime.datetime.now(datetime.UTC)
41 | return f"{now.year}-{now.month}-{now.day}"
42 |
43 |
44 | def iso_date_prefix(_, file_name_ext: str) -> str:
45 | """
46 | Get filename prepended with current date in ISO8601 format [year-month-day] as string
47 | The date prefix will be the folder's name storing the object e.g.: 2020-12-31/cat.png
48 | """
49 | return f"{get_iso_date()}/{file_name_ext}"
50 |
51 |
52 | class S3File(File):
53 | """A file returned from the Minio server"""
54 |
55 | def __init__(self, file, name, storage):
56 | super().__init__(file, name)
57 | self._storage = storage
58 |
59 | def open(self, mode=None, *args, **kwargs):
60 | if self.closed:
61 | self.file = self._storage.open(self.name, mode or "rb").file
62 | return super().open(mode, *args, **kwargs)
63 |
64 |
65 | @deconstructible
66 | class MinioBackend(Storage):
67 | """
68 | :param bucket_name (str): The bucket's name where file(s) will be stored
69 | :arg *args: An arbitrary number of arguments. Stored in the self._META_ARGS class field
70 | :arg **kwargs: An arbitrary number of key-value arguments.
71 | Stored in the self._META_KWARGS class field
72 | Through self._META_KWARGS, the "metadata", "sse" and "progress" fields can be set
73 | for the underlying put_object() MinIO SDK method
74 | """
75 | DEFAULT_MEDIA_FILES_BUCKET = 'auto-generated-bucket-media-files'
76 | DEFAULT_STATIC_FILES_BUCKET = 'auto-generated-bucket-static-files'
77 | DEFAULT_PRIVATE_BUCKETS = [DEFAULT_MEDIA_FILES_BUCKET, DEFAULT_STATIC_FILES_BUCKET]
78 | MINIO_MEDIA_FILES_BUCKET = get_setting("MINIO_MEDIA_FILES_BUCKET", default=DEFAULT_MEDIA_FILES_BUCKET)
79 | MINIO_STATIC_FILES_BUCKET = get_setting("MINIO_STATIC_FILES_BUCKET", default=DEFAULT_STATIC_FILES_BUCKET)
80 |
81 | def __init__(self,
82 | bucket_name: str = '',
83 | *args,
84 | **kwargs):
85 |
86 | # If bucket_name is not provided, MinioBackend acts as a DEFAULT_FILE_STORAGE
87 | # The automatically selected bucket is MINIO_MEDIA_FILES_BUCKET from settings.py
88 | # See https://docs.djangoproject.com/en/3.2/ref/settings/#default-file-storage
89 | if not bucket_name or bucket_name == '':
90 | self.__CONFIGURED_AS_DEFAULT_STORAGE = True
91 | self._BUCKET_NAME: str = self.MINIO_MEDIA_FILES_BUCKET
92 | else:
93 | self.__CONFIGURED_AS_DEFAULT_STORAGE = False
94 | self._BUCKET_NAME: str = bucket_name
95 |
96 | self._META_ARGS = args
97 | self._META_KWARGS = kwargs
98 |
99 | self._REPLACE_EXISTING = kwargs.get('replace_existing', False)
100 |
101 | self.__CLIENT: Union[minio.Minio, None] = None # This client is used for internal communication only. Communication this way should not leave the host network's perimeter
102 | self.__CLIENT_EXT: Union[minio.Minio, None] = None # This client is used for external communication. This client is necessary for creating region-aware pre-signed URLs
103 | self.__MINIO_ENDPOINT: str = get_setting("MINIO_ENDPOINT", "")
104 | self.__MINIO_EXTERNAL_ENDPOINT: str = get_setting("MINIO_EXTERNAL_ENDPOINT", self.__MINIO_ENDPOINT)
105 | self.__MINIO_ACCESS_KEY: str = get_setting("MINIO_ACCESS_KEY")
106 | self.__MINIO_SECRET_KEY: str = get_setting("MINIO_SECRET_KEY")
107 | self.__MINIO_USE_HTTPS: bool = get_setting("MINIO_USE_HTTPS")
108 | self.__MINIO_REGION: str = get_setting("MINIO_REGION", "us-east-1") # MINIO defaults to "us-east-1" when region is set to None
109 | self.__MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: bool = get_setting("MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", self.__MINIO_USE_HTTPS)
110 | self.__MINIO_BUCKET_CHECK_ON_SAVE: bool = get_setting("MINIO_BUCKET_CHECK_ON_SAVE", False)
111 |
112 | self.__BASE_URL = ("https://" if self.__MINIO_USE_HTTPS else "http://") + self.__MINIO_ENDPOINT
113 | self.__BASE_URL_EXTERNAL = ("https://" if self.__MINIO_EXTERNAL_ENDPOINT_USE_HTTPS else "http://") + self.__MINIO_EXTERNAL_ENDPOINT
114 | self.__SAME_ENDPOINTS = self.__MINIO_ENDPOINT == self.__MINIO_EXTERNAL_ENDPOINT
115 |
116 | self.PRIVATE_BUCKETS: List[str] = get_setting("MINIO_PRIVATE_BUCKETS", [])
117 | self.PUBLIC_BUCKETS: List[str] = get_setting("MINIO_PUBLIC_BUCKETS", [])
118 |
119 | # Configure storage type
120 | self.__STORAGE_TYPE = 'custom'
121 | if self.bucket == self.MINIO_MEDIA_FILES_BUCKET:
122 | self.__STORAGE_TYPE = 'media'
123 | if self.bucket == self.MINIO_STATIC_FILES_BUCKET:
124 | self.__STORAGE_TYPE = 'static'
125 |
126 | # Enforce good bucket security (private vs public)
127 | if (self.bucket in self.DEFAULT_PRIVATE_BUCKETS) and (self.bucket not in [*self.PRIVATE_BUCKETS, *self.PUBLIC_BUCKETS]):
128 | self.PRIVATE_BUCKETS.extend(self.DEFAULT_PRIVATE_BUCKETS) # policy for default buckets is PRIVATE
129 | # Require custom buckets to be declared explicitly
130 | if self.bucket not in [*self.PRIVATE_BUCKETS, *self.PUBLIC_BUCKETS]:
131 | raise ConfigurationError(f'The configured bucket ({self.bucket}) must be declared either in MINIO_PRIVATE_BUCKETS or MINIO_PUBLIC_BUCKETS')
132 |
133 | # https://docs.min.io/docs/python-client-api-reference.html
134 | http_client_from_kwargs = self._META_KWARGS.get("http_client", None)
135 | http_client_from_settings = get_setting("MINIO_HTTP_CLIENT")
136 | self.HTTP_CLIENT: urllib3.poolmanager.PoolManager = http_client_from_kwargs or http_client_from_settings
137 |
138 | bucket_name_intersection: List[str] = list(set(self.PRIVATE_BUCKETS) & set(self.PUBLIC_BUCKETS))
139 | if bucket_name_intersection:
140 | raise PrivatePublicMixedError(
141 | f'One or more buckets have been declared both private and public: {bucket_name_intersection}'
142 | )
143 |
144 | """
145 | django.core.files.storage.Storage
146 | """
147 |
148 | def _save(self, file_path_name: str, content: InMemoryUploadedFile) -> str:
149 | """
150 | Saves file to Minio by implementing Minio.put_object()
151 | :param file_path_name (str): Path to file + file name + file extension | i.e.: images/2018-12-31/cat.png
152 | :param content (InMemoryUploadedFile): File object
153 | :return:
154 | """
155 | if self.__MINIO_BUCKET_CHECK_ON_SAVE:
156 | # Create bucket if not exists
157 | self.check_bucket_existence()
158 |
159 | # Check if object with name already exists; delete if so
160 | try:
161 | if self._REPLACE_EXISTING and self.stat(file_path_name):
162 | self.delete(file_path_name)
163 | except AttributeError:
164 | pass
165 |
166 | # Upload object
167 | file_path: Path = Path(file_path_name) # app name + file.suffix
168 | content_bytes: io.BytesIO = io.BytesIO(content.read())
169 | content_length: int = len(content_bytes.getvalue())
170 |
171 | self.client.put_object(
172 | bucket_name=self.bucket,
173 | object_name=file_path.as_posix(),
174 | data=content_bytes,
175 | length=content_length,
176 | content_type=self._guess_content_type(file_path_name, content),
177 | metadata=self._META_KWARGS.get('metadata', None),
178 | sse=self._META_KWARGS.get('sse', None),
179 | progress=self._META_KWARGS.get('progress', None),
180 | )
181 | return file_path.as_posix()
182 |
183 | def get_available_name(self, name, max_length=None):
184 | """
185 | Return a filename that's free on the target storage system and
186 | available for new content to be written to.
187 | """
188 | if self._REPLACE_EXISTING:
189 | return name
190 | return super(MinioBackend, self).get_available_name(name, max_length)
191 |
192 | def _open(self, object_name, mode='rb', **kwargs) -> S3File:
193 | """
194 | Implements the Storage._open(name,mode='rb') method
195 | :param name (str): object_name [path to file excluding bucket name which is implied]
196 | :kwargs (dict): passed on to the underlying MinIO client's get_object() method
197 | """
198 | resp: urllib3.response.HTTPResponse = urllib3.response.HTTPResponse()
199 |
200 | if mode != 'rb':
201 | raise ValueError('Files retrieved from MinIO are read-only. Use save() method to override contents')
202 | try:
203 | resp = self.client.get_object(self.bucket, object_name, **kwargs)
204 | file = S3File(file=io.BytesIO(resp.read()), name=object_name, storage=self)
205 | finally:
206 | resp.close()
207 | resp.release_conn()
208 | return file
209 |
210 | def stat(self, name: str) -> Union[minio.datatypes.Object, bool]:
211 | """Get object information and metadata of an object"""
212 | object_name = Path(name).as_posix()
213 | try:
214 | obj = self.client.stat_object(self.bucket, object_name=object_name)
215 | return obj
216 | except (minio.error.S3Error, minio.error.ServerError, urllib3.exceptions.MaxRetryError):
217 | raise AttributeError(f'Could not stat object ({name}) in bucket ({self.bucket})')
218 |
219 | def delete(self, name: str):
220 | """
221 | Deletes an object in Django and MinIO.
222 | This method is called only when an object is deleted from its own `change view` i.e.:
223 | http://django.test/admin/upload/privateattachment/13/change/
224 | This method is NOT called during a bulk_delete order!
225 | :param name: File object name
226 | """
227 | object_name = Path(name).as_posix()
228 | self.client.remove_object(bucket_name=self.bucket, object_name=object_name)
229 |
230 | def exists(self, name: str) -> bool:
231 | """Check if an object with name already exists"""
232 | object_name = Path(name).as_posix()
233 | try:
234 | if self.stat(object_name):
235 | return True
236 | return False
237 | except AttributeError as e:
238 | logger.info(e)
239 | return False
240 |
241 | def listdir(self, bucket_name: str):
242 | """List all objects in a bucket"""
243 | objects = self.client.list_objects(bucket_name=bucket_name, recursive=True)
244 | return [(obj.object_name, obj) for obj in objects]
245 |
246 | def size(self, name: str) -> int:
247 | """Get an object's size"""
248 | object_name = Path(name).as_posix()
249 | try:
250 | obj = self.stat(object_name)
251 | return obj.size if obj else 0
252 | except AttributeError:
253 | return 0
254 |
255 | def url(self, name: str):
256 | """
257 | Returns url to object.
258 | If bucket is public, direct link is provided.
259 | if bucket is private, a pre-signed link is provided.
260 | :param name: (str) file path + file name + suffix
261 | :return: (str) URL to object
262 | """
263 | client = self.client if self.same_endpoints else self.client_external
264 |
265 | if self.is_bucket_public:
266 | # noinspection PyProtectedMember
267 | base_url = client._base_url.build("GET", self.__MINIO_REGION).geturl()
268 | return f'{base_url}{self.bucket}/{name}'
269 |
270 | # private bucket
271 | try:
272 | u: str = client.presigned_get_object(
273 | bucket_name=self.bucket,
274 | object_name=name,
275 | expires=get_setting("MINIO_URL_EXPIRY_HOURS", datetime.timedelta(days=7)) # Default is 7 days
276 | )
277 | return u
278 | except urllib3.exceptions.MaxRetryError:
279 | raise ConnectionError("Couldn't connect to Minio. Check django_minio_backend parameters in Django-Settings")
280 |
281 | def path(self, name):
282 | """The MinIO storage system doesn't support absolute paths"""
283 | raise NotImplementedError("The MinIO storage system doesn't support absolute paths.")
284 |
285 | def get_accessed_time(self, name: str) -> datetime:
286 | """
287 | Return the last accessed time (as a datetime) of the file specified by
288 | name. The datetime will be timezone-aware if USE_TZ=True.
289 | """
290 | raise NotImplementedError('MinIO does not store last accessed time')
291 |
292 | def get_created_time(self, name: str) -> datetime:
293 | """
294 | Return the creation time (as a datetime) of the file specified by name.
295 | The datetime will be timezone-aware if USE_TZ=True.
296 | """
297 | raise NotImplementedError('MinIO does not store creation time')
298 |
299 | def get_modified_time(self, name: str) -> datetime:
300 | """
301 | Return the last modified time (as a datetime) of the file specified by
302 | name. The datetime will be timezone-aware if USE_TZ=True.
303 | """
304 | if get_setting("USE_TZ"):
305 | return self.stat(name).last_modified
306 | return self.stat(name).last_modified.replace(tzinfo=None) # remove timezone info
307 |
308 | @staticmethod
309 | def _guess_content_type(file_path_name: str, content: InMemoryUploadedFile):
310 | if hasattr(content, 'content_type'):
311 | return content.content_type
312 | guess = mimetypes.guess_type(file_path_name)[0]
313 | if guess is None:
314 | return 'application/octet-stream' # default
315 | return guess
316 |
317 | """
318 | MinioBackend
319 | """
320 |
321 | @property
322 | def same_endpoints(self) -> bool:
323 | """
324 | Returns True if (self.__MINIO_ENDPOINT == self.__MINIO_EXTERNAL_ENDPOINT)
325 | """
326 | return self.__SAME_ENDPOINTS
327 |
328 | @property
329 | def bucket(self) -> str:
330 | """Get the configured bucket's [self.bucket] name"""
331 | return self._BUCKET_NAME
332 |
333 | @property
334 | def is_bucket_public(self) -> bool:
335 | """Check if configured bucket [self.bucket] is public"""
336 | return True if self.bucket in self.PUBLIC_BUCKETS else False
337 |
338 | def is_minio_available(self) -> MinioServerStatus:
339 | """Check if configured MinIO server is available"""
340 | if not self.__MINIO_ENDPOINT:
341 | mss = MinioServerStatus(None)
342 | mss.add_message('MINIO_ENDPOINT is not configured in Django settings')
343 | return mss
344 |
345 | with urllib3.PoolManager(cert_reqs=ssl.CERT_REQUIRED, ca_certs=certifi.where()) as http:
346 | try:
347 | r = http.request('GET', f'{self.__BASE_URL}/minio/index.html')
348 | return MinioServerStatus(r)
349 | except urllib3.exceptions.MaxRetryError as e:
350 | mss = MinioServerStatus(None)
351 | mss.add_message(f'Could not open connection to {self.__BASE_URL}/minio/index.html\n'
352 | f'Reason: {e}')
353 | return mss
354 | except Exception as e:
355 | mss = MinioServerStatus(None)
356 | mss.add_message(repr(e))
357 | return mss
358 |
359 | @property
360 | def client(self) -> minio.Minio:
361 | """
362 | Get handle to an (already) instantiated minio.Minio instance. This is the default Client.
363 | If "MINIO_EXTERNAL_ENDPOINT" != MINIO_ENDPOINT, this client is used for internal communication only
364 | """
365 | return self.__CLIENT or self._create_new_client()
366 |
367 | @property
368 | def client_external(self) -> minio.Minio:
369 | """Get handle to an (already) instantiated EXTERNAL minio.Minio instance for generating pre-signed URLs for external access"""
370 | return self.__CLIENT_EXT or self._create_new_client(external=True)
371 |
372 | @property
373 | def base_url(self) -> str:
374 | """Get internal base URL to MinIO"""
375 | return self.__BASE_URL
376 |
377 | @property
378 | def base_url_external(self) -> str:
379 | """Get external base URL to MinIO"""
380 | return self.__BASE_URL_EXTERNAL
381 |
382 | def _create_new_client(self, external: bool = False) -> minio.Minio:
383 | """
384 | Instantiates a new Minio client and assigns it to their respective class variable
385 | :param external: If True, the returned value is self.__CLIENT_EXT instead of self.__CLIENT
386 | """
387 | self.__CLIENT = minio.Minio(
388 | endpoint=self.__MINIO_ENDPOINT,
389 | access_key=self.__MINIO_ACCESS_KEY,
390 | secret_key=self.__MINIO_SECRET_KEY,
391 | secure=self.__MINIO_USE_HTTPS,
392 | http_client=self.HTTP_CLIENT,
393 | region=self.__MINIO_REGION,
394 | )
395 | self.__CLIENT_EXT = minio.Minio(
396 | endpoint=self.__MINIO_EXTERNAL_ENDPOINT,
397 | access_key=self.__MINIO_ACCESS_KEY,
398 | secret_key=self.__MINIO_SECRET_KEY,
399 | secure=self.__MINIO_EXTERNAL_ENDPOINT_USE_HTTPS,
400 | http_client=self.HTTP_CLIENT,
401 | region=self.__MINIO_REGION,
402 | )
403 | return self.__CLIENT_EXT if external else self.__CLIENT
404 |
405 | # MAINTENANCE
406 | def check_bucket_existence(self):
407 | """Check if configured bucket [self.bucket] exists"""
408 | if not self.client.bucket_exists(self.bucket):
409 | self.client.make_bucket(bucket_name=self.bucket)
410 |
411 | def check_bucket_existences(self): # Execute this handler upon starting Django to make sure buckets exist
412 | """Check if all buckets configured in settings.py do exist. If not, create them"""
413 | for bucket in [*self.PUBLIC_BUCKETS, *self.PRIVATE_BUCKETS]:
414 | if not self.client.bucket_exists(bucket):
415 | self.client.make_bucket(bucket_name=bucket)
416 |
417 | def set_bucket_policy(self, bucket: str, policy: dict):
418 | """Set a custom bucket policy"""
419 | self.client.set_bucket_policy(bucket_name=bucket, policy=json.dumps(policy))
420 |
421 | def set_bucket_to_public(self):
422 | """Set bucket policy to be public. It can be then accessed via public URLs"""
423 | policy_public_read_only = {"Version": "2012-10-17",
424 | "Statement": [
425 | {
426 | "Sid": "",
427 | "Effect": "Allow",
428 | "Principal": {"AWS": "*"},
429 | "Action": "s3:GetBucketLocation",
430 | "Resource": f"arn:aws:s3:::{self.bucket}"
431 | },
432 | {
433 | "Sid": "",
434 | "Effect": "Allow",
435 | "Principal": {"AWS": "*"},
436 | "Action": "s3:ListBucket",
437 | "Resource": f"arn:aws:s3:::{self.bucket}"
438 | },
439 | {
440 | "Sid": "",
441 | "Effect": "Allow",
442 | "Principal": {"AWS": "*"},
443 | "Action": "s3:GetObject",
444 | "Resource": f"arn:aws:s3:::{self.bucket}/*"
445 | }
446 | ]}
447 | self.set_bucket_policy(self.bucket, policy_public_read_only)
448 |
449 | def validate_settings(self):
450 | """
451 | validate_settings raises a ConfigurationError exception when one of the following conditions is met:
452 | * Neither MINIO_PRIVATE_BUCKETS nor MINIO_PUBLIC_BUCKETS have been declared and configured with at least 1 bucket
453 | * A mandatory parameter (MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY or MINIO_USE_HTTPS) hasn't been declared and configured properly
454 | """
455 | # minimum 1 bucket has to be declared
456 | if not (get_setting("MINIO_PRIVATE_BUCKETS") or get_setting("MINIO_PUBLIC_BUCKETS")):
457 | raise ConfigurationError(
458 | 'Either '
459 | 'MINIO_PRIVATE_BUCKETS'
460 | ' or '
461 | 'MINIO_PUBLIC_BUCKETS '
462 | 'must be configured in your settings.py (can be both)'
463 | )
464 | # mandatory parameters must be configured
465 | mandatory_parameters = (self.__MINIO_ENDPOINT, self.__MINIO_ACCESS_KEY, self.__MINIO_SECRET_KEY)
466 | if any([bool(x) is False for x in mandatory_parameters]) or (get_setting("MINIO_USE_HTTPS") is None):
467 | raise ConfigurationError(
468 | "A mandatory parameter (MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY or MINIO_USE_HTTPS) hasn't been configured properly"
469 | )
470 |
471 |
472 | @deconstructible
473 | class MinioBackendStatic(MinioBackend):
474 | """
475 | MinIO-compatible Django custom storage system for Django static files.
476 | The used bucket can be configured in settings.py through `STORAGES.staticfiles.BACKEND`
477 | :arg *args: Should not be used for static files. It's here for compatibility only
478 | :arg **kwargs: Should not be used for static files. It's here for compatibility only
479 | """
480 | def __init__(self, *args, **kwargs):
481 | super().__init__(self.MINIO_STATIC_FILES_BUCKET, *args, **kwargs)
482 | self.check_bucket_existence() # make sure the `MINIO_STATIC_FILES_BUCKET` exists
483 | self.set_bucket_to_public() # the static files bucket must be publicly available
484 |
485 | def path(self, name):
486 | """The MinIO storage system doesn't support absolute paths"""
487 | raise NotImplementedError("The MinIO storage system doesn't support absolute paths.")
488 |
489 | def get_accessed_time(self, name: str):
490 | """MinIO does not store last accessed time"""
491 | raise NotImplementedError('MinIO does not store last accessed time')
492 |
493 | def get_created_time(self, name: str):
494 | """MinIO does not store creation time"""
495 | raise NotImplementedError('MinIO does not store creation time')
496 |
--------------------------------------------------------------------------------
/django_minio_backend/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/django_minio_backend/utils.py:
--------------------------------------------------------------------------------
1 | # noinspection PyPackageRequirements minIO_requirement
2 | import urllib3
3 | from typing import Union, List
4 | from django.conf import settings
5 |
6 |
7 | __all__ = ['MinioServerStatus', 'PrivatePublicMixedError', 'ConfigurationError', 'get_setting', ]
8 |
9 |
10 | class MinioServerStatus:
11 | """
12 | MinioServerStatus is a simple status info wrapper for checking the availability of a remote MinIO server.
13 | MinioBackend.is_minio_available() returns a MinioServerStatus instance
14 |
15 | MinioServerStatus can be evaluated with the bool() method:
16 | ```
17 | minio_available = MinioBackend.is_minio_available()
18 | if bool(minio_available): # bool() can be omitted
19 | print("OK")
20 | ```
21 | """
22 | def __init__(self, request: Union[urllib3.response.BaseHTTPResponse, None]):
23 | self._request = request
24 | self._bool = False
25 | self._details: List[str] = []
26 | self.status = None
27 | self.data = None
28 |
29 | self.__OK = 'MinIO is available'
30 | self.___NOK = 'MinIO is NOT available'
31 |
32 | if not self._request:
33 | self.add_message('There was no HTTP request provided for MinioServerStatus upon initialisation.')
34 | else:
35 | self.status = self._request.status
36 | self.data = self._request.data.decode() if self._request.data else 'No data available'
37 | if self.status == 403: # Request was a legal, but the server refuses to respond to it -> it's running fine
38 | self._bool = True
39 | else:
40 | self._details.append(self.__OK)
41 | self._details.append('Reason: ' + self.data)
42 |
43 | def __bool__(self):
44 | return self._bool
45 |
46 | def add_message(self, text: str):
47 | self._details.append(text)
48 |
49 | @property
50 | def is_available(self):
51 | return self._bool
52 |
53 | @property
54 | def details(self):
55 | return '\n'.join(self._details)
56 |
57 | def __repr__(self):
58 | if self.is_available:
59 | return self.__OK
60 | return self.___NOK
61 |
62 |
63 | class PrivatePublicMixedError(Exception):
64 | """Raised on public|private bucket configuration collisions"""
65 | pass
66 |
67 |
68 | class ConfigurationError(Exception):
69 | """Raised on django-minio-backend configuration errors"""
70 | pass
71 |
72 |
73 | def get_setting(name, default=None):
74 | """Get setting from settings.py. Return a default value if not defined"""
75 | return getattr(settings, name, default)
76 |
--------------------------------------------------------------------------------
/docker-compose.develop.yml:
--------------------------------------------------------------------------------
1 | # ORIGINAL SOURCE
2 | # https://docs.min.io/docs/deploy-minio-on-docker-compose.html
3 | # https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/docker-compose.yaml?raw=true
4 |
5 | # IN THIS CONFIGURATION, THE PROJECT FILES ARE VOLUME MAPPED INTO THE CONTAINER FROM THE HOST
6 |
7 | version: "3.9"
8 |
9 | # Settings and configurations that are common for all containers
10 | x-minio-common: &minio-common
11 | image: minio/minio:RELEASE.2021-07-30T00-02-00Z
12 | command: server --console-address ":9001" http://minio{1...4}/data{1...2}
13 | expose:
14 | - "9000"
15 | - "9001"
16 | environment:
17 | MINIO_ROOT_USER: minio
18 | MINIO_ROOT_PASSWORD: minio123
19 | healthcheck:
20 | test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
21 | interval: 30s
22 | timeout: 20s
23 | retries: 3
24 |
25 | services:
26 | # starts Django from DjangoExampleProject + DjangoExampleApplication
27 | web:
28 | image: python:3
29 | command: bash -c "
30 | pip install -r /code/requirements.txt
31 | && python manage.py migrate
32 | && python manage.py runserver 0.0.0.0:8000
33 | "
34 | volumes:
35 | - .:/code
36 | working_dir: /code
37 | environment:
38 | PYTHONUNBUFFERED: "1"
39 | GH_MINIO_ENDPOINT: "nginx:9000"
40 | GH_MINIO_USE_HTTPS: "false"
41 | GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000"
42 | GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false"
43 | GH_MINIO_ACCESS_KEY: "minio"
44 | GH_MINIO_SECRET_KEY: "minio123"
45 | # CREATE AN ADMIN ACCOUNT FOR INTERNAL DEMO PURPOSES ONLY!
46 | DJANGO_SUPERUSER_USERNAME: "admin"
47 | DJANGO_SUPERUSER_PASSWORD: "123123"
48 | DJANGO_SUPERUSER_EMAIL: "admin@local.test"
49 | ports:
50 | - "8000:8000"
51 | depends_on:
52 | - nginx
53 | # starts 4 docker containers running minio server instances.
54 | # using nginx reverse proxy, load balancing, you can access
55 | # it through port 9000.
56 | minio1:
57 | <<: *minio-common
58 | hostname: minio1
59 | volumes:
60 | - data1-1:/data1
61 | - data1-2:/data2
62 |
63 | minio2:
64 | <<: *minio-common
65 | hostname: minio2
66 | volumes:
67 | - data2-1:/data1
68 | - data2-2:/data2
69 |
70 | minio3:
71 | <<: *minio-common
72 | hostname: minio3
73 | volumes:
74 | - data3-1:/data1
75 | - data3-2:/data2
76 |
77 | minio4:
78 | <<: *minio-common
79 | hostname: minio4
80 | volumes:
81 | - data4-1:/data1
82 | - data4-2:/data2
83 |
84 | nginx:
85 | image: nginx:1.19.2-alpine
86 | hostname: nginx
87 | volumes:
88 | - ./nginx.conf:/etc/nginx/nginx.conf:ro
89 | ports:
90 | - "9000:9000"
91 | - "9001:9001"
92 | depends_on:
93 | - minio1
94 | - minio2
95 | - minio3
96 | - minio4
97 |
98 | ## By default this config uses default local driver,
99 | ## For custom volumes replace with volume driver configuration.
100 | volumes:
101 | data1-1:
102 | data1-2:
103 | data2-1:
104 | data2-2:
105 | data3-1:
106 | data3-2:
107 | data4-1:
108 | data4-2:
109 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # ORIGINAL SOURCE
2 | # https://docs.min.io/docs/deploy-minio-on-docker-compose.html
3 | # https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/docker-compose.yaml?raw=true
4 |
5 | # IN THIS CONFIGURATION, THE PROJECT FILES ARE COPIED INTO THE CONTAINER
6 |
7 | version: "3.9"
8 |
9 | # Settings and configurations that are common for all containers
10 | x-minio-common: &minio-common
11 | image: minio/minio:RELEASE.2021-07-30T00-02-00Z
12 | command: server --console-address ":9001" http://minio{1...4}/data{1...2}
13 | expose:
14 | - "9000"
15 | - "9001"
16 | environment:
17 | MINIO_ROOT_USER: minio
18 | MINIO_ROOT_PASSWORD: minio123
19 | healthcheck:
20 | test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
21 | interval: 30s
22 | timeout: 20s
23 | retries: 3
24 |
25 | services:
26 | # starts Django from DjangoExampleProject + DjangoExampleApplication
27 | web:
28 | build: .
29 | command: bash -c "
30 | python manage.py migrate
31 | && python manage.py runserver 0.0.0.0:8000
32 | "
33 | environment:
34 | GH_MINIO_ENDPOINT: "nginx:9000"
35 | GH_MINIO_USE_HTTPS: "false"
36 | GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000"
37 | GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false"
38 | GH_MINIO_ACCESS_KEY: "minio"
39 | GH_MINIO_SECRET_KEY: "minio123"
40 | # CREATE AN ADMIN ACCOUNT FOR INTERNAL DEMO PURPOSES ONLY!
41 | DJANGO_SUPERUSER_USERNAME: "admin"
42 | DJANGO_SUPERUSER_PASSWORD: "123123"
43 | DJANGO_SUPERUSER_EMAIL: "admin@local.test"
44 | ports:
45 | - "8000:8000"
46 | depends_on:
47 | - nginx
48 | # starts 4 docker containers running minio server instances.
49 | # using nginx reverse proxy, load balancing, you can access
50 | # it through port 9000.
51 | minio1:
52 | <<: *minio-common
53 | hostname: minio1
54 | volumes:
55 | - data1-1:/data1
56 | - data1-2:/data2
57 |
58 | minio2:
59 | <<: *minio-common
60 | hostname: minio2
61 | volumes:
62 | - data2-1:/data1
63 | - data2-2:/data2
64 |
65 | minio3:
66 | <<: *minio-common
67 | hostname: minio3
68 | volumes:
69 | - data3-1:/data1
70 | - data3-2:/data2
71 |
72 | minio4:
73 | <<: *minio-common
74 | hostname: minio4
75 | volumes:
76 | - data4-1:/data1
77 | - data4-2:/data2
78 |
79 | nginx:
80 | image: nginx:1.19.2-alpine
81 | hostname: nginx
82 | volumes:
83 | - ./nginx.conf:/etc/nginx/nginx.conf:ro
84 | ports:
85 | - "9000:9000"
86 | - "9001:9001"
87 | depends_on:
88 | - minio1
89 | - minio2
90 | - minio3
91 | - minio4
92 |
93 | ## By default this config uses default local driver,
94 | ## For custom volumes replace with volume driver configuration.
95 | volumes:
96 | data1-1:
97 | data1-2:
98 | data2-1:
99 | data2-2:
100 | data3-1:
101 | data3-2:
102 | data4-1:
103 | data4-2:
104 |
--------------------------------------------------------------------------------
/examples/policy_hook.example.py:
--------------------------------------------------------------------------------
1 | from typing import List, Tuple
2 |
3 |
4 | bucket_name: str = 'my-very-public-bucket'
5 |
6 |
7 | # policy sets the appropriate bucket as world readable (no write)
8 | # See the following good summary of Bucket Policies
9 | # https://gist.github.com/krishnasrinivas/2f5a9affe6be6aff42fe723f02c86d6a
10 | policy = {"Version": "2012-10-17",
11 | "Statement": [
12 | {
13 | "Sid": "",
14 | "Effect": "Allow",
15 | "Principal": {"AWS": "*"},
16 | "Action": "s3:GetBucketLocation",
17 | "Resource": f"arn:aws:s3:::{bucket_name}"
18 | },
19 | {
20 | "Sid": "",
21 | "Effect": "Allow",
22 | "Principal": {"AWS": "*"},
23 | "Action": "s3:ListBucket",
24 | "Resource": f"arn:aws:s3:::{bucket_name}"
25 | },
26 | {
27 | "Sid": "",
28 | "Effect": "Allow",
29 | "Principal": {"AWS": "*"},
30 | "Action": "s3:GetObject",
31 | "Resource": f"arn:aws:s3:::{bucket_name}/*"
32 | }
33 | ]}
34 |
35 | MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [ # This array of (bucket_name, policy) tuples belong to Django settings
36 | (bucket_name, policy),
37 | ]
38 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings')
9 | try:
10 | from django.core.management import execute_from_command_line
11 | except ImportError as exc:
12 | raise ImportError(
13 | "Couldn't import Django. Are you sure it's installed and "
14 | "available on your PYTHONPATH environment variable? Did you "
15 | "forget to activate a virtual environment?"
16 | ) from exc
17 | execute_from_command_line(sys.argv)
18 |
19 |
20 | if __name__ == '__main__':
21 | main()
22 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | # ORIGINAL SOURCE
2 | # https://docs.min.io/docs/deploy-minio-on-docker-compose.html
3 | # https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/nginx.conf?raw=true
4 |
5 | user nginx;
6 | worker_processes auto;
7 |
8 | error_log /var/log/nginx/error.log warn;
9 | pid /var/run/nginx.pid;
10 |
11 | events {
12 | worker_connections 4096;
13 | }
14 |
15 | http {
16 | include /etc/nginx/mime.types;
17 | default_type application/octet-stream;
18 |
19 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
20 | '$status $body_bytes_sent "$http_referer" '
21 | '"$http_user_agent" "$http_x_forwarded_for"';
22 |
23 | access_log /var/log/nginx/access.log main;
24 | sendfile on;
25 | keepalive_timeout 65;
26 |
27 | # include /etc/nginx/conf.d/*.conf;
28 |
29 | upstream minio {
30 | server minio1:9000;
31 | server minio2:9000;
32 | server minio3:9000;
33 | server minio4:9000;
34 | }
35 |
36 | upstream console {
37 | ip_hash;
38 | server minio1:9001;
39 | server minio2:9001;
40 | server minio3:9001;
41 | server minio4:9001;
42 | }
43 |
44 | server {
45 | listen 9000;
46 | listen [::]:9000;
47 | server_name localhost;
48 |
49 | # To allow special characters in headers
50 | ignore_invalid_headers off;
51 | # Allow any size file to be uploaded.
52 | # Set to a value such as 1000m; to restrict file size to a specific value
53 | client_max_body_size 0;
54 | # To disable buffering
55 | proxy_buffering off;
56 |
57 | location / {
58 | proxy_set_header Host $http_host;
59 | proxy_set_header X-Real-IP $remote_addr;
60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
61 | proxy_set_header X-Forwarded-Proto $scheme;
62 |
63 | proxy_connect_timeout 300;
64 | # Default is HTTP/1, keepalive is only enabled in HTTP/1.1
65 | proxy_http_version 1.1;
66 | proxy_set_header Connection "";
67 | chunked_transfer_encoding off;
68 |
69 | proxy_pass http://minio;
70 | }
71 | }
72 |
73 | server {
74 | listen 9001;
75 | listen [::]:9001;
76 | server_name localhost;
77 |
78 | # To allow special characters in headers
79 | ignore_invalid_headers off;
80 | # Allow any size file to be uploaded.
81 | # Set to a value such as 1000m; to restrict file size to a specific value
82 | client_max_body_size 0;
83 | # To disable buffering
84 | proxy_buffering off;
85 |
86 | location / {
87 | proxy_set_header Host $http_host;
88 | proxy_set_header X-Real-IP $remote_addr;
89 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
90 | proxy_set_header X-Forwarded-Proto $scheme;
91 | proxy_set_header X-NginX-Proxy true;
92 |
93 | # This is necessary to pass the correct IP to be hashed
94 | real_ip_header X-Real-IP;
95 |
96 | proxy_connect_timeout 300;
97 | # Default is HTTP/1, keepalive is only enabled in HTTP/1.1
98 | proxy_http_version 1.1;
99 | proxy_set_header Connection "";
100 | chunked_transfer_encoding off;
101 |
102 | proxy_pass http://console;
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Django>=3.2
2 | minio>=7.2.8
3 | Pillow
4 | setuptools
5 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | from datetime import datetime
3 | from setuptools import find_packages, setup
4 |
5 | from version import get_git_version
6 |
7 | with open("README.md", "r") as readme_file:
8 | long_description = readme_file.read()
9 |
10 | # allow setup.py to be run from any path
11 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
12 |
13 |
14 | setup(
15 | name='django-minio-backend',
16 | version=get_git_version(),
17 | packages=find_packages(),
18 | include_package_data=True,
19 | license=f'MIT License | Copyright (c) {datetime.now().year} Kristof Daja',
20 | description='The django-minio-backend provides a wrapper around the MinIO Python Library.',
21 | long_description=long_description,
22 | long_description_content_type="text/markdown",
23 | url='https://github.com/theriverman/django-minio-backend',
24 | author='Kristof Daja (theriverman)',
25 | author_email='kristof@daja.hu',
26 | install_requires=[
27 | 'Django>=4.2',
28 | 'minio>=7.2.8'
29 | ],
30 | classifiers=[
31 | 'Environment :: Web Environment',
32 | 'Framework :: Django',
33 | 'Framework :: Django :: 4.2',
34 | 'Framework :: Django :: 5.0',
35 | 'Framework :: Django :: 5.1',
36 | 'Intended Audience :: Developers',
37 | 'License :: OSI Approved :: MIT License',
38 | 'Operating System :: OS Independent',
39 | 'Programming Language :: Python',
40 | 'Programming Language :: Python :: 3.11',
41 | 'Programming Language :: Python :: 3.12',
42 | 'Programming Language :: Python :: 3.13',
43 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
44 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System',
45 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application',
46 | ],
47 | )
48 |
--------------------------------------------------------------------------------
/version.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Author: Douglas Creager
3 | # Modifier: Kristof Daja
4 | # This file is placed into the public domain.
5 |
6 | # Calculates the current version number. If possible, this is the
7 | # output of “git describe”, modified to conform to the versioning
8 | # scheme that setuptools uses. If “git describe” returns an error
9 | # (most likely because we're in an unpacked copy of a release tarball,
10 | # rather than in a git working copy), then we fall back on reading the
11 | # contents of the RELEASE-VERSION file.
12 | #
13 | # To use this script, simply import it your setup.py file, and use the
14 | # results of get_git_version() as your package version:
15 | #
16 | # from version import *
17 | #
18 | # setup(
19 | # version=get_git_version(),
20 | # .
21 | # .
22 | # .
23 | # )
24 | #
25 | #
26 | # This will automatically update the RELEASE-VERSION file, if
27 | # necessary. Note that the RELEASE-VERSION file should *not* be
28 | # checked into git; please add it to your top-level .gitignore file.
29 | #
30 | # You'll probably want to distribute the RELEASE-VERSION file in your
31 | # sdist tarballs; to do this, just create a MANIFEST.in file that
32 | # contains the following line:
33 | #
34 | # include RELEASE-VERSION
35 | #
36 | # Change History:
37 | # 2020-12-12 - Updated for Python 3. Changed git describe --abbrev=7 to git describe --tags
38 | #
39 |
40 | __all__ = ["get_git_version"]
41 |
42 | from subprocess import Popen, PIPE
43 |
44 |
45 | def call_git_describe():
46 | # noinspection PyBroadException
47 | try:
48 | p = Popen(['git', 'describe', '--tags'],
49 | stdout=PIPE, stderr=PIPE)
50 | p.stderr.close()
51 | line = p.stdout.readlines()[0]
52 | return line.strip().decode('utf-8')
53 |
54 | except Exception:
55 | return None
56 |
57 |
58 | def is_dirty():
59 | # noinspection PyBroadException
60 | try:
61 | p = Popen(["git", "diff-index", "--name-only", "HEAD"],
62 | stdout=PIPE, stderr=PIPE)
63 | p.stderr.close()
64 | lines = p.stdout.readlines()
65 | return len(lines) > 0
66 | except Exception:
67 | return False
68 |
69 |
70 | def read_release_version():
71 | # noinspection PyBroadException
72 | try:
73 | f = open("RELEASE-VERSION", "r")
74 |
75 | try:
76 | version = f.readlines()[0]
77 | return version.strip()
78 |
79 | finally:
80 | f.close()
81 |
82 | except Exception:
83 | return None
84 |
85 |
86 | def write_release_version(version):
87 | f = open("RELEASE-VERSION", "w")
88 | f.write("%s\n" % version)
89 | f.close()
90 |
91 |
92 | def get_git_version():
93 | # Read in the version that's currently in RELEASE-VERSION.
94 | release_version = read_release_version()
95 |
96 | # First try to get the current version using “git describe”.
97 | version = call_git_describe()
98 | if is_dirty():
99 | version += "-dirty"
100 |
101 | # If that doesn't work, fall back on the value that's in
102 | # RELEASE-VERSION.
103 | if version is None:
104 | version = release_version
105 |
106 | # If we still don't have anything, that's an error.
107 | if version is None:
108 | raise ValueError("Cannot find the version number!")
109 |
110 | # If the current version is different from what's in the
111 | # RELEASE-VERSION file, update the file to be current.
112 | if version != release_version:
113 | write_release_version(version)
114 |
115 | # Finally, return the current version.
116 | return version
117 |
118 |
119 | if __name__ == "__main__":
120 | print(get_git_version())
121 |
--------------------------------------------------------------------------------