├── certificate └── empty ├── docs ├── _static │ └── Empty ├── _templates │ └── Empty ├── requirements.txt ├── index.rst ├── Makefile └── conf.py ├── MANIFEST.in ├── gdstorage ├── __init__.py ├── apps.py ├── tests.py └── storage.py ├── test ├── gdrive_logo.png └── settings.py ├── .readthedocs.yaml ├── .github └── workflows │ ├── lint.yml │ └── tests.yml ├── .gitignore ├── LICENSE.txt ├── setup.py └── README.rst /certificate/empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/Empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_templates/Empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==4.3.1 2 | sphinx-rtd-theme 3 | sphinx-rtd-dark-mode -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.rst 3 | exclude gdstorage/tests.py -------------------------------------------------------------------------------- /gdstorage/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'gdstorage.apps.GoogleDriveStorageConfig' 2 | -------------------------------------------------------------------------------- /test/gdrive_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torre76/django-googledrive-storage/HEAD/test/gdrive_logo.png -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-20.04 9 | tools: 10 | python: "3.8" 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | 15 | python: 16 | install: 17 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Python 11 | uses: actions/setup-python@v2 12 | - name: Install dependencies 13 | run: pip install flake8 flake8-quotes flake8-isort flake8-print 14 | - name: Run lint 15 | run: cd gdstorage && flake8 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | 89e3e07e0dc77f40c981b900c87d6246866172b7-privatekey.p12 4 | django_googledrive_storage 5 | *.pyc 6 | manage.py 7 | dist 8 | gdstorage.egg-info 9 | db.sqlite3 10 | django_googledrive_storage.egg-info 11 | .idea/ 12 | venv/ 13 | certificate/89e3e07e0dc77f40c981b900c87d6246866172b7-privatekey.crt 14 | certificate/89e3e07e0dc77f40c981b900c87d6246866172b7-privatekey.key 15 | certificate/Django Google Drive Storage-44bb261c98ae.key 16 | certificate/Django Google Drive Storage-44bb261c98ae.p12 17 | build 18 | docs/test 19 | docs/out 20 | .ropeproject 21 | .vscode/ 22 | gdtest/ 23 | docs/_build -------------------------------------------------------------------------------- /gdstorage/apps.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.apps import AppConfig 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | 7 | 8 | class GoogleDriveStorageConfig(AppConfig): 9 | name = 'gdstorage' 10 | verbose_name = 'Google Drive Storage' 11 | _prefix = 'GOOGLE_DRIVE_STORAGE' 12 | 13 | def ready(self): 14 | if not hasattr(settings, self._get_attr('JSON_KEY_FILE')): 15 | if not os.getenv(self._get_attr('JSON_KEY_FILE_CONTENTS')): 16 | raise ImproperlyConfigured( 17 | 'Either GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE in settings ' 18 | 'or GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE_CONTENTS ' 19 | 'environment variable should be defined.' 20 | ) 21 | if not hasattr(settings, self._get_attr('MEDIA_ROOT')): 22 | setattr(settings, self._get_attr('MEDIA_ROOT'), '') 23 | 24 | def _get_attr(self, suffix): 25 | return '_'.join([self._prefix, suffix]) 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ['3.6', '3.7', '3.8', '3.9'] 11 | django-version: ['2.2.*', '3.0.*', '3.1.*'] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} / Django ${{ matrix.django-version }} 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | pip install -U pip 21 | pip install -U Django==${{ matrix.django-version }} pytest-django 22 | - name: Run tests 23 | run: | 24 | python setup.py install 25 | django-admin.py startproject gdtest 26 | cp -av test/settings.py gdtest/gdtest/ 27 | cp -av gdstorage/tests.py gdtest/gdtest/ 28 | cp -av test/ gdtest/ 29 | cd gdtest && pytest -v --ds gdtest.settings gdtest/tests.py 30 | env: 31 | GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE_CONTENTS: ${{ secrets.GDSTORAGE_KEY_FILE_CONTENT }} 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Gian Luca Dalla Torre 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 1. Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | 3. The names of its contributors may not be used to endorse or promote products 12 | derived from this software without specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY GIAN LUCA DALLA TORRE ''AS IS'' AND ANY 15 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL GIAN LUCA DALLA TORRE BE LIABLE FOR ANY 18 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 21 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import setuptools 3 | import sys 4 | 5 | long_description = codecs.open('README.rst', "r").read() 6 | 7 | # Conditional load of enumeration 8 | # See https://hynek.me/articles/conditional-python-dependencies/ 9 | 10 | INSTALL_REQUIRES = [ 11 | "google-api-python-client >= 1.8.2", 12 | "google-auth >= 1.28.0,<2", 13 | "python-dateutil >= 2.5.3", 14 | "Django >= 2.2" 15 | ] 16 | 17 | setuptools.setup( 18 | name="django-googledrive-storage", 19 | version="1.6.0", 20 | author="Gian Luca Dalla Torre", 21 | author_email="gianluca.dallatorre@gmail.com", 22 | description=( 23 | "Storage implementation for Django that interacts with Google Drive"), 24 | license="LICENSE.txt", 25 | keywords="django google drive storage googledrive", 26 | url="https://github.com/torre76/django-googledrive-storage", 27 | download_url="https://github.com/torre76/django-googledrive-storage/tarball/1.6.0", 28 | packages=setuptools.find_packages( 29 | exclude=["django_googledrive_storage", "gdstorage.tests", "docs"]), 30 | long_description=long_description, 31 | package_data={ 32 | '': ['README.rst'], 33 | }, 34 | install_requires=INSTALL_REQUIRES, 35 | classifiers=[ 36 | "Development Status :: 5 - Production/Stable", 37 | "Framework :: Django", 38 | "License :: OSI Approved :: BSD License", 39 | "Programming Language :: Python :: 3.6", 40 | "Programming Language :: Python :: 3.7", 41 | "Programming Language :: Python :: 3.8", 42 | "Programming Language :: Python :: 3.9", 43 | "Programming Language :: Python :: 3.10" 44 | ], 45 | ) 46 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Django Google Drive Storage 3 | =========================== 4 | 5 | |build-status| |lint| |pypi| |docs| 6 | 7 | This is a `Django Storage `_ implementation that uses `Google Drive `_ as backend for storing data. 8 | 9 | Quick start 10 | ----------- 11 | 12 | Installation 13 | ************ 14 | 15 | .. code-block:: bash 16 | 17 | pip install django-googledrive-storage 18 | 19 | 20 | Setup 21 | ***** 22 | 23 | .. code-block:: python 24 | 25 | INSTALLED_APPS = ( 26 | ..., 27 | 'django.contrib.staticfiles', 28 | 'gdstorage' 29 | ) 30 | 31 | Set the environment variable `GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE_CONTENTS` or path file in the `settings.py`: `GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE`. 32 | 33 | Optional set `GOOGLE_DRIVE_STORAGE_MEDIA_ROOT` in the `settings.py` 34 | 35 | Usage example 36 | ************* 37 | 38 | .. code-block:: python 39 | 40 | from gdstorage.storage import GoogleDriveStorage 41 | 42 | # Define Google Drive Storage 43 | gd_storage = GoogleDriveStorage() 44 | 45 | ... 46 | 47 | class Map(models.Model): 48 | id = models.AutoField( primary_key=True) 49 | map_name = models.CharField(max_length=200) 50 | map_data = models.FileField(upload_to='maps', storage=gd_storage) 51 | 52 | 53 | Documentation and Installation instructions 54 | ------------------------------------------- 55 | 56 | Documentation and installation instructions can be found at `Read The Docs `_. 57 | 58 | .. |build-status| image:: https://github.com/conformist-mw/django-googledrive-storage/workflows/tests/badge.svg 59 | :target: https://github.com/conformist-mw/django-googledrive-storage/actions/workflows/tests.yml 60 | :alt: Tests status 61 | 62 | .. |lint| image:: https://github.com/conformist-mw/django-googledrive-storage/workflows/lint/badge.svg 63 | :target: https://github.com/conformist-mw/django-googledrive-storage/actions/workflows/lint.yml 64 | :alt: Linter status 65 | 66 | .. |pypi| image:: https://img.shields.io/pypi/v/django-googledrive-storage.svg 67 | :target: https://pypi.python.org/pypi/django-googledrive-storage/ 68 | :alt: django-googledrive-storage on Pypi 69 | 70 | .. |docs| image:: https://readthedocs.org/projects/django-googledrive-storage/badge/?version=latest 71 | :target: http://django-googledrive-storage.readthedocs.org/en/latest/?badge=latest 72 | :alt: Documentation Status 73 | -------------------------------------------------------------------------------- /test/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for gdtest project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10b1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/dev/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '&$)zvzgmzpvnot6_p7_6u@&#ea$@c7^j*(0%9g+iy%=yju07k%' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'gdstorage' 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | 'django.middleware.security.SecurityMiddleware', 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ] 52 | 53 | ROOT_URLCONF = 'gdtest.urls' 54 | 55 | TEMPLATES = [ 56 | { 57 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 58 | 'DIRS': [], 59 | 'APP_DIRS': True, 60 | 'OPTIONS': { 61 | 'context_processors': [ 62 | 'django.template.context_processors.debug', 63 | 'django.template.context_processors.request', 64 | 'django.contrib.auth.context_processors.auth', 65 | 'django.contrib.messages.context_processors.messages', 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = 'gdtest.wsgi.application' 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases 76 | 77 | DATABASES = { 78 | 'default': { 79 | 'ENGINE': 'django.db.backends.sqlite3', 80 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/dev/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 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/dev/topics/i18n/ 106 | 107 | LANGUAGE_CODE = 'en-us' 108 | 109 | TIME_ZONE = 'UTC' 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/dev/howto/static-files/ 120 | 121 | STATIC_URL = '/static/' 122 | 123 | # 124 | # Google Drive Storage Settings 125 | # 126 | GOOGLE_DRIVE_STORAGE_SERVICE_EMAIL = '61034839021-r18v4c9k072dud32iook8pv8meaie8vv@developer.gserviceaccount.com' -------------------------------------------------------------------------------- /gdstorage/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import time 4 | 5 | import pytest 6 | 7 | from gdstorage.storage import (GoogleDriveFilePermission, 8 | GoogleDrivePermissionRole, 9 | GoogleDrivePermissionType, GoogleDriveStorage) 10 | 11 | SLEEP_INTERVAL = 10 12 | 13 | 14 | @pytest.fixture 15 | def gds(): 16 | return GoogleDriveStorage() 17 | 18 | 19 | @pytest.fixture 20 | def write_perm_gds(): 21 | return GoogleDriveStorage( 22 | permissions=(GoogleDriveFilePermission( 23 | GoogleDrivePermissionRole.WRITER, 24 | GoogleDrivePermissionType.ANYONE, 25 | ),) 26 | ) 27 | 28 | 29 | @pytest.fixture 30 | def read_write_perm_gds(): 31 | return GoogleDriveStorage( 32 | permissions=( 33 | GoogleDriveFilePermission( 34 | GoogleDrivePermissionRole.WRITER, 35 | GoogleDrivePermissionType.USER, 36 | 'foo@mailinator.com', 37 | ), 38 | GoogleDriveFilePermission( 39 | GoogleDrivePermissionRole.READER, 40 | GoogleDrivePermissionType.ANYONE, 41 | ), 42 | ) 43 | ) 44 | 45 | 46 | class TestGoogleDriveStorage: 47 | def test_check_root_file_exists(self, gds): 48 | file_data = gds._check_file_exists('How to get started with Drive') 49 | assert file_data, "Unable to find file 'How to get started with Drive'" 50 | time.sleep(SLEEP_INTERVAL) 51 | 52 | def test_check_or_create_folder(self, gds): 53 | folder_data = gds._get_or_create_folder('test4/folder') 54 | assert folder_data, "Unable to find or create folder 'test4/folder'" 55 | time.sleep(SLEEP_INTERVAL) 56 | 57 | def _test_upload_file(self, gds): 58 | file_name = os.path.join( 59 | os.path.dirname(os.path.abspath(__file__)), 60 | '../test/gdrive_logo.png', 61 | ) 62 | with open(file_name, 'rb') as file: 63 | result = gds.save('/test4/gdrive_logo.png', file) 64 | assert result, 'Unable to upload file to Google Drive' 65 | 66 | def _test_list_folder(self, gds): 67 | self._test_upload_file(gds) 68 | directories, files = gds.listdir('/test4') 69 | assert len(files) > 0, 'Unable to read directory data' 70 | 71 | def _test_open_file(self): 72 | gds = GoogleDriveStorage() 73 | self._test_list_folder(gds) 74 | file = gds.open('/test4/gdrive_logo.png', 'rb') 75 | assert file, 'Unable to load data from Google Drive' 76 | 77 | def test_permission_full_write(self, write_perm_gds): 78 | file_name = os.path.join( 79 | os.path.dirname(os.path.abspath(__file__)), 80 | '../test/gdrive_logo.png', 81 | ) 82 | with open(file_name, 'rb') as file: 83 | result = write_perm_gds.save('/test4/gdrive_logo.png', file) 84 | assert result, 'Unable to upload file to Google Drive' 85 | file = write_perm_gds.open(result, 'rb') 86 | assert file, 'Unable to load data from Google Drive' 87 | time.sleep(SLEEP_INTERVAL) 88 | 89 | def test_multiple_permission(self, read_write_perm_gds): 90 | 91 | file_name = os.path.join( 92 | os.path.dirname(os.path.abspath(__file__)), 93 | '../test/gdrive_logo.png', 94 | ) 95 | result = read_write_perm_gds.save( 96 | '/test4/gdrive_logo.png', open(file_name, 'rb') 97 | ) 98 | assert result, 'Unable to upload file to Google Drive' 99 | file = read_write_perm_gds.open(result, 'rb') 100 | assert file, 'Unable to load data from Google Drive' 101 | time.sleep(SLEEP_INTERVAL) 102 | 103 | def test_upload_big_file(self, gds): 104 | file_name = os.path.join( 105 | os.path.dirname(os.path.abspath(__file__)), 106 | '../test/huge_file', 107 | ) 108 | with open(file_name, 'wb') as out: 109 | out.truncate(1024 * 1024 * 20) 110 | 111 | with open(file_name, 'rb') as file: 112 | result = gds.save('/test5/huge_file', file) 113 | assert result, 'Unable to upload file to Google Drive' 114 | 115 | os.remove(file_name) 116 | time.sleep(SLEEP_INTERVAL) 117 | 118 | def test_open_big_file(self, gds): 119 | self._test_list_folder(gds) 120 | file = gds.open('/test5/huge_file', 'rb') 121 | assert file, 'Unable to load data from Google Drive' 122 | time.sleep(SLEEP_INTERVAL) 123 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. # define a hard line break for HTML 2 | .. |br| raw:: html 3 | 4 |
5 | 6 | Django Google Drive Storage 7 | =========================== 8 | 9 | `Django Google Drive Storage `_ 10 | is a `Django Storage `_ 11 | implementation that uses `Google Drive `_ as a backend for storing data. 12 | 13 | Please take note that with **this implementation you could not save or load data from a user's Drive**. 14 | You can use only a Drive **dedicated to a Google Project**. This means that: 15 | 16 | * this storage interacts with Google Drive as a Google Project, not a Google User. 17 | * your project can use Google Drive only through `Google Drive SDK `_. Because no user is associated with this Drive, **you cannot use Google Drive User Interface**. 18 | * this storage authenticates with Google using public private keys. See prerequisites_ for how to obtain it. 19 | 20 | Having stated that, with this storage you gain a 15GB space hosted on Google Server where you are able to store data 21 | using Django models. 22 | 23 | .. _prerequisites: 24 | 25 | Prerequisites 26 | ************* 27 | 28 | To use this storage, you have to: 29 | 30 | * `set up a project and application in the Google Developers Console `_ 31 | * `obtain the json private key file (OAuth 2.0 for Server to Server Applications) for your Google Project associated with Google Drive service `_ 32 | 33 | Installation 34 | ************ 35 | 36 | This storage is hosted on `PyPI `_. It can be easily installed 37 | through *pip*: 38 | 39 | .. code-block:: bash 40 | 41 | pip install django-googledrive-storage 42 | 43 | Setup 44 | ***** 45 | 46 | Once installed, there are a few steps to configure the storage: 47 | 48 | * add the module *gdstorage* to your installed apps in your `settings.py` file: 49 | 50 | .. code-block:: python 51 | 52 | INSTALLED_APPS = ( 53 | ..., 54 | 'django.contrib.staticfiles', 55 | 'gdstorage' 56 | ) 57 | 58 | * create a section in your `setting.py` that contains the configuration for this storage: 59 | 60 | .. code-block:: python 61 | 62 | # 63 | # Google Drive Storage Settings 64 | # 65 | 66 | GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE = '' 67 | GOOGLE_DRIVE_STORAGE_MEDIA_ROOT = '' # OPTIONAL 68 | 69 | The `GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE` must be the path to *private json key file* obtained by Google. |br| 70 | Alternatively, you can place the contents of your json private key file into an environment variable named 71 | `GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE_CONTENTS`, this requires setting `GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE` to `None`. 72 | 73 | The `GOOGLE_DRIVE_STORAGE_MEDIA_ROOT` is analogous to MEDIA_ROOT for django’s built-in FileSystemStorage 74 | 75 | * instantiate the storage on you `models.py` file before using into the models: 76 | 77 | .. code-block:: python 78 | 79 | from gdstorage.storage import GoogleDriveStorage 80 | 81 | # Define Google Drive Storage 82 | gd_storage = GoogleDriveStorage() 83 | 84 | Use 85 | *** 86 | 87 | Once configured, it can be used as storage space associated with Django: 88 | 89 | .. code-block:: python 90 | 91 | class Map(models.Model): 92 | id = models.AutoField( primary_key=True) 93 | map_name = models.CharField(max_length=200) 94 | map_data = models.FileField(upload_to='maps', storage=gd_storage) 95 | 96 | .. note:: 97 | 98 | You can get the `upload_to` parameter to ignore `GOOGLE_DRIVE_STORAGE_MEDIA_ROOT` by using an absolute path 99 | e.g `/maps` 100 | 101 | 102 | File permissions 103 | **************** 104 | 105 | Using the storage this way, all files will be saved as publicly available for read (which is the most common use case), 106 | but sometimes you could have different reason to use Google Storage. 107 | 108 | It is possible to specify a set of file permissions [#google_drive_permissions]_ to change how the file could be read or 109 | written. 110 | 111 | This code block will assign read only capabilities only to the user identified by `foo@mailinator.com`. 112 | 113 | .. code-block:: python 114 | 115 | from gdstorage.storage import GoogleDriveStorage, GoogleDrivePermissionType, GoogleDrivePermissionRole, GoogleDriveFilePermission 116 | 117 | permission = GoogleDriveFilePermission( 118 | GoogleDrivePermissionRole.READER, 119 | GoogleDrivePermissionType.USER, 120 | "foo@mailinator.com" 121 | ) 122 | 123 | gd_storage = GoogleDriveStorage(permissions=(permission, )) 124 | 125 | class Map(models.Model): 126 | id = models.AutoField( primary_key=True) 127 | map_name = models.CharField(max_length=200) 128 | map_data = models.FileField(upload_to='maps/', storage=gd_storage) 129 | 130 | Source and License 131 | ****************** 132 | 133 | Source can be found on `GitHub `_ with its included 134 | `license `_. 135 | 136 | 137 | .. rubric:: Footnotes 138 | 139 | .. [#google_drive_permissions] A detailed explanation of Google Drive API permission can be found `here `_. 140 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoGoogleDriveStorage.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoGoogleDriveStorage.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoGoogleDriveStorage" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoGoogleDriveStorage" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django Google Drive Storage documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Dec 29 13:51:34 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import datetime 16 | import os 17 | import sphinx_rtd_theme 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx.ext.intersphinx', 35 | 'sphinx.ext.todo', 36 | 'sphinx.ext.ifconfig', 37 | 'sphinx.ext.viewcode', 38 | 'sphinx_rtd_dark_mode', 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix of source filenames. 45 | source_suffix = '.rst' 46 | 47 | # The encoding of source files. 48 | # source_encoding = 'utf-8-sig' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = u'Django Google Drive Storage' 55 | copyright = u'2014 - {}, Gian Luca Dalla Torre'.format( 56 | datetime.datetime.now().year) 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = '1.6.0' 64 | # The full version, including alpha/beta/rc tags. 65 | release = '1.6.0' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | # today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | # today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = ['_build'] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all 82 | # documents. 83 | # default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | # add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | # add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | # show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | # modindex_common_prefix = [] 101 | 102 | # If true, keep warnings as "system message" paragraphs in the built documents. 103 | # keep_warnings = False 104 | 105 | 106 | # -- Options for HTML output ---------------------------------------------- 107 | 108 | # The theme to use for HTML and HTML Help pages. See the documentation for 109 | # a list of builtin themes. 110 | html_theme = 'sphinx_rtd_theme' 111 | 112 | # Theme options are theme-specific and customize the look and feel of a theme 113 | # further. For a list of options available for each theme, see the 114 | # documentation. 115 | #html_theme_options = {} 116 | 117 | # Add any paths that contain custom themes here, relative to this directory. 118 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 119 | 120 | # The name for this set of Sphinx documents. If None, it defaults to 121 | # " v documentation". 122 | # html_title = None 123 | 124 | # A shorter title for the navigation bar. Default is the same as html_title. 125 | # html_short_title = None 126 | 127 | # The name of an image file (relative to this directory) to place at the top 128 | # of the sidebar. 129 | # html_logo = None 130 | 131 | # The name of an image file (within the static path) to use as favicon of the 132 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 133 | # pixels large. 134 | # html_favicon = None 135 | 136 | # Add any paths that contain custom static files (such as style sheets) here, 137 | # relative to this directory. They are copied after the builtin static files, 138 | # so a file named "default.css" will overwrite the builtin "default.css". 139 | html_static_path = ['_static'] 140 | 141 | # Add any extra paths that contain custom files (such as robots.txt or 142 | # .htaccess) here, relative to this directory. These files are copied 143 | # directly to the root of the documentation. 144 | # html_extra_path = [] 145 | 146 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 147 | # using the given strftime format. 148 | # html_last_updated_fmt = '%b %d, %Y' 149 | 150 | # If true, SmartyPants will be used to convert quotes and dashes to 151 | # typographically correct entities. 152 | # html_use_smartypants = True 153 | 154 | # Custom sidebar templates, maps document names to template names. 155 | # html_sidebars = {} 156 | 157 | # Additional templates that should be rendered to pages, maps page names to 158 | # template names. 159 | # html_additional_pages = {} 160 | 161 | # If false, no module index is generated. 162 | # html_domain_indices = True 163 | 164 | # If false, no index is generated. 165 | # html_use_index = True 166 | 167 | # If true, the index is split into individual pages for each letter. 168 | # html_split_index = False 169 | 170 | # If true, links to the reST sources are added to the pages. 171 | # html_show_sourcelink = True 172 | 173 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 174 | # html_show_sphinx = True 175 | 176 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 177 | # html_show_copyright = True 178 | 179 | # If true, an OpenSearch description file will be output, and all pages will 180 | # contain a tag referring to it. The value of this option must be the 181 | # base URL from which the finished HTML is served. 182 | # html_use_opensearch = '' 183 | 184 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 185 | # html_file_suffix = None 186 | 187 | # Output file base name for HTML help builder. 188 | htmlhelp_basename = 'DjangoGoogleDriveStoragedoc' 189 | 190 | 191 | # -- Options for LaTeX output --------------------------------------------- 192 | 193 | latex_elements = { 194 | # The paper size ('letterpaper' or 'a4paper'). 195 | # 'papersize': 'letterpaper', 196 | 197 | # The font size ('10pt', '11pt' or '12pt'). 198 | # 'pointsize': '10pt', 199 | 200 | # Additional stuff for the LaTeX preamble. 201 | # 'preamble': '', 202 | } 203 | 204 | # Grouping the document tree into LaTeX files. List of tuples 205 | # (source start file, target name, title, 206 | # author, documentclass [howto, manual, or own class]). 207 | latex_documents = [ 208 | ('index', 'DjangoGoogleDriveStorage.tex', u'Django Google Drive Storage Documentation', 209 | u'Gian Luca Dalla Torre', 'manual'), 210 | ] 211 | 212 | # The name of an image file (relative to this directory) to place at the top of 213 | # the title page. 214 | # latex_logo = None 215 | 216 | # For "manual" documents, if this is true, then toplevel headings are parts, 217 | # not chapters. 218 | # latex_use_parts = False 219 | 220 | # If true, show page references after internal links. 221 | # latex_show_pagerefs = False 222 | 223 | # If true, show URL addresses after external links. 224 | # latex_show_urls = False 225 | 226 | # Documents to append as an appendix to all manuals. 227 | # latex_appendices = [] 228 | 229 | # If false, no module index is generated. 230 | # latex_domain_indices = True 231 | 232 | 233 | # -- Options for manual page output --------------------------------------- 234 | 235 | # One entry per manual page. List of tuples 236 | # (source start file, name, description, authors, manual section). 237 | man_pages = [ 238 | ('index', 'djangogoogledrivestorage', u'Django Google Drive Storage Documentation', 239 | [u'Gian Luca Dalla Torre'], 1) 240 | ] 241 | 242 | # If true, show URL addresses after external links. 243 | # man_show_urls = False 244 | 245 | 246 | # -- Options for Texinfo output ------------------------------------------- 247 | 248 | # Grouping the document tree into Texinfo files. List of tuples 249 | # (source start file, target name, title, author, 250 | # dir menu entry, description, category) 251 | texinfo_documents = [ 252 | ('index', 'DjangoGoogleDriveStorage', u'Django Google Drive Storage Documentation', 253 | u'Gian Luca Dalla Torre', 'DjangoGoogleDriveStorage', 'One line description of project.', 254 | 'Miscellaneous'), 255 | ] 256 | 257 | # Documents to append as an appendix to all manuals. 258 | # texinfo_appendices = [] 259 | 260 | # If false, no module index is generated. 261 | # texinfo_domain_indices = True 262 | 263 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 264 | # texinfo_show_urls = 'footnote' 265 | 266 | # If true, do not generate a @detailmenu in the "Top" node's menu. 267 | # texinfo_no_detailmenu = False 268 | 269 | 270 | # Example configuration for intersphinx: refer to the Python standard library. 271 | intersphinx_mapping = {'http://docs.python.org/': None} 272 | -------------------------------------------------------------------------------- /gdstorage/storage.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import json 3 | import mimetypes 4 | import os 5 | from io import BytesIO 6 | 7 | from dateutil.parser import parse 8 | from django.conf import settings 9 | from django.core.files import File 10 | from django.core.files.storage import Storage 11 | from django.utils.deconstruct import deconstructible 12 | from google.oauth2.service_account import Credentials 13 | from googleapiclient.discovery import build 14 | from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload 15 | 16 | 17 | class GoogleDrivePermissionType(enum.Enum): 18 | """ 19 | Describe a permission type for Google Drive as described on 20 | `Drive docs `_ 21 | """ # noqa: E501 22 | 23 | USER = 'user' # Permission for single user 24 | 25 | GROUP = 'group' # Permission for group defined in Google Drive 26 | 27 | DOMAIN = 'domain' # Permission for domain defined in Google Drive 28 | 29 | ANYONE = 'anyone' # Permission for anyone 30 | 31 | 32 | class GoogleDrivePermissionRole(enum.Enum): 33 | """ 34 | Describe a permission role for Google Drive as described on 35 | `Drive docs `_ 36 | """ # noqa: E501 37 | 38 | OWNER = 'owner' # File Owner 39 | 40 | READER = 'reader' # User can read a file 41 | 42 | WRITER = 'writer' # User can write a file 43 | 44 | COMMENTER = 'commenter' # User can comment a file 45 | 46 | 47 | @deconstructible 48 | class GoogleDriveFilePermission(object): 49 | """ 50 | Describe a permission for Google Drive as described on 51 | `Drive docs `_ 52 | 53 | :param gdstorage.GoogleDrivePermissionRole g_role: Role associated to this permission 54 | :param gdstorage.GoogleDrivePermissionType g_type: Type associated to this permission 55 | :param str g_value: email address that qualifies the User associated to this permission 56 | 57 | """ # noqa: E501 58 | 59 | @property 60 | def role(self): 61 | """ 62 | Role associated to this permission 63 | 64 | :return: Enumeration that states the role associated to this permission 65 | :rtype: gdstorage.GoogleDrivePermissionRole 66 | """ 67 | return self._role 68 | 69 | @property 70 | def type(self): 71 | """ 72 | Type associated to this permission 73 | 74 | :return: Enumeration that states the role associated to this permission 75 | :rtype: gdstorage.GoogleDrivePermissionType 76 | """ 77 | return self._type 78 | 79 | @property 80 | def value(self): 81 | """ 82 | Email that qualifies the user associated to this permission 83 | :return: Email as string 84 | :rtype: str 85 | """ 86 | return self._value 87 | 88 | @property 89 | def raw(self): 90 | """ 91 | Transform the :class:`.GoogleDriveFilePermission` instance into a 92 | string used to issue the command to Google Drive API 93 | 94 | :return: Dictionary that states a permission compliant with Google Drive API 95 | :rtype: dict 96 | """ # noqa: E501 97 | 98 | result = { 99 | 'role': self.role.value, 100 | 'type': self.type.value 101 | } 102 | 103 | if self.value is not None: 104 | result['emailAddress'] = self.value 105 | 106 | return result 107 | 108 | def __init__(self, g_role, g_type, g_value=None): 109 | """ 110 | Instantiate this class 111 | """ 112 | if not isinstance(g_role, GoogleDrivePermissionRole): 113 | raise ValueError( 114 | 'Role should be a GoogleDrivePermissionRole instance' 115 | ) 116 | if not isinstance(g_type, GoogleDrivePermissionType): 117 | raise ValueError( 118 | 'Permission should be a GoogleDrivePermissionType instance' 119 | ) 120 | if g_value is not None and not isinstance(g_value, str): 121 | raise ValueError('Value should be a String instance') 122 | 123 | self._role = g_role 124 | self._type = g_type 125 | self._value = g_value 126 | 127 | 128 | _ANYONE_CAN_READ_PERMISSION_ = GoogleDriveFilePermission( 129 | GoogleDrivePermissionRole.READER, 130 | GoogleDrivePermissionType.ANYONE 131 | ) 132 | 133 | 134 | @deconstructible 135 | class GoogleDriveStorage(Storage): 136 | """ 137 | Storage class for Django that interacts with Google Drive as persistent 138 | storage. 139 | This class uses a system account for Google API that create an 140 | application drive (the drive is not owned by any Google User, but it is 141 | owned by the application declared on Google API console). 142 | """ 143 | 144 | _UNKNOWN_MIMETYPE_ = 'application/octet-stream' 145 | _GOOGLE_DRIVE_FOLDER_MIMETYPE_ = 'application/vnd.google-apps.folder' 146 | KEY_FILE_PATH = 'GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE' 147 | KEY_FILE_CONTENT = 'GOOGLE_DRIVE_STORAGE_JSON_KEY_FILE_CONTENTS' 148 | 149 | def __init__(self, json_keyfile_path=None, permissions=None): 150 | """ 151 | Handles credentials and builds the google service. 152 | 153 | :param json_keyfile_path: Path 154 | :raise ValueError: 155 | """ 156 | settings_keyfile_path = getattr(settings, self.KEY_FILE_PATH, None) 157 | self._json_keyfile_path = json_keyfile_path or settings_keyfile_path 158 | 159 | if self._json_keyfile_path: 160 | credentials = Credentials.from_service_account_file( 161 | self._json_keyfile_path, 162 | scopes=['https://www.googleapis.com/auth/drive'], 163 | ) 164 | else: 165 | credentials = Credentials.from_service_account_info( 166 | json.loads(os.environ[self.KEY_FILE_CONTENT]), 167 | scopes=['https://www.googleapis.com/auth/drive'], 168 | ) 169 | 170 | self._permissions = None 171 | if permissions is None: 172 | self._permissions = (_ANYONE_CAN_READ_PERMISSION_,) 173 | else: 174 | if not isinstance(permissions, (tuple, list,)): 175 | raise ValueError( 176 | 'Permissions should be a list or a tuple of ' 177 | 'GoogleDriveFilePermission instances' 178 | ) 179 | else: 180 | for p in permissions: 181 | if not isinstance(p, GoogleDriveFilePermission): 182 | raise ValueError( 183 | 'Permissions should be a list or a tuple of ' 184 | 'GoogleDriveFilePermission instances' 185 | ) 186 | # Ok, permissions are good 187 | self._permissions = permissions 188 | 189 | self._drive_service = build('drive', 'v3', credentials=credentials) 190 | 191 | def _split_path(self, p): 192 | """ 193 | Split a complete path in a list of strings 194 | 195 | :param p: Path to be splitted 196 | :type p: string 197 | :returns: list - List of strings that composes the path 198 | """ 199 | p = p[1:] if p[0] == '/' else p 200 | a, b = os.path.split(p) 201 | return (self._split_path(a) if len(a) and len(b) else []) + [b] 202 | 203 | def _get_or_create_folder(self, path, parent_id=None): 204 | """ 205 | Create a folder on Google Drive. 206 | It creates folders recursively. 207 | If the folder already exists, it retrieves only the unique identifier. 208 | 209 | :param path: Path that had to be created 210 | :type path: string 211 | :param parent_id: Unique identifier for its parent (folder) 212 | :type parent_id: string 213 | :returns: dict 214 | """ 215 | folder_data = self._check_file_exists(path, parent_id) 216 | if folder_data is not None: 217 | return folder_data 218 | 219 | # Folder does not exists, have to create 220 | split_path = self._split_path(path) 221 | 222 | if split_path[:-1]: 223 | parent_path = os.path.join(*split_path[:-1]) 224 | current_folder_data = self._get_or_create_folder( 225 | parent_path, parent_id=parent_id 226 | ) 227 | else: 228 | current_folder_data = None 229 | 230 | meta_data = { 231 | 'name': split_path[-1], 232 | 'mimeType': self._GOOGLE_DRIVE_FOLDER_MIMETYPE_ 233 | } 234 | if current_folder_data is not None: 235 | meta_data['parents'] = [current_folder_data['id']] 236 | else: 237 | # This is the first iteration loop so we have to set 238 | # the parent_id obtained by the user, if available 239 | if parent_id is not None: 240 | meta_data['parents'] = [parent_id] 241 | current_folder_data = self._drive_service.files().create( 242 | body=meta_data).execute() 243 | return current_folder_data 244 | 245 | def _check_file_exists(self, filename, parent_id=None): 246 | """ 247 | Check if a file with specific parameters exists in Google Drive. 248 | :param filename: File or folder to search 249 | :type filename: string 250 | :param parent_id: Unique identifier for its parent (folder) 251 | :type parent_id: string 252 | :returns: dict containing file / folder data if exists or None if does not exists 253 | """ # noqa: E501 254 | if len(filename) == 0: 255 | # This is the lack of directory at the beginning of a 'file.txt' 256 | # Since the target file lacks directories, the assumption 257 | # is that it belongs at '/' 258 | return self._drive_service.files().get(fileId='root').execute() 259 | split_filename = self._split_path(filename) 260 | if len(split_filename) > 1: 261 | # This is an absolute path with folder inside 262 | # First check if the first element exists as a folder 263 | # If so call the method recursively with next portion of path 264 | # Otherwise the path does not exists hence 265 | # the file does not exists 266 | q = "mimeType = '{0}' and name = '{1}'".format( 267 | self._GOOGLE_DRIVE_FOLDER_MIMETYPE_, split_filename[0], 268 | ) 269 | if parent_id is not None: 270 | q = "{0} and '{1}' in parents".format(q, parent_id) 271 | results = self._drive_service.files().list( 272 | q=q, fields='nextPageToken, files(*)').execute() 273 | items = results.get('files', []) 274 | for item in items: 275 | if item['name'] == split_filename[0]: 276 | # Assuming every folder has a single parent 277 | return self._check_file_exists( 278 | os.path.sep.join(split_filename[1:]), item['id']) 279 | return None 280 | # This is a file, checking if exists 281 | q = "name = '{0}'".format(split_filename[0]) 282 | if parent_id is not None: 283 | q = "{0} and '{1}' in parents".format(q, parent_id) 284 | results = self._drive_service.files().list( 285 | q=q, fields='nextPageToken, files(*)').execute() 286 | items = results.get('files', []) 287 | if len(items) > 0: 288 | return items[0] 289 | q = '' if parent_id is None else "'{0}' in parents".format(parent_id) 290 | results = self._drive_service.files().list( 291 | q=q, fields='nextPageToken, files(*)').execute() 292 | items = results.get('files', []) 293 | for item in items: 294 | if split_filename[0] in item['name']: 295 | return item 296 | return None 297 | 298 | # Methods that had to be implemented 299 | # to create a valid storage for Django 300 | 301 | def _open(self, name, mode='rb'): 302 | """For more details see 303 | https://developers.google.com/drive/api/v3/manage-downloads?hl=id#download_a_file_stored_on_google_drive 304 | """ # noqa: E501 305 | file_data = self._check_file_exists(name) 306 | request = self._drive_service.files().get_media( 307 | fileId=file_data['id']) 308 | fh = BytesIO() 309 | downloader = MediaIoBaseDownload(fh, request) 310 | done = False 311 | while done is False: 312 | _, done = downloader.next_chunk() 313 | fh.seek(0) 314 | return File(fh, name) 315 | 316 | def _save(self, name, content): 317 | name = os.path.join(settings.GOOGLE_DRIVE_STORAGE_MEDIA_ROOT, name) 318 | folder_path = os.path.sep.join(self._split_path(name)[:-1]) 319 | folder_data = self._get_or_create_folder(folder_path) 320 | parent_id = None if folder_data is None else folder_data['id'] 321 | # Now we had created (or obtained) folder on GDrive 322 | # Upload the file 323 | mime_type, _ = mimetypes.guess_type(name) 324 | if mime_type is None: 325 | mime_type = self._UNKNOWN_MIMETYPE_ 326 | media_body = MediaIoBaseUpload( 327 | content.file, mime_type, resumable=True, chunksize=1024 * 512) 328 | body = { 329 | 'name': self._split_path(name)[-1], 330 | 'mimeType': mime_type 331 | } 332 | # Set the parent folder. 333 | if parent_id: 334 | body['parents'] = [parent_id] 335 | file_data = self._drive_service.files().create( 336 | body=body, 337 | media_body=media_body).execute() 338 | 339 | # Setting up permissions 340 | for p in self._permissions: 341 | self._drive_service.permissions().create( 342 | fileId=file_data['id'], body={**p.raw}).execute() 343 | 344 | return file_data.get('originalFilename', file_data.get('name')) 345 | 346 | def delete(self, name): 347 | """ 348 | Deletes the specified file from the storage system. 349 | """ 350 | file_data = self._check_file_exists(name) 351 | if file_data is not None: 352 | self._drive_service.files().delete( 353 | fileId=file_data['id']).execute() 354 | 355 | def exists(self, name): 356 | """ 357 | Returns True if a file referenced by the given name already exists 358 | in the storage system, or False if the name is available for 359 | a new file. 360 | """ 361 | return self._check_file_exists(name) is not None 362 | 363 | def listdir(self, path): 364 | """ 365 | Lists the contents of the specified path, returning a 2-tuple of lists; 366 | the first item being directories, the second item being files. 367 | """ 368 | directories, files = [], [] 369 | if path == '/': 370 | folder_id = {'id': 'root'} 371 | else: 372 | folder_id = self._check_file_exists(path) 373 | if folder_id: 374 | file_params = { 375 | 'q': "'{0}' in parents and mimeType != '{1}'".format( 376 | folder_id['id'], self._GOOGLE_DRIVE_FOLDER_MIMETYPE_), 377 | } 378 | dir_params = { 379 | 'q': "'{0}' in parents and mimeType = '{1}'".format( 380 | folder_id['id'], self._GOOGLE_DRIVE_FOLDER_MIMETYPE_), 381 | } 382 | files_results = self._drive_service.files().list(**file_params).execute() # noqa: E501 383 | dir_results = self._drive_service.files().list(**dir_params).execute() # noqa: E501 384 | files_list = files_results.get('files', []) 385 | dir_list = dir_results.get('files', []) 386 | for element in files_list: 387 | files.append(os.path.join(path, element['name'])) 388 | for element in dir_list: 389 | directories.append(os.path.join(path, element['name'])) 390 | return directories, files 391 | 392 | def size(self, name): 393 | """ 394 | Returns the total size, in bytes, of the file specified by name. 395 | """ 396 | file_data = self._check_file_exists(name) 397 | if file_data is None: 398 | return 0 399 | return file_data['size'] 400 | 401 | def url(self, name): 402 | """ 403 | Returns an absolute URL where the file's contents can be accessed 404 | directly by a Web browser. 405 | """ 406 | file_data = self._check_file_exists(name) 407 | if file_data is None: 408 | return None 409 | return file_data['webContentLink'] 410 | 411 | def accessed_time(self, name): 412 | """ 413 | Returns the last accessed time (as datetime object) of the file 414 | specified by name. 415 | """ 416 | return self.modified_time(name) 417 | 418 | def created_time(self, name): 419 | """ 420 | Returns the creation time (as datetime object) of the file 421 | specified by name. 422 | """ 423 | file_data = self._check_file_exists(name) 424 | if file_data is None: 425 | return None 426 | return parse(file_data['createdDate']) 427 | 428 | def modified_time(self, name): 429 | """ 430 | Returns the last modified time (as datetime object) of the file 431 | specified by name. 432 | """ 433 | file_data = self._check_file_exists(name) 434 | if file_data is None: 435 | return None 436 | return parse(file_data['modifiedDate']) 437 | 438 | def deconstruct(self): 439 | """ 440 | Handle field serialization to support migration 441 | """ 442 | name, path, args, kwargs = super().deconstruct() 443 | if self._service_email is not None: 444 | kwargs['service_email'] = self._service_email 445 | if self._json_keyfile_path is not None: 446 | kwargs['json_keyfile_path'] = self._json_keyfile_path 447 | return name, path, args, kwargs 448 | --------------------------------------------------------------------------------