diff --git a/cookiecutter.json b/cookiecutter.json
index 66b5f2c..dbc7c9e 100644
--- a/cookiecutter.json
+++ b/cookiecutter.json
@@ -20,5 +20,7 @@
"sass_bootstrap": ["enabled", "disabled"],
"security_settings": ["enabled", "disabled"],
"sentry": ["enabled", "disabled"],
- "user_action_tracking": ["enabled", "disabled"]
+ "user_action_tracking": ["enabled", "disabled"],
+ "direct_upload": ["enabled", "disabled"],
+ "vue": ["enabled", "disabled"]
}
diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py
index 28c91c3..9af9b9c 100644
--- a/hooks/post_gen_project.py
+++ b/hooks/post_gen_project.py
@@ -21,7 +21,7 @@
"{% if cookiecutter.pre_commit == 'disabled' %}.pre-commit-config.yaml{% endif %}",
"{% if cookiecutter.django_react == 'disabled' %}config/webpack_loader.py{% endif %}",
"{% if cookiecutter.django_react == 'disabled' %}nwb.config.js{% endif %}",
- "{% if cookiecutter.django_react == 'disabled' and cookiecutter.sass_bootstrap == 'disabled' %}package.json{% endif %}",
+ "{% if cookiecutter.django_react == 'disabled' and cookiecutter.sass_bootstrap == 'disabled' and cookiecutter.vue == 'disabled' %}package.json{% endif %}",
]
diff --git a/{{cookiecutter.project_slug}}/.gitignore b/{{cookiecutter.project_slug}}/.gitignore
index d04ca68..bf6c2b3 100644
--- a/{{cookiecutter.project_slug}}/.gitignore
+++ b/{{cookiecutter.project_slug}}/.gitignore
@@ -54,6 +54,7 @@ venv.bak/
node_modules/
# Compiled Files
+static/js/dist*
staticfiles/
staticfiles/*
# START_FEATURE sass_bootstrap
@@ -65,7 +66,9 @@ static/webpack_bundles/
webpack-stats.json
# END_FEATURE django_react
+# Locally uploaded files
+media/
+
# START_FEATURE ecs
-.terraform.lock.hcl
.terraform/
# END_FEATURE ecs
\ No newline at end of file
diff --git a/{{cookiecutter.project_slug}}/.python-version b/{{cookiecutter.project_slug}}/.python-version
new file mode 100644
index 0000000..e4fba21
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/{{cookiecutter.project_slug}}/app/__init__.py b/{{cookiecutter.project_slug}}/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/{{cookiecutter.project_slug}}/app/admin.py b/{{cookiecutter.project_slug}}/app/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/{{cookiecutter.project_slug}}/app/apps.py b/{{cookiecutter.project_slug}}/app/apps.py
new file mode 100644
index 0000000..ed327d2
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class AppConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'app'
diff --git a/{{cookiecutter.project_slug}}/app/constants.py b/{{cookiecutter.project_slug}}/app/constants.py
new file mode 100644
index 0000000..363decd
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/constants.py
@@ -0,0 +1,11 @@
+SAMPLE_OBJECT_PK_URL_KWARG = "sample_object_id"
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+
+# START_FEATURE direct_upload
+{%- endif %}
+ATTACHMENT_PK_URL_KWARG = "attachment_id"
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/app/forms.py b/{{cookiecutter.project_slug}}/app/forms.py
new file mode 100644
index 0000000..e82158a
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/forms.py
@@ -0,0 +1,63 @@
+{%- if cookiecutter.crispy_forms == "enabled" %}
+from crispy_forms.helper import Layout
+from crispy_forms.layout import Fieldset
+from django import forms
+from django.http import HttpRequest
+from app.models import SampleObject
+{%- if cookiecutter.direct_upload == "enabled" %}
+from app.models import Attachment
+from common.fields import DirectUploadFileField
+{%- endif %}
+from common.forms import ActionFormMixin, CrispyFormMixin
+
+
+class SampleObjectBaseForm(CrispyFormMixin, ActionFormMixin, forms.ModelForm):
+ request: HttpRequest
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+
+ # START_FEATURE direct_upload
+ {%- endif %}
+ attachments = DirectUploadFileField(queryset=Attachment.objects.filter(deleted_on=None), required=False)
+ {%- if cookiecutter.feature_annotations == "on" %}
+ # END_FEATURE direct_upload
+ {%- endif %}
+ {%- endif %}
+
+ class Meta:
+ model = SampleObject
+ exclude = ['created_by']
+
+ layout = Layout(
+ Fieldset(
+ "Details",
+ "name",
+ "description"
+ ),
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ # START_FEATURE direct_upload
+ {%- endif %}
+ "attachments"
+ {%- if cookiecutter.feature_annotations == "on" %}
+ # END_FEATURE direct_upload
+ {%- endif %}
+ {%- endif %}
+ )
+
+ def __init__(self, request: HttpRequest, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.request = request
+
+
+class SampleObjectCreateForm(SampleObjectBaseForm):
+ action_title = "Create Sample Object"
+
+ def save(self, commit=True):
+ self.instance.created_by = self.request.user
+ return super().save(commit)
+
+
+class SampleObjectEditForm(SampleObjectBaseForm):
+ action_title = "Edit {instance}"
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/app/migrations/0001_initial.py b/{{cookiecutter.project_slug}}/app/migrations/0001_initial.py
new file mode 100644
index 0000000..4b2fad6
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/migrations/0001_initial.py
@@ -0,0 +1,48 @@
+# Generated by Django 5.2 on 2026-03-16 18:26
+
+import uuid
+from django.db import migrations, models
+{%- if cookiecutter.django_storages == "enabled" %}
+import common.models
+{%- endif %}
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ migrations.CreateModel(
+ name='Attachment',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_on', models.DateTimeField(auto_now_add=True)),
+ ('updated_on', models.DateTimeField(auto_now=True)),
+ ('name', models.CharField(max_length=512)),
+ ('file', models.FileField(max_length=1024, upload_to=common.models.get_upload_prefix)),
+ ('upload_completed_on', models.DateTimeField(null=True)),
+ ('deleted_on', models.DateTimeField(null=True)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ {%- endif %}
+ migrations.CreateModel(
+ name='SampleObject',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_on', models.DateTimeField(auto_now_add=True)),
+ ('updated_on', models.DateTimeField(auto_now=True)),
+ ('name', models.CharField(max_length=512, unique=True)),
+ ('description', models.TextField(blank=True, default='')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/{{cookiecutter.project_slug}}/app/migrations/0002_initial.py b/{{cookiecutter.project_slug}}/app/migrations/0002_initial.py
new file mode 100644
index 0000000..2f066e8
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/migrations/0002_initial.py
@@ -0,0 +1,35 @@
+# Generated by Django 5.2 on 2026-03-16 18:26
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('app', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ migrations.AddField(
+ model_name='attachment',
+ name='user',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='files', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddField(
+ model_name='sampleobject',
+ name='attachments',
+ field=models.ManyToManyField(related_name='sample_objects', to='app.attachment'),
+ ),
+ {%- endif %}
+ migrations.AddField(
+ model_name='sampleobject',
+ name='created_by',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sample_objects', to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/{{cookiecutter.project_slug}}/app/migrations/__init__.py b/{{cookiecutter.project_slug}}/app/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/{{cookiecutter.project_slug}}/app/models.py b/{{cookiecutter.project_slug}}/app/models.py
new file mode 100644
index 0000000..8e1f91b
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/models.py
@@ -0,0 +1,48 @@
+from django.db import models
+
+from common.models import TimestampedModel, User
+{%- if cookiecutter.django_storages == "enabled" %}
+from common.models import UploadFile
+{%- endif %}
+
+
+class SampleObject(TimestampedModel):
+ created_by = models.ForeignKey(User, related_name="sample_objects", on_delete=models.PROTECT)
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+
+ # START_FEATURE direct_upload
+ {%- endif %}
+ attachments = models.ManyToManyField("Attachment", related_name="sample_objects")
+ {%- if cookiecutter.feature_annotations == "on" %}
+ # END_FEATURE direct_upload
+ {%- endif %}
+ {%- endif %}
+
+ name = models.CharField(max_length=512, unique=True)
+ description = models.TextField(default="", blank=True)
+
+ def __str__(self) -> str:
+ return f'Sample Object {self.name}'
+ {%- if cookiecutter.direct_upload == "enabled" %}
+
+ def get_attachments(self):
+ qs = self.attachments.prefetch_related('user')
+ return [
+ attachment for attachment in qs
+ if not attachment.deleted_on and attachment.upload_completed_on
+ ]
+ {%- endif %}
+
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+
+
+# START_FEATURE direct_upload
+{%- endif %}
+class Attachment(UploadFile):
+ pass
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/app/serializers.py b/{{cookiecutter.project_slug}}/app/serializers.py
new file mode 100644
index 0000000..cdc18a0
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/serializers.py
@@ -0,0 +1,51 @@
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+# START_FEATURE direct_upload
+{%- endif %}
+from app.constants import ATTACHMENT_PK_URL_KWARG
+from django.utils.formats import date_format
+from common.serializers import UserSerializer
+from django.urls import reverse
+from rest_framework import serializers
+
+from app.models import Attachment
+
+
+class AttachmentSerializer(serializers.ModelSerializer):
+ user = UserSerializer()
+ view_url = serializers.SerializerMethodField()
+ download_url = serializers.SerializerMethodField()
+ delete_url = serializers.SerializerMethodField()
+ created_on = serializers.SerializerMethodField()
+ upload_completed_on = serializers.SerializerMethodField()
+ size = serializers.SerializerMethodField()
+ path = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Attachment
+ exclude = []
+
+ def get_view_url(self, instance):
+ return reverse('attachment_open', kwargs={ATTACHMENT_PK_URL_KWARG: instance.id})
+
+ def get_download_url(self, instance):
+ return reverse('attachment_download', kwargs={ATTACHMENT_PK_URL_KWARG: instance.id})
+
+ def get_delete_url(self, instance):
+ return reverse('attachment_delete', kwargs={ATTACHMENT_PK_URL_KWARG: instance.id})
+
+ def get_created_on(self, instance):
+ return date_format(instance.created_on, format="DATETIME_FORMAT")
+
+ def get_upload_completed_on(self, instance):
+ return date_format(instance.upload_completed_on, format="DATETIME_FORMAT")
+
+ def get_size(self, instance):
+ return instance.file.size
+
+ def get_path(self, instance):
+ return instance.file.name
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/app/templates/app/dashboard.html b/{{cookiecutter.project_slug}}/app/templates/app/dashboard.html
new file mode 100644
index 0000000..f5e05b8
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/templates/app/dashboard.html
@@ -0,0 +1,128 @@
+{%- raw -%}
+{% extends "base_templates/base.html" %}
+{% endraw %}
+{%- if cookiecutter.vue == "enabled" %}
+{%- raw %}
+{% load vue %}
+{% endraw %}
+{%- endif %}
+{%- raw %}
+
+{% block title %}Dashboard{% endblock %}
+
+{% block head %}
+
+{% endblock %}
+
+{% block body %}
+
+
Dashboard
+
Hello {{ user.email }}!
+
+
+
+
+
+ {% if sample_objects %}
+
+
+
+ | Name |
+ Created By |
+ Created On |
+ {% endraw %}
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# START_FEATURE direct_upload #}{% endraw %}
+ {%- endif %}
+ {%- raw %}
+ Attachments |
+ {% endraw %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# END_FEATURE direct_upload #}{% endraw %}
+ {%- endif %}
+ {%- endif %}
+ {%- raw %}
+ Actions |
+
+
+
+ {% for sample_object in sample_objects %}
+
+ | {{ sample_object.name }} |
+ {{ sample_object.created_by }} |
+ {{ sample_object.created_on }} |
+ {% endraw %}
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# START_FEATURE direct_upload #}{% endraw %}
+ {%- endif %}
+ {%- raw %}
+
+ {% for attachment in sample_object.get_attachments %}
+ {{ attachment.name }}
+ {% endfor %}
+ |
+
+
+ |
+ {% endraw %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# END_FEATURE direct_upload #}{% endraw %}
+ {%- endif %}
+ {%- endif %}
+ {%- raw %}
+
+ {% endfor %}
+
+
+ {% else %}
+
+
+ No Objects
+
+ {% endif %}
+
+
+
+
+ {% endraw %}
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# START_FEATURE direct_upload #}{% endraw %}
+ {%- endif %}
+ {%- raw %}
+
+
+
All Attachments
+
+
+
+ {% endraw %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# END_FEATURE direct_upload #}{% endraw %}
+ {%- endif %}
+ {%- endif %}
+ {%- raw %}
+
+{% endblock %}
+{%- endraw %}
diff --git a/{{cookiecutter.project_slug}}/app/templates/app/sample_object_detail.html b/{{cookiecutter.project_slug}}/app/templates/app/sample_object_detail.html
new file mode 100644
index 0000000..eb05d6a
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/templates/app/sample_object_detail.html
@@ -0,0 +1,74 @@
+{%- raw -%}
+{% extends "base_templates/base.html" %}
+{% endraw %}
+{%- if cookiecutter.vue == "enabled" %}
+{%- raw %}
+{% load vue %}
+{% endraw %}
+{%- endif %}
+{%- raw %}
+
+{% block title %}Sample Object {{sample_object.name }}{% endblock %}
+
+{% block body %}
+
+
+
+
Sample Object {{ sample_object.name }}
+ Created by {{ sample_object.created_by }} on {{ sample_object.created_on }}
+
+
+
+
+
+
+
+
+
+
+
Description
+
{{ sample_object.description }}
+
+
+
+ {% endraw %}
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# START_FEATURE direct_upload #}{% endraw %}
+ {%- endif %}
+ {%- raw %}
+
+
+ {% endraw %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# END_FEATURE direct_upload #}{% endraw %}
+ {%- endif %}
+ {%- endif %}
+ {%- raw %}
+{% endblock %}
+{%- endraw %}
diff --git a/{{cookiecutter.project_slug}}/app/templates/app/sample_object_form.html b/{{cookiecutter.project_slug}}/app/templates/app/sample_object_form.html
new file mode 100644
index 0000000..9f9f72e
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/templates/app/sample_object_form.html
@@ -0,0 +1,25 @@
+{%- raw -%}
+{% extends "base_templates/base.html" %}
+{% endraw %}
+{%- if cookiecutter.crispy_forms == "enabled" %}
+{%- raw %}
+{% load crispy_forms_tags %}
+{% endraw %}
+{%- endif %}
+{%- raw %}
+
+{% block title %}{{ form.action_title_formatted }}{% endblock %}
+
+{% block body %}
+
+
{{ form.action_title_formatted }}
+ {% endraw %}
+ {%- if cookiecutter.crispy_forms == "enabled" %}
+ {%- raw %}
+ {% crispy form %}
+ {% endraw %}
+ {%- endif %}
+ {%- raw %}
+
+{% endblock %}
+{%- endraw %}
diff --git a/{{cookiecutter.project_slug}}/app/templates/widgets/direct_upload_file_input.html b/{{cookiecutter.project_slug}}/app/templates/widgets/direct_upload_file_input.html
new file mode 100644
index 0000000..2950622
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/templates/widgets/direct_upload_file_input.html
@@ -0,0 +1,28 @@
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+{% raw %}{# START_FEATURE direct_upload #}{% endraw %}
+{%- endif %}
+{%- raw %}
+{% load vue %}
+
+{% endraw %}
+{%- if cookiecutter.feature_annotations == "on" %}
+{% raw %}{# END_FEATURE direct_upload #}{% endraw %}
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/app/tests.py b/{{cookiecutter.project_slug}}/app/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/{{cookiecutter.project_slug}}/app/urls.py b/{{cookiecutter.project_slug}}/app/urls.py
new file mode 100644
index 0000000..34572b5
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/urls.py
@@ -0,0 +1,69 @@
+from django.urls import path
+
+from app import views
+from app.constants import SAMPLE_OBJECT_PK_URL_KWARG
+{%- if cookiecutter.direct_upload == "enabled" %}
+from app.constants import ATTACHMENT_PK_URL_KWARG
+{%- endif %}
+
+urlpatterns = [
+ path(
+ "dashboard/",
+ views.DashboardView.as_view(),
+ name='dashboard'
+ ),
+ path(
+ "sample-objects/create/",
+ views.SampleObjectCreateView.as_view(),
+ name='sample-object-create'
+ ),
+ path(
+ f"sample-objects//",
+ views.SampleObjectDetailView.as_view(),
+ name='sample-object'
+ ),
+ path(
+ f"sample-objects//edit/",
+ views.SampleObjectEditView.as_view(),
+ name='sample-object-edit'
+ ),
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+
+ # START_FEATURE direct_upload
+ {%- endif %}
+ path(
+ "attachments/upload-start/",
+ views.FileUploadStartView.as_view(),
+ name='attachment_upload_start'
+ ),
+ path(
+ f"attachments//upload-stream/",
+ views.FileUploadStreamView.as_view(),
+ name='attachment_upload_stream'
+ ),
+ path(
+ f"attachments//upload-complete/",
+ views.FileUploadCompleteView.as_view(),
+ name='attachment_upload_complete'
+ ),
+ path(
+ f"attachments//delete/",
+ views.FileDeleteView.as_view(),
+ name='attachment_delete'
+ ),
+ path(
+ f"attachments//download/",
+ views.FileDownloadView.as_view(),
+ name='attachment_download'
+ ),
+ path(
+ f"attachments//open/",
+ views.FileOpenView.as_view(),
+ name='attachment_open'
+ ),
+ {%- if cookiecutter.feature_annotations == "on" %}
+ # END_FEATURE direct_upload
+ {%- endif %}
+ {%- endif %}
+]
diff --git a/{{cookiecutter.project_slug}}/app/views.py b/{{cookiecutter.project_slug}}/app/views.py
new file mode 100644
index 0000000..0188866
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/app/views.py
@@ -0,0 +1,218 @@
+from common.mixins import PermissionRequiredMixin, RequestFormMixin
+from common.permissions import Permission
+from django.core.files.storage import default_storage
+from django.core.files.storage.filesystem import FileSystemStorage
+from django.db import transaction
+from django.http import JsonResponse
+from django.http.response import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.urls import reverse, reverse_lazy
+from django.utils import timezone
+from django.views.generic.base import TemplateView, View
+from django.views.generic.detail import DetailView, SingleObjectMixin
+from django.views.generic.edit import CreateView, UpdateView
+
+from app.constants import SAMPLE_OBJECT_PK_URL_KWARG
+from app.models import SampleObject
+{%- if cookiecutter.crispy_forms == "enabled" %}
+from app.forms import SampleObjectCreateForm, SampleObjectEditForm
+{%- endif %}
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+
+# START_FEATURE direct_upload
+{%- endif %}
+import json
+from common.s3 import create_presigned_upload_url
+from app.constants import ATTACHMENT_PK_URL_KWARG
+from app.models import Attachment
+from app.serializers import AttachmentSerializer
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
+
+
+class DashboardView(PermissionRequiredMixin, TemplateView):
+ permission_required = Permission.dashboard
+ template_name = "app/dashboard.html"
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+
+ # START_FEATURE direct_upload
+ {%- endif %}
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['attachments'] = AttachmentSerializer(
+ Attachment.objects.filter(deleted_on=None, upload_completed_on__isnull=False),
+ many=True,
+ ).data
+ context['sample_objects'] = SampleObject.objects.prefetch_related('attachments')
+ return context
+ {%- if cookiecutter.feature_annotations == "on" %}
+ # END_FEATURE direct_upload
+ {%- endif %}
+ {%- endif %}
+
+
+class SampleObjectCreateView(PermissionRequiredMixin, RequestFormMixin, CreateView):
+ permission_required = Permission.dashboard
+ template_name = "app/sample_object_form.html"
+ {%- if cookiecutter.crispy_forms == "enabled" %}
+ form_class = SampleObjectCreateForm
+ {%- else %}
+ fields = ['name', 'description']
+ {%- endif %}
+ model = SampleObject
+ success_url = reverse_lazy('dashboard')
+
+
+class SampleObjectDetailView(PermissionRequiredMixin, DetailView):
+ permission_required = Permission.dashboard
+ template_name = "app/sample_object_detail.html"
+ model = SampleObject
+ pk_url_kwarg = SAMPLE_OBJECT_PK_URL_KWARG
+ context_object_name = "sample_object"
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+
+ # START_FEATURE direct_upload
+ {%- endif %}
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ obj = self.get_object()
+ context['attachments'] = AttachmentSerializer(obj.get_attachments(), many=True).data
+ return context
+ {%- if cookiecutter.feature_annotations == "on" %}
+ # END_FEATURE direct_upload
+ {%- endif %}
+ {%- endif %}
+
+
+class SampleObjectEditView(PermissionRequiredMixin, RequestFormMixin, UpdateView):
+ permission_required = Permission.dashboard
+ template_name = "app/sample_object_form.html"
+ {%- if cookiecutter.crispy_forms == "enabled" %}
+ form_class = SampleObjectEditForm
+ {%- else %}
+ fields = ['name', 'description']
+ {%- endif %}
+ model = SampleObject
+ pk_url_kwarg = SAMPLE_OBJECT_PK_URL_KWARG
+ context_object_name = "sample_object"
+
+ def get_success_url(self):
+ return reverse('sample-object', kwargs={
+ SAMPLE_OBJECT_PK_URL_KWARG: self.object.id,
+ })
+
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+
+
+# START_FEATURE direct_upload
+{%- endif %}
+class FileUploadStartView(PermissionRequiredMixin, View):
+ permission_required = Permission.dashboard
+
+ @transaction.atomic
+ def post(self, request, *args, **kwargs):
+ attachment_name = request.POST['name']
+
+ # Create `Attachment` instance
+ attachment = Attachment.objects.create(
+ user=request.user,
+ name=attachment_name,
+ )
+
+ # Generate S3 path for file
+ storage_path = Attachment._meta.get_field('file').generate_filename(attachment, attachment_name)
+ attachment.file = storage_path
+ attachment.save()
+
+ # Set the presigned upload and completion URLs on response payload
+ serialized_data = {}
+ url_kwargs = {ATTACHMENT_PK_URL_KWARG: str(attachment.id)}
+ if isinstance(default_storage, FileSystemStorage):
+ serialized_data['upload_presigned_url'] = reverse("attachment_upload_stream", kwargs=url_kwargs)
+ serialized_data['upload_complete_url'] = reverse("attachment_upload_complete", kwargs=url_kwargs)
+ else:
+ serialized_data['upload_presigned_url'] = create_presigned_upload_url(object_name=storage_path)
+ serialized_data['upload_complete_url'] = reverse("attachment_upload_complete", kwargs=url_kwargs)
+
+ return JsonResponse(serialized_data)
+
+
+class FileUploadBaseView(PermissionRequiredMixin, SingleObjectMixin, View):
+ permission_required = Permission.dashboard
+ model = Attachment
+ pk_url_kwarg = ATTACHMENT_PK_URL_KWARG
+
+
+class FileUploadStreamView(FileUploadBaseView):
+
+ def post(self, request, *args, **kwargs):
+ instance = self.get_object()
+
+ file = request.FILES.get('file')
+ if not file:
+ return HttpResponse(status=400)
+
+ instance.update(file=file)
+
+ return JsonResponse({
+ "id": instance.id,
+ "name": instance.name,
+ "url": instance.file.url,
+ })
+
+
+class FileUploadCompleteView(FileUploadBaseView):
+
+ def _link_objects(self, attachment: Attachment):
+ relations = json.loads(self.request.POST.get("relations", "{}"))
+ for accessor_name, value in relations.items():
+ accessor = getattr(attachment, accessor_name, None)
+ if accessor is None:
+ raise Exception(f'Accesor {accessor_name} does not exist on {attachment}')
+ pks = value if isinstance(value, list) else [value]
+ for pk in pks:
+ accessor.add(get_object_or_404(accessor.model, pk=pk))
+
+ @transaction.atomic
+ def post(self, request, *args, **kwargs):
+ instance = self.get_object()
+ instance.update(upload_completed_on=timezone.now())
+ self._link_objects(instance)
+ return JsonResponse(AttachmentSerializer(instance).data)
+
+
+class FileDownloadView(PermissionRequiredMixin, SingleObjectMixin, View):
+ permission_required = Permission.dashboard
+
+ model = Attachment
+ pk_url_kwarg = ATTACHMENT_PK_URL_KWARG
+
+ def get(self, request, *args, **kwargs):
+ instance = self.get_object()
+ return instance.download_file()
+
+
+class FileOpenView(FileDownloadView):
+ permission_required = Permission.dashboard
+
+ def get(self, request, *args, **kwargs):
+ instance = self.get_object()
+ return instance.view_file()
+
+
+class FileDeleteView(FileUploadBaseView):
+
+ def post(self, request, *args, **kwargs):
+ instance = self.get_object()
+ instance.update(deleted_on=timezone.now())
+ return HttpResponse(status=200)
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/common/constants.py b/{{cookiecutter.project_slug}}/common/constants.py
index 037ded4..6ae3430 100644
--- a/{{cookiecutter.project_slug}}/common/constants.py
+++ b/{{cookiecutter.project_slug}}/common/constants.py
@@ -1 +1,16 @@
from django.db.models import TextChoices
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+
+
+# START_FEATURE direct_upload
+{%- endif %}
+
+
+class StorageBackendType(TextChoices):
+ s3 = ("s3", "S3 Bucket")
+ filesystem = ("filesystem", "Local Filesystem Storage")
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/common/context_processors.py b/{{cookiecutter.project_slug}}/common/context_processors.py
index aafe705..1d58de1 100644
--- a/{{cookiecutter.project_slug}}/common/context_processors.py
+++ b/{{cookiecutter.project_slug}}/common/context_processors.py
@@ -1,6 +1,5 @@
def django_settings(request):
from django.conf import settings
return {
- "PRODUCTION": settings.PRODUCTION,
- "LOCALHOST": settings.LOCALHOST,
+ "settings": settings,
}
diff --git a/{{cookiecutter.project_slug}}/common/fields.py b/{{cookiecutter.project_slug}}/common/fields.py
new file mode 100644
index 0000000..9454ef2
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/common/fields.py
@@ -0,0 +1,65 @@
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+# START_FEATURE direct_upload
+{%- endif %}
+from django import forms
+from django.conf import settings
+from django.db.models import QuerySet
+from django.urls import reverse
+
+from app.serializers import AttachmentSerializer
+
+
+class DirectUploadFileInput(forms.SelectMultiple):
+ queryset: QuerySet
+ template_name = "widgets/direct_upload_file_input.html"
+
+ def get_context(self, name, value, attrs):
+ context: dict = super().get_context(name, value, attrs)
+ context["upload_start_url"] = reverse("attachment_upload_start")
+ context['queryset_json'] = AttachmentSerializer(
+ self.queryset.filter(upload_completed_on__isnull=False),
+ many=True,
+ ).data
+ context["storage_backend"] = settings.DEFAULT_STORAGE_TYPE
+ return context
+
+
+class DirectUploadFileField(forms.ModelMultipleChoiceField):
+ """
+ A field that allows for direct file uploads to S3 in form submissions.
+ """
+
+ widget = DirectUploadFileInput
+
+ def __init__(
+ self,
+ queryset: QuerySet,
+ allowed_file_types: list[str] = [],
+ max_number_of_files: int | None = None,
+ max_file_size: int | None = None,
+ **kwargs,
+ ):
+ self.allowed_file_types = [ft if ft.startswith(".") else "." + ft for ft in allowed_file_types]
+ self.max_number_of_files = max_number_of_files
+
+ # Max file size in bytes
+ self.max_file_size = max_file_size
+
+ super().__init__(queryset=queryset, **kwargs)
+ self.widget.queryset = self.queryset
+
+ if allowed_file_types:
+ self.help_text = "Only the following file types are allowed: " + ", ".join(self.allowed_file_types)
+
+ def widget_attrs(self, widget):
+ attrs = super().widget_attrs(widget)
+ attrs['required'] = self.required
+ attrs['max_number_of_files'] = self.max_number_of_files
+ attrs['max_file_size'] = self.max_file_size
+ attrs['allowed_file_types'] = self.allowed_file_types
+ return attrs
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/common/forms.py b/{{cookiecutter.project_slug}}/common/forms.py
index c2a9f09..6a775b4 100644
--- a/{{cookiecutter.project_slug}}/common/forms.py
+++ b/{{cookiecutter.project_slug}}/common/forms.py
@@ -2,53 +2,40 @@
{%- if cookiecutter.feature_annotations == "on" -%}
# START_FEATURE crispy_forms
{%- endif %}
-from django import forms
+from crispy_forms.helper import FormHelper, Layout
+from crispy_forms.layout import Button, Submit
+from django.db.models import Model
-from crispy_forms.helper import FormHelper
-from crispy_forms.layout import Submit
-
-class CrispyFormMixin(object):
- submit_label = "Save"
- form_action = ""
+class CrispyFormMixin:
+ submit_label: str = "Save"
+ cancel_label: str = "Cancel"
+ form_action: str = ""
+ form_tag: bool = True
+ default_actions: bool = True
+ layout: Layout | None = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = "POST"
self.helper.form_action = self.form_action
- self.helper.add_input(Submit("submit", self.submit_label))
+ if self.layout is not None:
+ self.helper.layout = self.layout
+ if self.default_actions:
+ self.helper.add_input(Submit("submit", self.submit_label))
+ self.helper.add_input(Button("cancel", self.cancel_label,
+ css_class="btn-secondary", onclick="history.back()"))
{%- if cookiecutter.feature_annotations == "on" %}
# END_FEATURE crispy_forms
{%- endif %}
{%- endif %}
-{%- if cookiecutter.reference_examples == "on" %}
-{%- if cookiecutter.crispy_forms == "enabled" %}
+class ActionFormMixin:
+ instance: type[Model]
+ action_title: str = "Continue"
-{% if cookiecutter.feature_annotations == "on" %}
-# START_FEATURE crispy_forms
-{%- endif %}
-class SampleForm(CrispyFormMixin, forms.Form):
- # TODO: delete me; this is just a reference example
- is_company = forms.CharField(label="company", required=False, widget=forms.CheckboxInput())
- email = forms.EmailField(
- label="email", max_length=30, required=True, widget=forms.TextInput(), help_text="Insert your email"
- )
- first_name = forms.CharField(label="first name", max_length=5, required=True, widget=forms.TextInput())
- last_name = forms.CharField(label="last name", max_length=5, required=True, widget=forms.TextInput())
- datetime_field = forms.SplitDateTimeField(label="date time", widget=forms.SplitDateTimeWidget())
-
- def clean(self):
- super().clean()
- password1 = self.cleaned_data.get("password1", None)
- password2 = self.cleaned_data.get("password2", None)
- if not password1 and not password2 or password1 != password2:
- raise forms.ValidationError("Passwords dont match")
- return self.cleaned_data
-{%- if cookiecutter.feature_annotations == "on" %}
-# END_FEATURE crispy_forms
-{%- endif %}
-{%- endif %}
-{%- endif %}
+ @property
+ def action_title_formatted(self):
+ return self.action_title.format(instance=self.instance)
diff --git a/{{cookiecutter.project_slug}}/common/helpers.py b/{{cookiecutter.project_slug}}/common/helpers.py
new file mode 100644
index 0000000..eb45d9d
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/common/helpers.py
@@ -0,0 +1,20 @@
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+# START_FEATURE direct_upload
+{%- endif %}
+import os
+
+
+def get_attachment_extension(filename: str):
+ _, extension = os.path.splitext(filename)
+ extension = extension.lower().replace('.', '')
+ return extension
+
+
+def remove_attachment_extension(filename: str):
+ name, _ = os.path.splitext(filename)
+ return name
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/common/migrations/0001_initial.py b/{{cookiecutter.project_slug}}/common/migrations/0001_initial.py
index 411c4d1..423ffeb 100644
--- a/{{cookiecutter.project_slug}}/common/migrations/0001_initial.py
+++ b/{{cookiecutter.project_slug}}/common/migrations/0001_initial.py
@@ -1,11 +1,10 @@
-# Generated by Django 3.2.6 on 2021-10-04 19:37
+# Generated by Django 5.2 on 2026-03-16 18:26
-import common.models
-from django.conf import settings
-from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
+from django.conf import settings
+from django.db import migrations, models
class Migration(migrations.Migration):
@@ -32,8 +31,9 @@ class Migration(migrations.Migration):
('created_on', models.DateTimeField(auto_now_add=True)),
('updated_on', models.DateTimeField(auto_now=True)),
('email', models.EmailField(max_length=254, unique=True)),
- ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
- ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
+ ('role', models.CharField(choices=[('guest', 'Guest'), ('standard', 'Standard')], default='standard', max_length=128)),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
@@ -41,6 +41,7 @@ class Migration(migrations.Migration):
'abstract': False,
},
),
+ {%- if cookiecutter.user_action_tracking == "enabled" %}
migrations.CreateModel(
name='UserAction',
fields=[
@@ -58,6 +59,8 @@ class Migration(migrations.Migration):
'abstract': False,
},
),
+ {%- endif %}
+ {%- if cookiecutter.celery == "enabled" and cookiecutter.sentry != "enabled" %}
migrations.CreateModel(
name='TaskMonitor',
fields=[
@@ -70,4 +73,5 @@ class Migration(migrations.Migration):
'abstract': False,
},
),
+ {%- endif %}
]
diff --git a/{{cookiecutter.project_slug}}/common/mixins.py b/{{cookiecutter.project_slug}}/common/mixins.py
new file mode 100644
index 0000000..f48a29f
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/common/mixins.py
@@ -0,0 +1,28 @@
+from django.contrib.auth.mixins import AccessMixin
+from django.core.exceptions import ImproperlyConfigured
+from django.views.generic.edit import FormMixin
+
+from common.permissions import Permission
+
+
+class PermissionRequiredMixin(AccessMixin):
+ permission_required = None
+
+ def dispatch(self, request, *args, **kwargs):
+ if not self.permission_required:
+ raise ImproperlyConfigured("Permission for view not specified.")
+ if not request.user.is_authenticated:
+ return self.handle_no_permission()
+
+ if not request.user.has_permission(self.permission_required):
+ return self.handle_no_permission()
+
+ return super().dispatch(request, *args, **kwargs)
+
+
+class RequestFormMixin(FormMixin):
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs['request'] = self.request
+ return kwargs
diff --git a/{{cookiecutter.project_slug}}/common/models.py b/{{cookiecutter.project_slug}}/common/models.py
index 8202316..2ebda2d 100644
--- a/{{cookiecutter.project_slug}}/common/models.py
+++ b/{{cookiecutter.project_slug}}/common/models.py
@@ -1,12 +1,39 @@
import uuid
+{%- if cookiecutter.direct_upload == "enabled" %}
+import mimetypes
+{%- endif %}
from django.contrib.auth.models import AbstractUser
from django.db import models
+{%- if cookiecutter.direct_upload == "enabled" %}
+from django.http import FileResponse, Http404, HttpResponse, HttpResponseRedirect
+from django.core.files.storage import FileSystemStorage
+{%- if cookiecutter.feature_annotations == "on" %}
+
+# START_FEATURE direct_upload
+{%- endif %}
+from common.helpers import get_attachment_extension, remove_attachment_extension
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
{% if cookiecutter.django_social == "enabled" -%}
from common.managers import UserManager
{%- endif %}
+from common.permissions import ROLE_PERMISSIONS, UserRole
+{%- if cookiecutter.sentry == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+
+# START_FEATURE sentry
+{%- endif %}
+from sentry_sdk import capture_message
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE sentry
+{%- endif %}
+{%- endif %}
+
class TimestampedModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -31,6 +58,7 @@ class Meta:
# Create your models here.
class User(AbstractUser, TimestampedModel):
email = models.EmailField(unique=True)
+
{%- if cookiecutter.django_social == "enabled" %}
{%- if cookiecutter.feature_annotations == "on" %}
# START_FEATURE django_social
@@ -45,15 +73,22 @@ class User(AbstractUser, TimestampedModel):
{%- endif %}
{%- endif %}
-{%- if cookiecutter.reference_examples == "on" %}
+ role = models.CharField(max_length=128, default=UserRole.standard, choices=UserRole.choices)
+
+ @property
+ def permissions(self):
+ return ROLE_PERMISSIONS[self.role]
+
+ def has_permission(self, permission):
+ return permission in self.permissions
+
{%- if cookiecutter.django_storages == "enabled" %}
{%- if cookiecutter.feature_annotations == "on" %}
# START_FEATURE django_storages
{%- endif %}
-# TODO: delete me; this is just a reference example
-def get_s3_path(instance, filename):
+def get_upload_prefix(instance, filename):
return "%s/%s/%s" % (
"uploads",
instance.user_id,
@@ -62,19 +97,64 @@ def get_s3_path(instance, filename):
class UploadFile(TimestampedModel):
- user = models.ForeignKey(User, related_name="files", on_delete=models.PROTECT)
- file = models.FileField(
- max_length=1024,
- upload_to=get_s3_path
- )
class Meta:
abstract = True
+
+ user = models.ForeignKey(User, related_name="files", on_delete=models.PROTECT)
+ name = models.CharField(max_length=512)
+ file = models.FileField(max_length=1024, upload_to=get_upload_prefix)
+
+ {%- if cookiecutter.direct_upload == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ # START_FEATURE direct_upload
+ {%- endif %}
+ upload_completed_on = models.DateTimeField(null=True)
+ deleted_on = models.DateTimeField(null=True)
+
+ def get_download_url(self, download_on_open: bool = True):
+ extension = get_attachment_extension(self.file.name)
+ filename = f"{remove_attachment_extension(self.name)}.{extension}".replace('"', '')
+ content_type, _ = mimetypes.guess_type(self.file.name)
+ s3_filename = self.file.name
+
+ content_disposition = "attachment" if download_on_open else "inline"
+
+ try:
+ if isinstance(self.file.storage, FileSystemStorage):
+ return FileResponse(
+ self.file.open(),
+ as_attachment=download_on_open,
+ filename=filename
+ )
+
+ # Download file directly from S3
+ else:
+ url = self.file.storage.url(self.file.name, parameters={
+ "ResponseContentDisposition": f'{content_disposition}; filename="{filename}"',
+ "ResponseContentType": content_type or "application/octet-stream",
+ })
+ return HttpResponseRedirect(url)
+
+ except Exception:
+ {%- if cookiecutter.sentry == "enabled" %}
+ capture_message(f"Failed to get object URL from S3 for ({self}) with path ({s3_filename})")
+ {%- endif %}
+ raise Http404()
+
+ def download_file(self) -> FileResponse | HttpResponse:
+ return self.get_download_url(download_on_open=True)
+
+ def view_file(self) -> FileResponse | HttpResponse:
+ return self.get_download_url(download_on_open=False)
+ {%- if cookiecutter.feature_annotations == "on" %}
+ # END_FEATURE direct_upload
+ {%- endif %}
+ {%- endif %}
{%- if cookiecutter.feature_annotations == "on" %}
# END_FEATURE django_storages
{%- endif %}
{%- endif %}
-{%- endif %}
{%- if cookiecutter.user_action_tracking == "enabled" %}
@@ -104,4 +184,4 @@ class TaskMonitor(TimestampedModel):
{%- if cookiecutter.feature_annotations == "on" %}
# END_FEATURE celery
{%- endif %}
-{%- endif %}
\ No newline at end of file
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/common/permissions.py b/{{cookiecutter.project_slug}}/common/permissions.py
new file mode 100644
index 0000000..000dcb3
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/common/permissions.py
@@ -0,0 +1,15 @@
+from django.db.models import TextChoices
+
+
+class UserRole(TextChoices):
+ guest = ("guest", "Guest")
+ standard = ("standard", "Standard")
+
+
+class Permission(TextChoices):
+ dashboard = ("dashboard", "View Dashboard")
+
+
+ROLE_PERMISSIONS = {}
+ROLE_PERMISSIONS[UserRole.guest] = []
+ROLE_PERMISSIONS[UserRole.standard] = list(Permission)
diff --git a/{{cookiecutter.project_slug}}/common/s3.py b/{{cookiecutter.project_slug}}/common/s3.py
new file mode 100644
index 0000000..ea98e2c
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/common/s3.py
@@ -0,0 +1,21 @@
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+# START_FEATURE direct_upload
+{%- endif %}
+from django.core.files.storage import default_storage
+from storages.backends.s3boto3 import S3Boto3Storage
+
+
+def create_presigned_upload_url(object_name: str, expiration: int = 3600):
+ """
+ Generate a presigned URL to upload an S3 object via PUT
+ (see https://boto3.amazonaws.com/v1/documentation/api/latest/guide/s3-presigned-urls.html)
+ """
+ if isinstance(default_storage, S3Boto3Storage):
+ return default_storage.url(object_name, http_method="PUT", expire=expiration)
+ else:
+ raise Exception(f"Cannot create a presigned upload URL for {type(default_storage)} storage")
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/common/serializers.py b/{{cookiecutter.project_slug}}/common/serializers.py
new file mode 100644
index 0000000..748c0cf
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/common/serializers.py
@@ -0,0 +1,15 @@
+{%- if cookiecutter.direct_upload == "enabled" %}
+from rest_framework import serializers
+from common.models import User
+
+
+class UserSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = User
+ fields = [
+ "first_name",
+ "last_name",
+ "email",
+ ]
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/common/templates/base_templates/base.html b/{{cookiecutter.project_slug}}/common/templates/base_templates/base.html
index 99ec89b..da66b2f 100644
--- a/{{cookiecutter.project_slug}}/common/templates/base_templates/base.html
+++ b/{{cookiecutter.project_slug}}/common/templates/base_templates/base.html
@@ -11,6 +11,8 @@
{%- endif %}
{%- raw %}
+{% load static %}
+
@@ -37,53 +39,24 @@
{%- raw %}
+{%- endraw %}
+{%- if cookiecutter.vue == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# START_FEATURE vue #}{% endraw %}
+ {%- endif %}
+ {%- raw %}
+
+ {%- endraw %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# END_FEATURE vue #}{% endraw %}
+ {%- endif %}
+{%- endif %}
+{%- raw %}
{% block head %}{% endblock %}
-
- {% if not PRODUCTION and not LOCALHOST %}
+ {% include "base_templates/navbar.html" %}
+ {% if not settings.PRODUCTION and not settings.LOCALHOST %}
@@ -94,7 +67,7 @@
Warning
{% endif %}
-
+
{% endraw %}
{%- if cookiecutter.bootstrap_messages == "enabled" %}
{%- if cookiecutter.feature_annotations == "on" -%}
@@ -110,15 +83,34 @@ Warning
{{ message }}
{% endfor %}
- {% block body %}{% endblock %}
+
+ {% block body %}
+ {% endblock %}
+
{% endraw %}
{%- if cookiecutter.feature_annotations == "on" -%}
{% raw %}{# END_FEATURE bootstrap_messages #}{% endraw %}
{%- endif %}
{%- endif %}
- {%- raw %}
-
+
+{%- if cookiecutter.vue == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# START_FEATURE vue #}{% endraw %}
+ {%- endif %}
+ {%- raw %}
+
+ {% block bottom_javascript %}
+
+ {% endblock %}
+ {%- endraw %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# END_FEATURE vue #}{% endraw %}
+ {%- endif %}
+{%- endif %}
+{%- raw %}
{%- endraw %}
diff --git a/{{cookiecutter.project_slug}}/common/templates/base_templates/navbar.html b/{{cookiecutter.project_slug}}/common/templates/base_templates/navbar.html
new file mode 100644
index 0000000..89db75b
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/common/templates/base_templates/navbar.html
@@ -0,0 +1,70 @@
+{%- raw -%}
+
+{%- endraw %}
diff --git a/{{cookiecutter.project_slug}}/common/templates/common/index.html b/{{cookiecutter.project_slug}}/common/templates/common/index.html
index e183862..d4819c4 100644
--- a/{{cookiecutter.project_slug}}/common/templates/common/index.html
+++ b/{{cookiecutter.project_slug}}/common/templates/common/index.html
@@ -34,24 +34,22 @@
{% endblock %}
{% block body %}
-
-
Welcome to {% endraw %}{{ cookiecutter.project_name }}{% raw %}. Please log in to continue.
- {% endraw %}
- {%- if cookiecutter.sass_bootstrap == "enabled" %}
- {%- if cookiecutter.feature_annotations == "on" %}
- {% raw %}{# START_FEATURE sass_bootstrap #}{% endraw %}
- {%- endif %}
- {%- if cookiecutter.reference_examples == "on" %}
- {%- raw %}
- {# TODO: delete me; this is just a reference example #}
-
- {%- endraw %}
- {%- endif %}
- {%- if cookiecutter.feature_annotations == "on" %}
- {% raw %}{# END_FEATURE sass_bootstrap #}{% endraw %}
- {%- endif %}
- {%- endif %}
- {%- raw %}
-
+ Welcome to {% endraw %}{{ cookiecutter.project_name }}{% raw %}. Please log in to continue.
+ {% endraw %}
+ {%- if cookiecutter.sass_bootstrap == "enabled" %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# START_FEATURE sass_bootstrap #}{% endraw %}
+ {%- endif %}
+ {%- if cookiecutter.reference_examples == "on" %}
+ {%- raw %}
+ {# TODO: delete me; this is just a reference example #}
+
+ {%- endraw %}
+ {%- endif %}
+ {%- if cookiecutter.feature_annotations == "on" %}
+ {% raw %}{# END_FEATURE sass_bootstrap #}{% endraw %}
+ {%- endif %}
+ {%- endif %}
+ {%- raw %}
{% endblock %}
{%- endraw %}
diff --git a/{{cookiecutter.project_slug}}/common/templatetags/__init__.py b/{{cookiecutter.project_slug}}/common/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/{{cookiecutter.project_slug}}/common/templatetags/vue.py b/{{cookiecutter.project_slug}}/common/templatetags/vue.py
new file mode 100644
index 0000000..95ae0f7
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/common/templatetags/vue.py
@@ -0,0 +1,33 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+# START_FEATURE vue
+{%- endif %}
+import json
+import re
+
+from django.core.serializers.json import DjangoJSONEncoder
+from django.template.defaultfilters import register
+from django.utils.html import escapejs
+from django.utils.safestring import mark_safe
+
+
+@register.filter
+def to_v_init_arg(model_name):
+ """
+ Converts a v-model field key to the value expected for a v-init arg
+ """
+ v_init_arg = ":".join(model_name.split("."))
+ # https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
+ camel_case_re = re.compile(r'(?
+ Hello {props.msg}
+
+ );
+}
+
+export default Hello;
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/react/Pages/Home.jsx b/{{cookiecutter.project_slug}}/react/Pages/Home.jsx
new file mode 100644
index 0000000..9dcd50e
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/react/Pages/Home.jsx
@@ -0,0 +1,20 @@
+{%- if cookiecutter.reference_examples == "on" -%}
+/*
+ * # TODO: delete me; this is just a reference example
+ */
+
+import React from 'react';
+import Hello from "../Components/Hello";
+
+function Home(props) {
+ const {message} = props;
+ return (
+
+
You may want to wrap several components in a page, but you don't have to.
+
+
+ );
+}
+
+export default Home;
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/requirements.in b/{{cookiecutter.project_slug}}/requirements.in
index d546b5c..af5ad05 100644
--- a/{{cookiecutter.project_slug}}/requirements.in
+++ b/{{cookiecutter.project_slug}}/requirements.in
@@ -3,6 +3,7 @@ django-environ
django-extensions>=3.2
requests
psycopg2
+whitenoise
{%- if cookiecutter.feature_annotations == "on" %}
####### OPTIONAL FEATURES #######
@@ -59,6 +60,17 @@ django-storages
# END_FEATURE django_storages
{%- endif %}
{%- endif %}
+{%- if cookiecutter.direct_upload == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+
+# START_FEATURE direct_upload
+{%- endif %}
+boto3
+djangorestframework
+{%- if cookiecutter.feature_annotations == "on" %}
+# END_FEATURE direct_upload
+{%- endif %}
+{%- endif %}
{%- if cookiecutter.docker == "enabled" %}
{%- if cookiecutter.feature_annotations == "on" %}
diff --git a/{{cookiecutter.project_slug}}/vite.config.js b/{{cookiecutter.project_slug}}/vite.config.js
new file mode 100644
index 0000000..04c2cec
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vite.config.js
@@ -0,0 +1,62 @@
+{%- if cookiecutter.vue == "enabled" -%}
+{%- if cookiecutter.feature_annotations == "on" %}
+// START_FEATURE vue
+{% endif %}
+import { defineConfig } from "vite"
+import vue from "@vitejs/plugin-vue"
+import { glob } from "glob"
+
+const SRC_LOCATION = "vue/pages"
+const DEST_LOCATION = "static/js/dist"
+
+export default defineConfig(({ mode }) => {
+ const DEVELOPMENT = mode === "development"
+ return {
+ plugins: [vue()],
+ base: "/" + DEST_LOCATION,
+ resolve: {
+ alias: [
+ { find: /^vue$/, replacement: "vue/dist/vue.esm-bundler.js" },
+ ],
+ },
+ build: {
+ sourcemap: true,
+ emptyOutDir: DEVELOPMENT,
+ outDir: DEST_LOCATION,
+ minify: DEVELOPMENT ? false : "terser",
+ rollupOptions: {
+ input: glob.sync(`${SRC_LOCATION}/**/*.js`),
+ preserveEntrySignatures: true,
+ output: {
+ manualChunks(id) {
+ if (id.includes("pages") || id.includes("mixins")) {
+ // These files are imported by path and must be chunked individually
+ return id.split("vue/")[1].split(".js")[0]
+ }
+ // otherwise, add to combined chunk
+ return "vue/main"
+ },
+ entryFileNames(chunkInfo) {
+ if (chunkInfo.isEntry) {
+ const name = chunkInfo.facadeModuleId.split("vue/")[1]
+ if (!name.includes("pages")) {
+ // This is not a 'real' entrypoint, should contain hash for cache busting
+ const path = name.split(".js")[0]
+ return `${path}-[hash].js`
+ }
+ // This is a real entry point, should not contain hash
+ return name
+ }
+ return `[name]-[hash].js`
+ },
+ chunkFileNames: `[name]-[hash].js`,
+ assetFileNames: `[name].[ext]`,
+ },
+ },
+ },
+ }
+})
+{%- if cookiecutter.feature_annotations == "on" %}
+// END_FEATURE vue
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/vue/components/FileUploadDashboard.vue b/{{cookiecutter.project_slug}}/vue/components/FileUploadDashboard.vue
new file mode 100644
index 0000000..7b53c23
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vue/components/FileUploadDashboard.vue
@@ -0,0 +1,96 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+
+{%- endif %}
+
+
+
+
+
+
+
+ No Attachments
+
+
+
+
+
+
+{%- if cookiecutter.feature_annotations == "on" %}
+
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/vue/components/FileUploadDirect.vue b/{{cookiecutter.project_slug}}/vue/components/FileUploadDirect.vue
new file mode 100644
index 0000000..021a533
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vue/components/FileUploadDirect.vue
@@ -0,0 +1,139 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+
+{%- endif %}
+
+
+
+
+
+{%- if cookiecutter.feature_annotations == "on" %}
+
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/vue/components/index.js b/{{cookiecutter.project_slug}}/vue/components/index.js
new file mode 100644
index 0000000..f8c5386
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vue/components/index.js
@@ -0,0 +1,18 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+// START_FEATURE vue
+{%- endif %}
+const modules = import.meta.glob("./*.vue", { eager: true })
+const components = {}
+
+for (const path in modules) {
+ // Extract the file name without extension
+ const name = path.match(/\.\/(.*)\.vue$/)[1]
+ components[name] = modules[path].default
+}
+
+export default components
+{%- if cookiecutter.feature_annotations == "on" %}
+// END_FEATURE vue
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/vue/composables/csrf.js b/{{cookiecutter.project_slug}}/vue/composables/csrf.js
new file mode 100644
index 0000000..1c8138e
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vue/composables/csrf.js
@@ -0,0 +1,17 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+// START_FEATURE vue
+{%- endif %}
+import { onMounted, ref } from "vue"
+
+export function useCSRF() {
+ const csrf = ref(null)
+ onMounted(() => {
+ csrf.value = window.csrfmiddlewaretoken
+ })
+ return { csrf }
+}
+{%- if cookiecutter.feature_annotations == "on" %}
+// END_FEATURE vue
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/vue/composables/fetch.js b/{{cookiecutter.project_slug}}/vue/composables/fetch.js
new file mode 100644
index 0000000..3b9f0a8
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vue/composables/fetch.js
@@ -0,0 +1,70 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+// START_FEATURE vue
+{%- endif %}
+import { useCSRF } from "./csrf.js"
+
+export function useFetch() {
+ const { csrf } = useCSRF()
+ const post = (url, options = {}, headers = {}) => {
+ return fetch(url, {
+ headers: {
+ ...headers,
+ "X-CSRFTOKEN": csrf.value,
+ },
+ ...{
+ method: "POST",
+ ...options,
+ },
+ })
+ }
+ const get = (url, options = {}, headers = {}, queryParams = {}) => {
+ let searchParams = new URLSearchParams(queryParams)
+ return fetch(url + "?" + searchParams.toString(), {
+ headers,
+ ...{
+ method: "GET",
+ ...options,
+ },
+ })
+ }
+ const poll = async (
+ valueRef,
+ url,
+ handleResponse = null,
+ onSuccess = null,
+ pollRate = 2000,
+ options = {},
+ headers = {},
+ queryParams = {},
+ ) => {
+ const doQuery = async () => {
+ return await get(url, options, headers, queryParams).then(async res => {
+ if (res.status === 202) {
+ return false
+ } else {
+ if (handleResponse) {
+ return await handleResponse(res)
+ }
+ return await res.json()
+ }
+ })
+ }
+ const tryAgain = () => {
+ setTimeout(async () => {
+ const res = await doQuery()
+ if (res !== false) {
+ valueRef.value = res
+ } else {
+ tryAgain()
+ }
+ }, pollRate)
+ }
+ tryAgain()
+ }
+ return { post, get, poll }
+}
+{%- if cookiecutter.feature_annotations == "on" %}
+// END_FEATURE vue
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/vue/directives/index.js b/{{cookiecutter.project_slug}}/vue/directives/index.js
new file mode 100644
index 0000000..b3bc854
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vue/directives/index.js
@@ -0,0 +1,18 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+// START_FEATURE vue
+{%- endif %}
+const modules = import.meta.glob("./*.js", { eager: true })
+const components = {}
+
+for (const path in modules) {
+ // Extract the file name without extension
+ const name = path.match(/\.\/(.*)\.js$/)[1]
+ components[name] = modules[path].default
+}
+
+export default components
+{%- if cookiecutter.feature_annotations == "on" %}
+// END_FEATURE vue
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/vue/directives/v-init.js b/{{cookiecutter.project_slug}}/vue/directives/v-init.js
new file mode 100644
index 0000000..47fbbc5
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vue/directives/v-init.js
@@ -0,0 +1,65 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+// START_FEATURE vue
+{%- endif %}
+import "vue"
+
+function set(context, path, value) {
+ /**
+ * set is a helper function which allows us to dynamically target and set values within an object, allowing
+ * v-init to interpret a model like 'abc.def' as context['abc']['def']
+ */
+ var pList = path.split(".")
+ if (pList.length > 2) {
+ throw "You cannot use v-init with a nest factor of > 2 e.g. a.b.c"
+ }
+ if (pList.length >= 2) {
+ // we are setting the value of pList[0][pList[1]] = value
+ var target = pList.shift()
+ if (context.$data[target] === undefined) {
+ context.$data[target] = {}
+ }
+ set(context[target], pList.join("."), value)
+ } else {
+ context[pList[0]] = value
+ }
+}
+
+const initArgToModel = arg => {
+ // Convert v-init arg to v-model format
+ const fieldKeys = arg.split(":").map(key => {
+ // convert each field key from kebab-case to camelCase
+ const camelChunks = key.split("-").map((chunk, i) => {
+ if (i === 0) {
+ return chunk
+ } //
+ return chunk.charAt(0).toUpperCase() + chunk.substring(1)
+ })
+ return camelChunks.join("")
+ })
+ return fieldKeys.join(".")
+}
+
+const VInit = [
+ // v-init automatically sets the data value on page load; the expression is given by the binding argument
+ "init",
+ {
+ beforeMount(el, binding, vNode) {
+ if (!binding.arg) {
+ console.log(el, binding, vNode)
+ throw new Error("You cannot use v-init without providing an argument.")
+ }
+ const expression = initArgToModel(binding.arg)
+ set(binding.instance, expression, binding.value)
+ binding.instance.$nextTick(() => {
+ binding.instance.$forceUpdate()
+ })
+ },
+ },
+]
+
+export default VInit
+{%- if cookiecutter.feature_annotations == "on" %}
+// END_FEATURE vue
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/vue/main.js b/{{cookiecutter.project_slug}}/vue/main.js
new file mode 100644
index 0000000..68e2503
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vue/main.js
@@ -0,0 +1,34 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+// START_FEATURE vue
+{%- endif %}
+import components from "./components"
+import directives from "./directives"
+
+import "bootstrap"
+import "bootstrap-icons/font/bootstrap-icons.css"
+
+const MainVueApp = {
+ install: (app, options) => {
+ app.config.compilerOptions.whitespace = "preserve"
+
+ for (const componentName in components) {
+ const component = components[componentName]
+ app.component(componentName, component)
+ }
+
+ for (const directiveName in directives) {
+ const directive = directives[directiveName]
+ // arr[0] is name of directive, arr[1] is content
+ app.directive(directive[0], directive[1])
+ }
+ },
+}
+
+export * from "./directives"
+export * from "./components"
+export default MainVueApp
+{%- if cookiecutter.feature_annotations == "on" %}
+// END_FEATURE vue
+{%- endif %}
+{%- endif %}
diff --git a/{{cookiecutter.project_slug}}/vue/pages/default.js b/{{cookiecutter.project_slug}}/vue/pages/default.js
new file mode 100644
index 0000000..a45ff50
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/vue/pages/default.js
@@ -0,0 +1,12 @@
+{%- if cookiecutter.vue == "enabled" %}
+{%- if cookiecutter.feature_annotations == "on" %}
+// START_FEATURE vue
+{%- endif %}
+import { createApp } from "vue"
+import MainVueApp from "../main"
+
+createApp({}).use(MainVueApp).mount("#app")
+{%- if cookiecutter.feature_annotations == "on" %}
+// END_FEATURE vue
+{%- endif %}
+{%- endif %}