Repository: cookiecutter/cookiecutter-django
Pull request: Add Azure Storage as an option to serve static and media files
## Description
Add Azure Storage as an option to serve static and media files

Checklist:
- [X] I've made sure that tests are updated accordingly (especially if adding or updating a template option)
- [X] I've updated the documentation or confirm that my change doesn't require any updates

## Rationale
Fix #2301


Problem statement:
Add Blob Storage as an option for static and media files
## Description

[//]: # (What's it you're proposing? How should it be implemented?)
Can I add Blob Storage (Microsoft Azure) as an option to serve static and media files?



## Rationale

[//]: # (Why should this feature be implemented?)
I always use Amazon S3 for serving static and media files, but now I have a client that has a preference for Blob Storage (Microsoft Azure), so I thought about adding this as an option because it can be helpful for other developers.



## Use case(s) / visualization(s)

[//]: # ("Better to see something once than to hear about it a thousand times.")
In my case, I have to use it because a client has a preference for it. But maybe someone feels more comfortable using Azure instead of AWS or Google Cloud.

I can make the PR if you all think it's ok.

Hints from issue discussion:
Not sure how I didn't get around to reply, but yes, this would be a welcomed addition. PR welcome 👍🏻 
@Jeanluis019: I know it has been a while but are you still interested in this? We could collaborate on a PR.

I am doing something naive like looking at GCP (for an example) and seeing what could need changing. It doesn't seem like much at all.


````
$ grep -riI gcp ./*
./config/settings/production.py:GS_BUCKET_NAME = env("DJANGO_GCP_STORAGE_BUCKET_NAME")

$ grep -riI google ./*
./config/settings/production.py:STATICFILES_STORAGE = "project.utils.storages.StaticRootGoogleCloudStorage"
./config/settings/production.py:COLLECTFAST_STRATEGY = "collectfast.strategies.gcloud.GoogleCloudStrategy"
./config/settings/production.py:STATIC_URL = f"https://storage.googleapis.com/{GS_BUCKET_NAME}/static/"
./config/settings/production.py:DEFAULT_FILE_STORAGE = "project.utils.storages.MediaRootGoogleCloudStorage"
./config/settings/production.py:MEDIA_URL = f"https://storage.googleapis.com/{GS_BUCKET_NAME}/media/"
./docs/howto.rst:Numpy or Google style docstrings will be picked up from project files and available for documentation. See the `Napoleon <https://sphinxcontrib-napoleon.readthedocs.io/en/latest/>`_ extension for details.
./project/utils/storages.py:from storages.backends.gcloud import GoogleCloudStorage
./project/utils/storages.py:class StaticRootGoogleCloudStorage(GoogleCloudStorage):
./project/utils/storages.py:class MediaRootGoogleCloudStorage(GoogleCloudStorage):
./requirements/production.txt:django-storages[google]==1.12.3  # https://github.com/jschneier/django-storages
````

Commit message: Add Azure Storage as an option to serve static and media files

Patch to review:
diff --git a/README.md b/README.md
index 9f2af6d37e..27fc6e0e1f 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ production-ready Django projects quickly.
 -   Optional basic ASGI setup for Websockets
 -   Optional custom static build using Gulp and livereload
 -   Send emails via [Anymail](https://github.com/anymail/django-anymail) (using [Mailgun](http://www.mailgun.com/) by default or Amazon SES if AWS is selected cloud provider, but switchable)
--   Media storage using Amazon S3 or Google Cloud Storage
+-   Media storage using Amazon S3, Google Cloud Storage or Azure Storage
 -   Docker support using [docker-compose](https://github.com/docker/compose) for development and production (using [Traefik](https://traefik.io/) with [LetsEncrypt](https://letsencrypt.org/) support)
 -   [Procfile](https://devcenter.heroku.com/articles/procfile) for deploying to Heroku
 -   Instructions for deploying to [PythonAnywhere](https://www.pythonanywhere.com/)
@@ -41,7 +41,7 @@ production-ready Django projects quickly.
 
 *These features can be enabled during initial project setup.*
 
--   Serve static files from Amazon S3, Google Cloud Storage or [Whitenoise](https://whitenoise.readthedocs.io/)
+-   Serve static files from Amazon S3, Google Cloud Storage, Azure Storage or [Whitenoise](https://whitenoise.readthedocs.io/)
 -   Configuration for [Celery](https://docs.celeryq.dev) and [Flower](https://github.com/mher/flower) (the latter in Docker setup only)
 -   Integration with [MailHog](https://github.com/mailhog/MailHog) for local email testing
 -   Integration with [Sentry](https://sentry.io/welcome/) for error logging
diff --git a/cookiecutter.json b/cookiecutter.json
index 90d084b3ac..9844e7121e 100644
--- a/cookiecutter.json
+++ b/cookiecutter.json
@@ -27,6 +27,7 @@
   "cloud_provider": [
     "AWS",
     "GCP",
+    "AZURE",
     "None"
   ],
   "mail_service": [
diff --git a/docs/project-generation-options.rst b/docs/project-generation-options.rst
index 2d8103cf2f..67c68fa4a0 100644
--- a/docs/project-generation-options.rst
+++ b/docs/project-generation-options.rst
@@ -66,7 +66,8 @@ cloud_provider:
 
     1. AWS_
     2. GCP_
-    3. None
+    3. AZURE_
+    4. None
 
     Note that if you choose no cloud provider, media files won't work.
 
diff --git a/docs/settings.rst b/docs/settings.rst
index b8c6d448e4..0f35495ced 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -49,6 +49,9 @@ DJANGO_AWS_S3_CUSTOM_DOMAIN             AWS_S3_CUSTOM_DOMAIN        n/a
 DJANGO_AWS_S3_MAX_MEMORY_SIZE           AWS_S3_MAX_MEMORY_SIZE      n/a                                            100_000_000
 DJANGO_GCP_STORAGE_BUCKET_NAME          GS_BUCKET_NAME              n/a                                            raises error
 GOOGLE_APPLICATION_CREDENTIALS          n/a                         n/a                                            raises error
+DJANGO_AZURE_STORAGE_ACCOUNT_KEY        AZURE_ACCOUNT_KEY           n/a                                            raises error
+DJANGO_AZURE_STORAGE_ACCOUNT_NAME       AZURE_ACCOUNT_NAME          n/a                                            raises error
+DJANGO_AZURE_CONTAINER_NAME             AZURE_CONTAINER             n/a                                            raises error
 SENTRY_DSN                              SENTRY_DSN                  n/a                                            raises error
 SENTRY_ENVIRONMENT                      n/a                         n/a                                            production
 SENTRY_TRACES_SAMPLE_RATE               n/a                         n/a                                            0.0
diff --git a/hooks/pre_gen_project.py b/hooks/pre_gen_project.py
index 2845f012cd..527e74f507 100644
--- a/hooks/pre_gen_project.py
+++ b/hooks/pre_gen_project.py
@@ -72,11 +72,18 @@
     sys.exit(1)
 
 if (
-    "{{ cookiecutter.cloud_provider }}" == "GCP"
-    and "{{ cookiecutter.mail_service }}" == "Amazon SES"
-) or (
-    "{{ cookiecutter.cloud_provider }}" == "None"
-    and "{{ cookiecutter.mail_service }}" == "Amazon SES"
+    (
+        "{{ cookiecutter.cloud_provider }}" == "GCP"
+        and "{{ cookiecutter.mail_service }}" == "Amazon SES"
+    )
+    or (
+        "{{ cookiecutter.cloud_provider }}" == "AZURE"
+        and "{{ cookiecutter.mail_service }}" == "Amazon SES"
+    )
+    or (
+        "{{ cookiecutter.cloud_provider }}" == "None"
+        and "{{ cookiecutter.mail_service }}" == "Amazon SES"
+    )
 ):
     print(
         "You should either use AWS or select a different "
diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py
index 3a757fcb7b..f4ff14c3cf 100755
--- a/tests/test_cookiecutter_generation.py
+++ b/tests/test_cookiecutter_generation.py
@@ -56,6 +56,8 @@ def context():
     {"cloud_provider": "AWS", "use_whitenoise": "n"},
     {"cloud_provider": "GCP", "use_whitenoise": "y"},
     {"cloud_provider": "GCP", "use_whitenoise": "n"},
+    {"cloud_provider": "AZURE", "use_whitenoise": "y"},
+    {"cloud_provider": "AZURE", "use_whitenoise": "n"},
     {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "Mailgun"},
     {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "Mailjet"},
     {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "Mandrill"},
@@ -82,7 +84,16 @@ def context():
     {"cloud_provider": "GCP", "mail_service": "SendinBlue"},
     {"cloud_provider": "GCP", "mail_service": "SparkPost"},
     {"cloud_provider": "GCP", "mail_service": "Other SMTP"},
-    # Note: cloud_providers GCP and None with mail_service Amazon SES is not supported
+    {"cloud_provider": "AZURE", "mail_service": "Mailgun"},
+    {"cloud_provider": "AZURE", "mail_service": "Mailjet"},
+    {"cloud_provider": "AZURE", "mail_service": "Mandrill"},
+    {"cloud_provider": "AZURE", "mail_service": "Postmark"},
+    {"cloud_provider": "AZURE", "mail_service": "Sendgrid"},
+    {"cloud_provider": "AZURE", "mail_service": "SendinBlue"},
+    {"cloud_provider": "AZURE", "mail_service": "SparkPost"},
+    {"cloud_provider": "AZURE", "mail_service": "Other SMTP"},
+    # Note: cloud_providers GCP, AZURE, and None
+    # with mail_service Amazon SES is not supported
     {"use_async": "y"},
     {"use_async": "n"},
     {"use_drf": "y"},
@@ -113,6 +124,7 @@ def context():
 UNSUPPORTED_COMBINATIONS = [
     {"cloud_provider": "None", "use_whitenoise": "n"},
     {"cloud_provider": "GCP", "mail_service": "Amazon SES"},
+    {"cloud_provider": "AZURE", "mail_service": "Amazon SES"},
     {"cloud_provider": "None", "mail_service": "Amazon SES"},
 ]
 
diff --git a/{{cookiecutter.project_slug}}/.envs/.production/.django b/{{cookiecutter.project_slug}}/.envs/.production/.django
index e7e8461c96..8eb9b38bf3 100644
--- a/{{cookiecutter.project_slug}}/.envs/.production/.django
+++ b/{{cookiecutter.project_slug}}/.envs/.production/.django
@@ -44,6 +44,12 @@ DJANGO_AWS_STORAGE_BUCKET_NAME=
 # ------------------------------------------------------------------------------
 GOOGLE_APPLICATION_CREDENTIALS=
 DJANGO_GCP_STORAGE_BUCKET_NAME=
+{% elif cookiecutter.cloud_provider == 'AZURE' %}
+# AZURE
+# ------------------------------------------------------------------------------
+DJANGO_AZURE_STORAGE_ACCOUNT_KEY=
+DJANGO_AZURE_STORAGE_ACCOUNT_NAME=
+DJANGO_AZURE_CONTAINER_NAME=
 {% endif %}
 # django-allauth
 # ------------------------------------------------------------------------------
diff --git a/{{cookiecutter.project_slug}}/config/settings/production.py b/{{cookiecutter.project_slug}}/config/settings/production.py
index 8eed5a805c..308eecd5bb 100644
--- a/{{cookiecutter.project_slug}}/config/settings/production.py
+++ b/{{cookiecutter.project_slug}}/config/settings/production.py
@@ -100,6 +100,10 @@
 {% elif cookiecutter.cloud_provider == 'GCP' %}
 GS_BUCKET_NAME = env("DJANGO_GCP_STORAGE_BUCKET_NAME")
 GS_DEFAULT_ACL = "publicRead"
+{% elif cookiecutter.cloud_provider == 'AZURE' %}
+AZURE_ACCOUNT_KEY = env("DJANGO_AZURE_STORAGE_ACCOUNT_KEY")
+AZURE_ACCOUNT_NAME = env("DJANGO_AZURE_STORAGE_ACCOUNT_NAME")
+AZURE_CONTAINER = env("DJANGO_AZURE_CONTAINER_NAME")
 {% endif -%}
 
 {% if cookiecutter.cloud_provider != 'None' or cookiecutter.use_whitenoise == 'y' -%}
@@ -116,6 +120,9 @@
 STATICFILES_STORAGE = "{{cookiecutter.project_slug}}.utils.storages.StaticRootGoogleCloudStorage"
 COLLECTFAST_STRATEGY = "collectfast.strategies.gcloud.GoogleCloudStrategy"
 STATIC_URL = f"https://storage.googleapis.com/{GS_BUCKET_NAME}/static/"
+{% elif cookiecutter.cloud_provider == 'AZURE' -%}
+STATICFILES_STORAGE = "{{cookiecutter.project_slug}}.utils.storages.StaticRootAzureStorage"
+STATIC_URL = f"https://{AZURE_ACCOUNT_NAME}.blob.core.windows.net/static/"
 {% endif -%}
 
 # MEDIA
@@ -126,6 +133,9 @@
 {%- elif cookiecutter.cloud_provider == 'GCP' %}
 DEFAULT_FILE_STORAGE = "{{cookiecutter.project_slug}}.utils.storages.MediaRootGoogleCloudStorage"
 MEDIA_URL = f"https://storage.googleapis.com/{GS_BUCKET_NAME}/media/"
+{%- elif cookiecutter.cloud_provider == 'AZURE' %}
+DEFAULT_FILE_STORAGE = "{{cookiecutter.project_slug}}.utils.storages.MediaRootAzureStorage"
+MEDIA_URL = f"https://{AZURE_ACCOUNT_NAME}.blob.core.windows.net/media/"
 {%- endif %}
 
 # EMAIL
@@ -228,7 +238,7 @@
 {%- if cookiecutter.cloud_provider == 'None' %}
 # https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_STORAGE
 COMPRESS_STORAGE = "compressor.storage.GzipCompressorFileStorage"
-{%- elif cookiecutter.cloud_provider in ('AWS', 'GCP') and cookiecutter.use_whitenoise == 'n' %}
+{%- elif cookiecutter.cloud_provider in ('AWS', 'GCP', 'AZURE') and cookiecutter.use_whitenoise == 'n' %}
 # https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_STORAGE
 COMPRESS_STORAGE = STATICFILES_STORAGE
 {%- endif %}
diff --git a/{{cookiecutter.project_slug}}/requirements/production.txt b/{{cookiecutter.project_slug}}/requirements/production.txt
index 275633dd4a..fbaaaaec18 100644
--- a/{{cookiecutter.project_slug}}/requirements/production.txt
+++ b/{{cookiecutter.project_slug}}/requirements/production.txt
@@ -20,6 +20,8 @@ hiredis==2.0.0  # https://github.com/redis/hiredis-py
 django-storages[boto3]==1.13.1  # https://github.com/jschneier/django-storages
 {%- elif cookiecutter.cloud_provider == 'GCP' %}
 django-storages[google]==1.13.1  # https://github.com/jschneier/django-storages
+{%- elif cookiecutter.cloud_provider == 'AZURE' %}
+django-storages[azure]==1.13.1  # https://github.com/jschneier/django-storages
 {%- endif %}
 {%- if cookiecutter.mail_service == 'Mailgun' %}
 django-anymail[mailgun]==8.6  # https://github.com/anymail/django-anymail
diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/utils/storages.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/utils/storages.py
index b712d3239d..25db7011a7 100644
--- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/utils/storages.py
+++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/utils/storages.py
@@ -22,4 +22,15 @@ class StaticRootGoogleCloudStorage(GoogleCloudStorage):
 class MediaRootGoogleCloudStorage(GoogleCloudStorage):
     location = "media"
     file_overwrite = False
+{%- elif cookiecutter.cloud_provider == 'AZURE' -%}
+from storages.backends.azure_storage import AzureStorage
+
+
+class StaticRootAzureStorage(AzureStorage):
+    location = "static"
+
+
+class MediaRootAzureStorage(AzureStorage):
+    location = "media"
+    file_overwrite = False
 {%- endif %}


Code review comments:
diff_hunk: @@ -27,6 +27,7 @@
   "cloud_provider": [
     "AWS",
     "GCP",
+    "AZURE",
path: cookiecutter.json
text: @user1:
The other 2 (AWS and GCP) are in capital letters because they are acronyms. I think the name "Azure" is a single word. Shall we change that to `Azure` (not all caps)? 

@author:
Yep, I've updated all the references.

diff_hunk: @@ -49,6 +49,9 @@ DJANGO_AWS_S3_CUSTOM_DOMAIN             AWS_S3_CUSTOM_DOMAIN        n/a
 DJANGO_AWS_S3_MAX_MEMORY_SIZE           AWS_S3_MAX_MEMORY_SIZE      n/a                                            100_000_000
 DJANGO_GCP_STORAGE_BUCKET_NAME          GS_BUCKET_NAME              n/a                                            raises error
 GOOGLE_APPLICATION_CREDENTIALS          n/a                         n/a                                            raises error
+DJANGO_AZURE_STORAGE_ACCOUNT_KEY        AZURE_ACCOUNT_KEY           n/a                                            raises error
+DJANGO_AZURE_STORAGE_ACCOUNT_NAME       AZURE_ACCOUNT_NAME          n/a                                            raises error
path: docs/settings.rst
text: @user1:
Shall we just call them `DJANGO_AZURE_ACCOUNT_KEY` and `DJANGO_AZURE_ACCOUNT_NAME`?

@author:
I thought ACCOUNT_NAME and ACCOUNT_KEY was a bit ambiguous, but I agree that it makes sense to align with what `django-storages` uses.

diff_hunk: @@ -66,7 +66,8 @@ cloud_provider:
 
     1. AWS_
     2. GCP_
-    3. None
+    3. AZURE_
path: docs/project-generation-options.rst
text: @user1:
In restructured text, this is a link, which target should be specified later in the file:

https://github.com/cookiecutter/cookiecutter-django/blame/0a8b15615a51f09083ce5386c4e09c5fe2ed2757/docs/project-generation-options.rst#L148-L149

@author:
Thanks for catching this, updated!

diff_hunk: @@ -72,11 +72,18 @@
     sys.exit(1)
 
 if (
-    "{{ cookiecutter.cloud_provider }}" == "GCP"
-    and "{{ cookiecutter.mail_service }}" == "Amazon SES"
-) or (
-    "{{ cookiecutter.cloud_provider }}" == "None"
-    and "{{ cookiecutter.mail_service }}" == "Amazon SES"
+    (
+        "{{ cookiecutter.cloud_provider }}" == "GCP"
+        and "{{ cookiecutter.mail_service }}" == "Amazon SES"
+    )
+    or (
+        "{{ cookiecutter.cloud_provider }}" == "AZURE"
+        and "{{ cookiecutter.mail_service }}" == "Amazon SES"
+    )
+    or (
+        "{{ cookiecutter.cloud_provider }}" == "None"
+        and "{{ cookiecutter.mail_service }}" == "Amazon SES"
+    )
path: hooks/pre_gen_project.py
text: @user1:
Maybe it's time to simplify this?

```suggestion
    (
        "{{ cookiecutter.mail_service }}" == "Amazon SES" 
        and "{{ cookiecutter.cloud_provider }}" != "AWS"
    )
```

@author:
Good point, updated!