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 }}!

+ +
+
+
+

Sample Objects

+ + + +
+
+ {% if sample_objects %} + + + + + + + {% 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 %} + + + + + {% for sample_object in sample_objects %} + + + + + {% 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 %} + + {% endfor %} + +
NameCreated ByCreated OnAttachmentsActions
{{ sample_object.name }}{{ sample_object.created_by }}{{ sample_object.created_on }} + {% for attachment in sample_object.get_attachments %} + {{ attachment.name }} + {% 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 %} +
+
+

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_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 %} + + + +{%- 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 %}