Commit 9f2bd906 authored by Nguyễn Văn Vũ's avatar Nguyễn Văn Vũ

Week5 - Application Django Minio Backend with Docker

parent c6b4d60e
Contributing to Django Minio Backend
------------------------------
You can find a reference implementation of a Django app using **django-minio-backend** as a storage backend in
[DjangoExampleApplication/models.py](DjangoExampleApplication/models.py).
When you're finished with your changes, please open a pull request!
# Development Environment
Execute the following steps to prepare your development environment:
1. Clone the library:
```bash
git clone https://github.com/theriverman/django-minio-backend.git
cd django-minio-backend
```
1. Create a virtual environment and activate it:
```bash
python3 -m venv .venv
source .venv/bin/activate
```
1. Install Python Dependencies:
```bash
pip install -r requirements.txt
```
1. Execute Django Migrations:
```bash
python manage.py migrate
```
1. Create Admin Account (optional):
```bash
python manage.py createsuperuser
```
1. Run the Project:
```bash
python manage.py runserver
```
# Testing
You can run tests by executing the following command (in the repository root):
```bash
python manage.py test
```
**Note:** Tests are quite poor at the moment.
from typing import Union
from django.db.models.query import QuerySet
from django.contrib import admin
from django.core.handlers.wsgi import WSGIRequest
from .models import PublicAttachment, PrivateAttachment, Image, GenericAttachment
# https://docs.djangoproject.com/en/2.2/ref/contrib/admin/actions/#writing-action-functions
def delete_everywhere(model_admin: Union[PublicAttachment, PrivateAttachment],
request: WSGIRequest,
queryset: QuerySet):
"""
Delete object both in Django and in MinIO too.
:param model_admin: unused
:param request: unused
:param queryset: A QuerySet containing the set of objects selected by the user
:return:
"""
del model_admin, request # We don't need these
for obj in queryset:
obj.delete()
delete_everywhere.short_description = "Delete selected objects in Django and MinIO"
@admin.register(Image)
class ImageAdmin(admin.ModelAdmin):
list_display = ('id', 'image',)
readonly_fields = ('id', )
model = Image
actions = [delete_everywhere, ]
@admin.register(GenericAttachment)
class GenericAttachmentAdmin(admin.ModelAdmin):
list_display = ('id', 'file',)
readonly_fields = ('id', )
model = GenericAttachment
actions = [delete_everywhere, ]
# Register your models here.
@admin.register(PublicAttachment)
class PublicAttachmentAdmin(admin.ModelAdmin):
list_display = ('id', 'content_type',)
readonly_fields = ('id', 'content_object', 'file_name', 'file_size', )
model = PublicAttachment
actions = [delete_everywhere, ]
fieldsets = [
('General Information',
{'fields': ('id',)}),
('S3 Object',
{'fields': ('file_name', 'file_size', 'file',)}),
('S3 Object Details',
{'fields': ('content_object', 'content_type', 'object_id',)}),
]
def get_actions(self, request):
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
@admin.register(PrivateAttachment)
class PrivateAttachmentAdmin(admin.ModelAdmin):
list_display = ('id', 'content_type',)
readonly_fields = ('id', 'content_object', 'file_name', 'file_size')
model = PrivateAttachment
actions = [delete_everywhere, ]
fieldsets = [
('General Information',
{'fields': ('id',)}),
('S3 Object',
{'fields': ('file_name', 'file_size', 'file',)}),
('S3 Object Details',
{'fields': ('content_object', 'content_type', 'object_id',)}),
]
def get_actions(self, request):
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
from django.apps import AppConfig
class DjangoExampleApplicationConfig(AppConfig):
name = 'DjangoExampleApplication'
https://pixabay.com/photos/audience-concert-music-868074/
Simplified Pixabay License
https://pixabay.com/service/license/
Free for commercial use
No attribution required
# Generated by Django 3.1.3 on 2020-11-15 20:23
import DjangoExampleApplication.models
from django.db import migrations, models
import django.db.models.deletion
import django_minio_backend.models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Image',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('image', models.ImageField(storage=django_minio_backend.models.MinioBackend(bucket_name='django-backend-dev-public'), upload_to=django_minio_backend.models.iso_date_prefix)),
],
),
migrations.CreateModel(
name='PublicAttachment',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, verbose_name='Public Attachment ID')),
('object_id', models.PositiveIntegerField(verbose_name="Related Object's ID")),
('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')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')),
],
),
migrations.CreateModel(
name='PrivateAttachment',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, verbose_name='Public Attachment ID')),
('object_id', models.PositiveIntegerField(verbose_name="Related Object's ID")),
('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')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')),
],
),
]
# Generated by Django 3.1.3 on 2021-03-13 10:49
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('DjangoExampleApplication', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='privateattachment',
name='content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'),
),
migrations.AlterField(
model_name='privateattachment',
name='object_id',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Related Object's ID"),
),
migrations.AlterField(
model_name='publicattachment',
name='content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'),
),
migrations.AlterField(
model_name='publicattachment',
name='object_id',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Related Object's ID"),
),
]
# Generated by Django 3.2.3 on 2021-07-18 22:07
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('DjangoExampleApplication', '0002_auto_20210313_1049'),
]
operations = [
migrations.CreateModel(
name='GenericAttachment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='', verbose_name='Object Upload (to default storage)')),
],
),
]
import uuid
import datetime
from django.db import models
from django.db.models.fields.files import FieldFile
from django.utils.timezone import utc
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django_minio_backend import MinioBackend, iso_date_prefix
def get_iso_date() -> str:
now = datetime.datetime.utcnow().replace(tzinfo=utc)
return f"{now.year}-{now.month}-{now.day}"
class Image(models.Model):
"""
This is just for uploaded image
"""
objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
image = models.ImageField(upload_to=iso_date_prefix, storage=MinioBackend(bucket_name='django-backend-dev-public'))
def delete(self, *args, **kwargs):
"""
Delete must be overridden because the inherited delete method does not call `self.image.delete()`.
"""
# noinspection PyUnresolvedReferences
self.image.delete()
super(Image, self).delete(*args, **kwargs)
class GenericAttachment(models.Model):
"""
This is for demonstrating uploads to the default file storage
"""
objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
file = models.FileField(verbose_name="Object Upload (to default storage)")
def delete(self, *args, **kwargs):
"""
Delete must be overridden because the inherited delete method does not call `self.image.delete()`.
"""
# noinspection PyUnresolvedReferences
self.file.delete()
super(GenericAttachment, self).delete(*args, **kwargs)
# Create your models here.
class PublicAttachment(models.Model):
def set_file_path_name(self, file_name_ext: str) -> str:
"""
Defines the full absolute path to the file in the bucket. The original content's type is used as parent folder.
:param file_name_ext: (str) File name + extension. ie.: cat.png OR images/animals/2019/cat.png
:return: (str) Absolute path to file in Minio Bucket
"""
return f"{get_iso_date()}/{self.content_type.name}/{file_name_ext}"
def delete(self, *args, **kwargs):
"""
Delete must be overridden because the inherited delete method does not call `self.file.delete()`.
"""
self.file.delete()
super(PublicAttachment, self).delete(*args, **kwargs)
@property
def file_name(self):
try:
return self.file.name.split("/")[-1]
except AttributeError:
return "[Deleted Object]"
@property
def file_size(self):
return self.file.size
def __str__(self):
return str(self.file)
id = models.AutoField(primary_key=True, verbose_name="Public Attachment ID")
content_type: ContentType = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE,
verbose_name="Content Type")
object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Related Object's ID")
content_object = GenericForeignKey("content_type", "object_id")
file: FieldFile = models.FileField(verbose_name="Object Upload",
storage=MinioBackend( # Configure MinioBackend as storage backend here
bucket_name='django-backend-dev-public',
),
upload_to=iso_date_prefix)
class PrivateAttachment(models.Model):
def set_file_path_name(self, file_name_ext: str) -> str:
"""
Defines the full absolute path to the file in the bucket. The original content's type is used as parent folder.
:param file_name_ext: (str) File name + extension. ie.: cat.png OR images/animals/2019/cat.png
:return: (str) Absolute path to file in Minio Bucket
"""
return f"{get_iso_date()}/{self.content_type.name}/{file_name_ext}"
def delete(self, *args, **kwargs):
"""
Delete must be overridden because the inherited delete method does not call `self.file.delete()`.
"""
self.file.delete()
super(PrivateAttachment, self).delete(*args, **kwargs)
@property
def file_name(self):
try:
return self.file.name.split("/")[-1]
except AttributeError:
return "[Deleted Object]"
@property
def file_size(self):
return self.file.size
def __str__(self):
return str(self.file)
id = models.AutoField(primary_key=True, verbose_name="Public Attachment ID")
content_type: ContentType = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE,
verbose_name="Content Type")
object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Related Object's ID")
content_object = GenericForeignKey("content_type", "object_id")
file: FieldFile = models.FileField(verbose_name="Object Upload",
storage=MinioBackend( # Configure MinioBackend as storage backend here
bucket_name='django-backend-dev-private',
),
upload_to=set_file_path_name)
import time
from pathlib import Path
from django.conf import settings
from django.core.files import File
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.core.validators import URLValidator
from DjangoExampleApplication.models import Image, PublicAttachment, PrivateAttachment
test_file_path = Path(settings.BASE_DIR) / "DjangoExampleApplication" / "assets" / "audience-868074_1920.jpg"
test_file_size = 339085
class ImageTestCase(TestCase):
obj: Image = None
def setUp(self):
# Open a test file from disk and upload to minIO as an image
with open(test_file_path, 'rb') as f:
self.obj = Image.objects.create()
self.obj.image.save(name='audience-868074_1920.jpg', content=f)
def tearDown(self):
# Remove uploaded file from minIO and remove the Image entry from Django's database
self.obj.delete() # deletes from both locations
def test_url_generation_works(self):
"""Accessing the value of obj.image.url"""
val = URLValidator()
val(self.obj.image.url) # 1st make sure it's an URL
self.assertTrue('audience-868074_1920' in self.obj.image.url) # 2nd make sure our filename matches
def test_read_image_size(self):
self.assertEqual(self.obj.image.size, test_file_size)
class PublicAttachmentTestCase(TestCase):
obj: PublicAttachment = None
filename = f'public_audience-868074_1920_{int(time.time())}.jpg' # adding unix time makes our filename unique
def setUp(self):
ct = ContentType.objects.get(app_label='auth', model='user') # PublicAttachment is generic so this is needed
with open(test_file_path, 'rb') as f:
# noinspection PyUnresolvedReferences
self.obj = PublicAttachment.objects.create()
self.obj.ct = ct
self.obj.object_id = 1 # we associate this uploaded file to user with pk=1
self.obj.file.save(name=self.filename, content=File(f), save=True)
def test_url_generation_works(self):
"""Accessing the value of obj.file.url"""
val = URLValidator()
val(self.obj.file.url) # 1st make sure it's an URL
self.assertTrue('public_audience-868074_1920' in self.obj.file.url) # 2nd make sure our filename matches
def test_read_file_size(self):
self.assertEqual(self.obj.file_size, test_file_size)
def test_read_file_name(self):
self.assertEqual(self.obj.file_name, self.filename)
class PrivateAttachmentTestCase(TestCase):
obj: PrivateAttachment = None
filename = f'private_audience-868074_1920_{int(time.time())}.jpg' # adding unix time makes our filename unique
def setUp(self):
ct = ContentType.objects.get(app_label='auth', model='user') # PublicAttachment is generic so this is needed
with open(test_file_path, 'rb') as f:
# noinspection PyUnresolvedReferences
self.obj = PublicAttachment.objects.create()
self.obj.ct = ct
self.obj.object_id = 1 # we associate this uploaded file to user with pk=1
self.obj.file.save(name=self.filename, content=File(f), save=True)
def test_url_generation_works(self):
"""Accessing the value of obj.file.url"""
val = URLValidator()
val(self.obj.file.url) # 1st make sure it's an URL
self.assertTrue('private_audience-868074_1920' in self.obj.file.url) # 2nd make sure our filename matches
def test_read_file_size(self):
self.assertEqual(self.obj.file_size, test_file_size)
def test_read_file_name(self):
self.assertEqual(self.obj.file_name, self.filename)
"""
ASGI config for DjangoExampleProject project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings')
application = get_asgi_application()
"""
Django settings for DjangoExampleProject project.
Generated by 'django-admin startproject' using Django 3.0.6.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.0/ref/settings/
"""
import os
import distutils.util
from datetime import timedelta
from typing import List, Tuple
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'sp1d7_7z9))q58(6k&1)9m_@!8e420*m+3dasq-*711fu8)y!6'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_minio_backend.apps.DjangoMinioBackendConfig', # Driver
'DjangoExampleApplication', # Test App
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'DjangoExampleProject.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'DjangoExampleProject.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_STORAGE = 'django_minio_backend.models.MinioBackendStatic'
DEFAULT_FILE_STORAGE = 'django_minio_backend.models.MinioBackend'
# #################### #
# django_minio_backend #
# #################### #
dummy_policy = {"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetBucketLocation",
"Resource": f"arn:aws:s3:::django-backend-dev-private"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:ListBucket",
"Resource": f"arn:aws:s3:::django-backend-dev-private"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetObject",
"Resource": f"arn:aws:s3:::django-backend-dev-private/*"
}
]}
MINIO_ENDPOINT = os.getenv("GH_MINIO_ENDPOINT", "play.min.io")
MINIO_EXTERNAL_ENDPOINT = os.getenv("GH_MINIO_EXTERNAL_ENDPOINT", "externalplay.min.io")
MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = bool(distutils.util.strtobool(os.getenv("GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", "true")))
MINIO_ACCESS_KEY = os.getenv("GH_MINIO_ACCESS_KEY", "Q3AM3UQ867SPQQA43P2F")
MINIO_SECRET_KEY = os.getenv("GH_MINIO_SECRET_KEY", "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG")
MINIO_USE_HTTPS = bool(distutils.util.strtobool(os.getenv("GH_MINIO_USE_HTTPS", "true")))
MINIO_REGION = os.getenv("GH_MINIO_REGION", "us-east-1")
MINIO_PRIVATE_BUCKETS = [
'django-backend-dev-private',
'my-media-files-bucket',
]
MINIO_PUBLIC_BUCKETS = [
'django-backend-dev-public',
't5p2g08k31',
'7xi7lx9rjh',
'my-static-files-bucket',
]
MINIO_URL_EXPIRY_HOURS = timedelta(days=1) # Default is 7 days (longest) if not defined
MINIO_CONSISTENCY_CHECK_ON_START = True
MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [
# ('django-backend-dev-private', dummy_policy)
]
MINIO_MEDIA_FILES_BUCKET = 'my-media-files-bucket' # replacement for STATIC_ROOT
MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for MEDIA_ROOT
MINIO_BUCKET_CHECK_ON_SAVE = False # Create bucket if missing, then save
"""DjangoExampleProject URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
urlpatterns = [
path('admin/', admin.site.urls),
]
"""
WSGI config for DjangoExampleProject project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings')
application = get_wsgi_application()
# syntax=docker/dockerfile:1
FROM python:3
ENV PYTHONUNBUFFERED=1
WORKDIR /code
# Copy Demo Project
COPY ./manage.py /code/manage.py
COPY ./django_minio_backend /code/django_minio_backend
COPY ./DjangoExampleProject /code/DjangoExampleProject
COPY ./DjangoExampleApplication /code/DjangoExampleApplication
# Copy and install requirements.txt
COPY requirements.txt /code/
RUN pip install -r /code/requirements.txt
MIT License
Copyright (c) 2019 Kristof
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
include LICENSE
include README*.md
include RELEASE-VERSION
include version.py
# recursive-include docs *
recursive-include django_minio_backend/management *
# Docker Compose Description for django-minio-backend
Execute the following step to start a demo environment using Docker Compose:
**Start the Docker Compose services:**
```shell
docker compose up -d
docker compose exec web python manage.py createsuperuser --noinput
docker compose exec web python manage.py collectstatic --noinput
```
## About docker-compose.yml
Note the following lines in `docker-compose.yml`:
```yaml
environment:
GH_MINIO_ENDPOINT: "nginx:9000"
GH_MINIO_USE_HTTPS: "false"
GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000"
GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false"
```
MinIO is load balanced by nginx, so all connections made from Django towards MinIO happens through the internal `nginx` FQDN. <br>
Therefore, the value of `GH_MINIO_ENDPOINT` is `nginx:9000`.
# Web Access
Both Django(:8000) and MinIO(:9001) expose a Web GUI and their ports are mapped to the host machine.
## Django Admin
Open your browser at http://localhost:8000/admin to access the Django admin portal:
* username: `admin`
* password: `123123`
## MinIO Console
Open your browser at http://localhost:9001 to access the MiniIO Console:
* username: `minio`
* password: `minio123`
# Developer Environment
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.
**Input file**: `docker-compose.develop.yml`
If you would like to develop in a Docker Compose environment, execute the following commands:
```shell
docker compose -f docker-compose.develop.yml up -d
docker compose -f docker-compose.develop.yml exec web python manage.py createsuperuser --noinput
docker compose -f docker-compose.develop.yml exec web python manage.py collectstatic --noinput
```
This diff is collapsed.
Subproject commit 1bd19bedada908fc7888706712b45225ba12b404
from .apps import *
from .models import *
from django.apps import AppConfig
from .utils import get_setting, ConfigurationError
from .models import MinioBackend, MinioBackendStatic
__all__ = ['DjangoMinioBackendConfig', ]
class DjangoMinioBackendConfig(AppConfig):
name = 'django_minio_backend'
def ready(self):
mb = MinioBackend()
mb.validate_settings()
consistency_check_on_start = get_setting('MINIO_CONSISTENCY_CHECK_ON_START', False)
if consistency_check_on_start:
from django.core.management import call_command
print("Executing consistency checks...")
call_command('initialize_buckets', silenced=True)
# Validate configuration combinations for EXTERNAL ENDPOINT
external_address = bool(get_setting('MINIO_EXTERNAL_ENDPOINT'))
external_use_https = get_setting('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS')
if (external_address and external_use_https is None) or (not external_address and external_use_https):
raise ConfigurationError('MINIO_EXTERNAL_ENDPOINT must be configured together with MINIO_EXTERNAL_ENDPOINT_USE_HTTPS')
# Validate static storage and default storage configurations
staticfiles_storage: str = get_setting('STATICFILES_STORAGE')
if staticfiles_storage.endswith(MinioBackendStatic.__name__):
mbs = MinioBackendStatic()
mbs.check_bucket_existence()
from typing import List
from django.core.management.base import BaseCommand
from django_minio_backend.models import MinioBackend
from django_minio_backend.utils import get_setting
class Command(BaseCommand):
help = 'Helps initializing Minio buckets by creating them and setting their policies.'
def add_arguments(self, parser):
parser.add_argument('--silenced', action='store_true', default=False, help='No console messages')
def handle(self, *args, **options):
silenced = options.get('silenced')
self.stdout.write(f"Initializing Minio buckets...\n") if not silenced else None
private_buckets: List[str] = get_setting("MINIO_PRIVATE_BUCKETS", [])
public_buckets: List[str] = get_setting("MINIO_PUBLIC_BUCKETS", [])
for bucket in [*public_buckets, *private_buckets]:
m = MinioBackend(bucket)
m.check_bucket_existence()
self.stdout.write(f"Bucket ({bucket}) OK", ending='\n') if not silenced else None
if m.is_bucket_public: # Based on settings.py configuration
m.set_bucket_to_public()
self.stdout.write(
f"Bucket ({m.bucket}) policy has been set to public", ending='\n') if not silenced else None
c = MinioBackend() # Client
for policy_tuple in get_setting('MINIO_POLICY_HOOKS', []):
bucket, policy = policy_tuple
c.set_bucket_policy(bucket, policy)
self.stdout.write(
f"Bucket ({m.bucket}) policy has been set via policy hook", ending='\n') if not silenced else None
self.stdout.write('\nAll private & public buckets have been verified.\n', ending='\n') if not silenced else None
self.stdout.flush()
from django.core.management.base import BaseCommand, CommandError
from django_minio_backend.models import MinioBackend
class Command(BaseCommand):
help = 'Checks if the configured MinIO service is available.'
def add_arguments(self, parser):
parser.add_argument('--silenced', action='store_true', default=False, help='No console messages')
def handle(self, *args, **options):
m = MinioBackend() # use default storage
silenced = options.get('silenced')
self.stdout.write(f"Checking the availability of MinIO at {m.base_url}\n") if not silenced else None
available = m.is_minio_available()
if not available:
self.stdout.flush()
raise CommandError(f'MinIO is NOT available at {m.base_url}\n'
f'Reason: {available.details}')
self.stdout.write(f'MinIO is available at {m.base_url}', ending='\n') if not silenced else None
self.stdout.flush()
This diff is collapsed.
from django.test import TestCase
# Create your tests here.
# noinspection PyPackageRequirements minIO_requirement
import urllib3
from typing import Union, List
from django.conf import settings
__all__ = ['MinioServerStatus', 'PrivatePublicMixedError', 'ConfigurationError', 'get_setting', ]
class MinioServerStatus:
"""
MinioServerStatus is a simple status info wrapper for checking the availability of a remote MinIO server.
MinioBackend.is_minio_available() returns a MinioServerStatus instance
MinioServerStatus can be evaluated with the bool() method:
```
minio_available = MinioBackend.is_minio_available()
if bool(minio_available): # bool() can be omitted
print("OK")
```
"""
def __init__(self, request: Union[urllib3.response.HTTPResponse, None]):
self._request = request
self._bool = False
self._details: List[str] = []
self.status = None
self.data = None
self.__OK = 'MinIO is available'
self.___NOK = 'MinIO is NOT available'
if not self._request:
self.add_message('There was no HTTP request provided for MinioServerStatus upon initialisation.')
else:
self.status = self._request.status
self.data = self._request.data.decode() if self._request.data else 'No data available'
if self.status == 403: # Request was a legal, but the server refuses to respond to it -> it's running fine
self._bool = True
else:
self._details.append(self.__OK)
self._details.append('Reason: ' + self.data)
def __bool__(self):
return self._bool
def add_message(self, text: str):
self._details.append(text)
@property
def is_available(self):
return self._bool
@property
def details(self):
return '\n'.join(self._details)
def __repr__(self):
if self.is_available:
return self.__OK
return self.___NOK
class PrivatePublicMixedError(Exception):
"""Raised on public|private bucket configuration collisions"""
pass
class ConfigurationError(Exception):
"""Raised on django-minio-backend configuration errors"""
pass
def get_setting(name, default=None):
"""Get setting from settings.py. Return a default value if not defined"""
return getattr(settings, name, default)
# ORIGINAL SOURCE
# https://docs.min.io/docs/deploy-minio-on-docker-compose.html
# https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/docker-compose.yaml?raw=true
# IN THIS CONFIGURATION, THE PROJECT FILES ARE VOLUME MAPPED INTO THE CONTAINER FROM THE HOST
version: "3.9"
# Settings and configurations that are common for all containers
x-minio-common: &minio-common
image: minio/minio:RELEASE.2021-07-30T00-02-00Z
command: server --console-address ":9001" http://minio{1...4}/data{1...2}
expose:
- "9000"
- "9001"
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
interval: 30s
timeout: 20s
retries: 3
services:
# starts Django from DjangoExampleProject + DjangoExampleApplication
web:
image: python:3
command: bash -c "
pip install -r /code/requirements.txt
&& python manage.py migrate
&& python manage.py runserver 0.0.0.0:8000
"
volumes:
- .:/code
working_dir: /code
environment:
PYTHONUNBUFFERED: "1"
GH_MINIO_ENDPOINT: "nginx:9000"
GH_MINIO_USE_HTTPS: "false"
GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000"
GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false"
GH_MINIO_ACCESS_KEY: "minio"
GH_MINIO_SECRET_KEY: "minio123"
# CREATE AN ADMIN ACCOUNT FOR INTERNAL DEMO PURPOSES ONLY!
DJANGO_SUPERUSER_USERNAME: "admin"
DJANGO_SUPERUSER_PASSWORD: "123123"
DJANGO_SUPERUSER_EMAIL: "admin@local.test"
ports:
- "8000:8000"
depends_on:
- nginx
# starts 4 docker containers running minio server instances.
# using nginx reverse proxy, load balancing, you can access
# it through port 9000.
minio1:
<<: *minio-common
hostname: minio1
volumes:
- data1-1:/data1
- data1-2:/data2
minio2:
<<: *minio-common
hostname: minio2
volumes:
- data2-1:/data1
- data2-2:/data2
minio3:
<<: *minio-common
hostname: minio3
volumes:
- data3-1:/data1
- data3-2:/data2
minio4:
<<: *minio-common
hostname: minio4
volumes:
- data4-1:/data1
- data4-2:/data2
nginx:
image: nginx:1.19.2-alpine
hostname: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "9000:9000"
- "9001:9001"
depends_on:
- minio1
- minio2
- minio3
- minio4
## By default this config uses default local driver,
## For custom volumes replace with volume driver configuration.
volumes:
data1-1:
data1-2:
data2-1:
data2-2:
data3-1:
data3-2:
data4-1:
data4-2:
# ORIGINAL SOURCE
# https://docs.min.io/docs/deploy-minio-on-docker-compose.html
# https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/docker-compose.yaml?raw=true
# IN THIS CONFIGURATION, THE PROJECT FILES ARE COPIED INTO THE CONTAINER
version: "3.9"
# Settings and configurations that are common for all containers
x-minio-common: &minio-common
image: minio/minio:RELEASE.2021-07-30T00-02-00Z
command: server --console-address ":9001" http://minio{1...4}/data{1...2}
expose:
- "9000"
- "9001"
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
interval: 30s
timeout: 20s
retries: 3
services:
# starts Django from DjangoExampleProject + DjangoExampleApplication
web:
build: .
command: bash -c "
python manage.py migrate
&& python manage.py runserver 0.0.0.0:8000
"
environment:
GH_MINIO_ENDPOINT: "nginx:9000"
GH_MINIO_USE_HTTPS: "false"
GH_MINIO_EXTERNAL_ENDPOINT: "localhost:9000"
GH_MINIO_EXTERNAL_ENDPOINT_USE_HTTPS: "false"
GH_MINIO_ACCESS_KEY: "minio"
GH_MINIO_SECRET_KEY: "minio123"
# CREATE AN ADMIN ACCOUNT FOR INTERNAL DEMO PURPOSES ONLY!
DJANGO_SUPERUSER_USERNAME: "admin"
DJANGO_SUPERUSER_PASSWORD: "123123"
DJANGO_SUPERUSER_EMAIL: "admin@local.test"
ports:
- "8000:8000"
depends_on:
- nginx
# starts 4 docker containers running minio server instances.
# using nginx reverse proxy, load balancing, you can access
# it through port 9000.
minio1:
<<: *minio-common
hostname: minio1
volumes:
- data1-1:/data1
- data1-2:/data2
minio2:
<<: *minio-common
hostname: minio2
volumes:
- data2-1:/data1
- data2-2:/data2
minio3:
<<: *minio-common
hostname: minio3
volumes:
- data3-1:/data1
- data3-2:/data2
minio4:
<<: *minio-common
hostname: minio4
volumes:
- data4-1:/data1
- data4-2:/data2
nginx:
image: nginx:1.19.2-alpine
hostname: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "9000:9000"
- "9001:9001"
depends_on:
- minio1
- minio2
- minio3
- minio4
## By default this config uses default local driver,
## For custom volumes replace with volume driver configuration.
volumes:
data1-1:
data1-2:
data2-1:
data2-2:
data3-1:
data3-2:
data4-1:
data4-2:
from typing import List, Tuple
bucket_name: str = 'my-very-public-bucket'
# policy sets the appropriate bucket as world readable (no write)
# See the following good summary of Bucket Policies
# https://gist.github.com/krishnasrinivas/2f5a9affe6be6aff42fe723f02c86d6a
policy = {"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetBucketLocation",
"Resource": f"arn:aws:s3:::{bucket_name}"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:ListBucket",
"Resource": f"arn:aws:s3:::{bucket_name}"
},
{
"Sid": "",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetObject",
"Resource": f"arn:aws:s3:::{bucket_name}/*"
}
]}
MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [ # This array of (bucket_name, policy) tuples belong to Django settings
(bucket_name, policy),
]
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoExampleProject.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
# ORIGINAL SOURCE
# https://docs.min.io/docs/deploy-minio-on-docker-compose.html
# https://github.com/minio/minio/blob/master/docs/orchestration/docker-compose/nginx.conf?raw=true
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
# include /etc/nginx/conf.d/*.conf;
upstream minio {
server minio1:9000;
server minio2:9000;
server minio3:9000;
server minio4:9000;
}
upstream console {
ip_hash;
server minio1:9001;
server minio2:9001;
server minio3:9001;
server minio4:9001;
}
server {
listen 9000;
listen [::]:9000;
server_name localhost;
# To allow special characters in headers
ignore_invalid_headers off;
# Allow any size file to be uploaded.
# Set to a value such as 1000m; to restrict file size to a specific value
client_max_body_size 0;
# To disable buffering
proxy_buffering off;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 300;
# Default is HTTP/1, keepalive is only enabled in HTTP/1.1
proxy_http_version 1.1;
proxy_set_header Connection "";
chunked_transfer_encoding off;
proxy_pass http://minio;
}
}
server {
listen 9001;
listen [::]:9001;
server_name localhost;
# To allow special characters in headers
ignore_invalid_headers off;
# Allow any size file to be uploaded.
# Set to a value such as 1000m; to restrict file size to a specific value
client_max_body_size 0;
# To disable buffering
proxy_buffering off;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
# This is necessary to pass the correct IP to be hashed
real_ip_header X-Real-IP;
proxy_connect_timeout 300;
# Default is HTTP/1, keepalive is only enabled in HTTP/1.1
proxy_http_version 1.1;
proxy_set_header Connection "";
chunked_transfer_encoding off;
proxy_pass http://console;
}
}
}
Django>=2.2.2
minio>=7.0.2
Pillow
setuptools
import os
from datetime import datetime
from setuptools import find_packages, setup
from version import get_git_version
with open("README.md", "r") as readme_file:
long_description = readme_file.read()
# allow setup.py to be run from any path
os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
setup(
name='django-minio-backend',
version=get_git_version(),
packages=find_packages(),
include_package_data=True,
license=f'MIT License | Copyright (c) {datetime.now().year} Kristof Daja',
description='The django-minio-backend provides a wrapper around the MinIO Python Library.',
long_description=long_description,
long_description_content_type="text/markdown",
url='https://github.com/theriverman/django-minio-backend',
author='Kristof Daja (theriverman)',
author_email='kristof@daja.hu',
install_requires=[
'Django>=2.2.2',
'minio>=7.0.2'
],
classifiers=[
'Environment :: Web Environment',
'Framework :: Django',
'Framework :: Django :: 2.2',
'Framework :: Django :: 3.0',
'Framework :: Django :: 3.1',
'Framework :: Django :: 4.0',
'Framework :: Django :: 4.1',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System',
'Topic :: Internet :: WWW/HTTP :: WSGI :: Application',
],
)
# -*- coding: utf-8 -*-
# Author: Douglas Creager <dcreager@dcreager.net>
# Modifier: Kristof Daja <kristof@daja.hu>
# This file is placed into the public domain.
# Calculates the current version number. If possible, this is the
# output of “git describe”, modified to conform to the versioning
# scheme that setuptools uses. If “git describe” returns an error
# (most likely because we're in an unpacked copy of a release tarball,
# rather than in a git working copy), then we fall back on reading the
# contents of the RELEASE-VERSION file.
#
# To use this script, simply import it your setup.py file, and use the
# results of get_git_version() as your package version:
#
# from version import *
#
# setup(
# version=get_git_version(),
# .
# .
# .
# )
#
#
# This will automatically update the RELEASE-VERSION file, if
# necessary. Note that the RELEASE-VERSION file should *not* be
# checked into git; please add it to your top-level .gitignore file.
#
# You'll probably want to distribute the RELEASE-VERSION file in your
# sdist tarballs; to do this, just create a MANIFEST.in file that
# contains the following line:
#
# include RELEASE-VERSION
#
# Change History:
# 2020-12-12 - Updated for Python 3. Changed git describe --abbrev=7 to git describe --tags
#
__all__ = ["get_git_version"]
from subprocess import Popen, PIPE
def call_git_describe():
# noinspection PyBroadException
try:
p = Popen(['git', 'describe', '--tags'],
stdout=PIPE, stderr=PIPE)
p.stderr.close()
line = p.stdout.readlines()[0]
return line.strip().decode('utf-8')
except Exception:
return None
def is_dirty():
# noinspection PyBroadException
try:
p = Popen(["git", "diff-index", "--name-only", "HEAD"],
stdout=PIPE, stderr=PIPE)
p.stderr.close()
lines = p.stdout.readlines()
return len(lines) > 0
except Exception:
return False
def read_release_version():
# noinspection PyBroadException
try:
f = open("RELEASE-VERSION", "r")
try:
version = f.readlines()[0]
return version.strip()
finally:
f.close()
except Exception:
return None
def write_release_version(version):
f = open("RELEASE-VERSION", "w")
f.write("%s\n" % version)
f.close()
def get_git_version():
# Read in the version that's currently in RELEASE-VERSION.
release_version = read_release_version()
# First try to get the current version using “git describe”.
version = call_git_describe()
if is_dirty():
version += "-dirty"
# If that doesn't work, fall back on the value that's in
# RELEASE-VERSION.
if version is None:
version = release_version
# If we still don't have anything, that's an error.
if version is None:
raise ValueError("Cannot find the version number!")
# If the current version is different from what's in the
# RELEASE-VERSION file, update the file to be current.
if version != release_version:
write_release_version(version)
# Finally, return the current version.
return version
if __name__ == "__main__":
print(get_git_version())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment