diff --git a/.bashrc b/.bashrc
new file mode 100644
index 0000000..3d299ab
--- /dev/null
+++ b/.bashrc
@@ -0,0 +1,8 @@
+# START_FEATURE ecs
+# If we're on prod, make the terminal red
+if [ "$DEPLOY_ENVIRONMENT" == "prod" ]; then
+ export PS1="\[\e[31m\]\u@\h:\w\$ \[\e[0m\]"
+fi
+
+alias djm="uv run --no-sync manage.py"
+# END_FEATURE ecs
diff --git a/.gitignore b/.gitignore
index 1441253..4021544 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,7 +53,12 @@ venv.bak/
# Installed packages
node_modules/
+# ECS deploy script
+.deploy/
+.git-copy/
+
# Compiled Files
+static/js/dist*
staticfiles/
staticfiles/*
# START_FEATURE sass_bootstrap
@@ -64,3 +69,8 @@ staticfiles/*
static/webpack_bundles/
webpack-stats.json
# END_FEATURE django_react
+
+.terraform
+
+# Locally uploaded files
+uploads/
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..e4fba21
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/Dockerfile b/Dockerfile
index 0fe8706..4810e8c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,36 @@
-# START_FEATURE docker
-FROM python:3.11.4-slim-buster
+# START_FEATURE ecs
+# START_FEATURE vue
+# ----------------------------------- NPM ------------------------------------ #
+FROM node:24-slim AS node-deps
+
+WORKDIR /app
+
+# Install required system dependencies
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends bzip2
+
+# Add only files needed for dependency installation
+COPY package.json ./
+
+# Install Node dependencies with caching
+RUN npm install && npm cache clean --force
+
+# Build vue dist
+COPY . /app/
+RUN npm run vue-build
+
+# END_FEATURE vue
+# ---------------------------------- Python ---------------------------------- #
+
+FROM python:3.12.13-slim-trixie
+
+# Set working directory
WORKDIR /app
ADD requirements.txt /app/requirements.txt
+# Install system and python dependencies
RUN set -ex \
&& buildDeps=" \
build-essential \
@@ -12,8 +38,15 @@ RUN set -ex \
" \
&& deps=" \
postgresql-client \
+ git \
+ # (editing)
+ vim \
+ # (debug)
+ curl \
+ htop \
" \
- && apt-get update && apt-get install -y $buildDeps $deps --no-install-recommends \
+ && apt-get update \
+ && apt-get install -y $buildDeps $deps --no-install-recommends \
&& pip install --no-cache-dir -r /app/requirements.txt \
&& apt-get purge -y --auto-remove $buildDeps \
$(! command -v gpg > /dev/null || echo 'gnupg dirmngr') \
@@ -22,29 +55,25 @@ RUN set -ex \
ENV VIRTUAL_ENV /env
ENV PATH /env/bin:$PATH
-# START_FEATURE django_react
-COPY ./nwb.config.js /app/nwb.config.js
-COPY ./package.json /app/package.json
-COPY ./package-lock.json /app/package-lock.json
-RUN npm install
-# END_FEATURE django_react
+# START_FEATURE vue
+# Copy Node dependencies from node-deps stage
+COPY --from=node-deps /app/node_modules /app/node_modules
+COPY --from=node-deps /app/package*.json /app/
+COPY --from=node-deps /app/static/js/dist/ /app/static/js/dist/
+# END_FEATURE vue
+# Copy application files and .env.example
COPY . /app/
COPY ./config/.env.example /app/config/.env
+COPY .bashrc /root/
-# START_FEATURE django_react
-RUN ./node_modules/.bin/nwb build --no-vendor
-# END_FEATURE django_react
-
+# Compile static assets
# START_FEATURE sass_bootstrap
RUN python manage.py compilescss
# END_FEATURE sass_bootstrap
-
RUN python manage.py collectstatic --noinput
-
RUN rm /app/config/.env
-EXPOSE 8000
-
-CMD ["gunicorn", "--bind", ":8000", "--workers", "3", "config.wsgi:application"]
-# END_FEATURE docker
+EXPOSE 8080
+CMD ["gunicorn", "--bind", ":8080", "--workers", "15", "config.wsgi:application"]
+# END_FEATURE ecs
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/admin.py b/app/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/app/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/app/apps.py b/app/apps.py
new file mode 100644
index 0000000..ed327d2
--- /dev/null
+++ b/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/app/constants.py b/app/constants.py
new file mode 100644
index 0000000..a2e43ab
--- /dev/null
+++ b/app/constants.py
@@ -0,0 +1,5 @@
+SAMPLE_OBJECT_PK_URL_KWARG = "sample_object_id"
+
+# START_FEATURE direct_upload
+ATTACHMENT_PK_URL_KWARG = "attachment_id"
+# END_FEATURE direct_upload
diff --git a/app/forms.py b/app/forms.py
new file mode 100644
index 0000000..6c6e4e6
--- /dev/null
+++ b/app/forms.py
@@ -0,0 +1,46 @@
+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 Attachment, SampleObject
+from common.fields import DirectUploadFileField
+from common.forms import ActionFormMixin, CrispyFormMixin
+
+
+class SampleObjectBaseForm(CrispyFormMixin, ActionFormMixin, forms.ModelForm):
+ request: HttpRequest
+
+ # START_FEATURE direct_upload
+ attachments = DirectUploadFileField(queryset=Attachment.objects.filter(deleted_on=None), required=False)
+ # END_FEATURE direct_upload
+
+ class Meta:
+ model = SampleObject
+ exclude = ['created_by']
+
+ layout = Layout(
+ Fieldset(
+ "Details",
+ "name",
+ "description"
+ ),
+ # START_FEATURE direct_upload
+ "attachments"
+ # END_FEATURE direct_upload
+ )
+
+ 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}"
diff --git a/app/migrations/0001_initial.py b/app/migrations/0001_initial.py
new file mode 100644
index 0000000..3a8d0b6
--- /dev/null
+++ b/app/migrations/0001_initial.py
@@ -0,0 +1,44 @@
+# Generated by Django 5.2.12 on 2026-03-16 18:26
+
+import common.models
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ 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,
+ },
+ ),
+ 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/app/migrations/0002_initial.py b/app/migrations/0002_initial.py
new file mode 100644
index 0000000..d35abd5
--- /dev/null
+++ b/app/migrations/0002_initial.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.12 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 = [
+ 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'),
+ ),
+ 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/app/migrations/__init__.py b/app/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000..abab0c7
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,26 @@
+from django.db import models
+
+from common.models import TimestampedModel, UploadFile, User
+
+
+class SampleObject(TimestampedModel):
+ created_by = models.ForeignKey(User, related_name="sample_objects", on_delete=models.PROTECT)
+
+ # START_FEATURE direct_upload
+ attachments = models.ManyToManyField("Attachment", related_name="sample_objects")
+ # END_FEATURE direct_upload
+
+ name = models.CharField(max_length=512, unique=True)
+ description = models.TextField(default="", blank=True)
+
+ def __str__(self) -> str:
+ return f'Sample Object {self.name}'
+
+ def get_attachments(self):
+ return self.attachments.filter(deleted_on=None)
+
+
+# START_FEATURE direct_upload
+class Attachment(UploadFile):
+ pass
+# END_FEATURE direct_upload
diff --git a/app/serializers.py b/app/serializers.py
new file mode 100644
index 0000000..7c854a7
--- /dev/null
+++ b/app/serializers.py
@@ -0,0 +1,35 @@
+# START_FEATURE direct_upload
+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()
+
+ class Meta:
+ model = Attachment
+ exclude = []
+
+ def to_representation(self, instance):
+ rep = super().to_representation(instance)
+ rep["view_url"] = reverse('attachment_open', kwargs={
+ ATTACHMENT_PK_URL_KWARG: instance.id,
+ })
+ rep["download_url"] = reverse('attachment_download', kwargs={
+ ATTACHMENT_PK_URL_KWARG: instance.id,
+ })
+ rep["delete_url"] = reverse('attachment_delete', kwargs={
+ ATTACHMENT_PK_URL_KWARG: instance.id,
+ })
+ rep["created_on"] = date_format(instance.created_on, format="DATETIME_FORMAT")
+ rep["upload_completed_on"] = date_format(instance.upload_completed_on, format="DATETIME_FORMAT")
+ if instance.file.storage.exists(instance.file.name):
+ rep["size"] = instance.file.size
+ rep["path"] = instance.file.name
+ return rep
+# END_FEATURE direct_upload
diff --git a/app/templates/app/dashboard.html b/app/templates/app/dashboard.html
new file mode 100644
index 0000000..ba73e5a
--- /dev/null
+++ b/app/templates/app/dashboard.html
@@ -0,0 +1,99 @@
+{% extends "base_templates/base.html" %}
+
+
+{# START_FEATURE sass_bootstrap #}
+{% load sass_tags %}
+{# END_FEATURE sass_bootstrap #}
+
+{% block title %}Dashboard{% endblock %}
+
+{% block head %}
+
+{# START_FEATURE sass_bootstrap #}
+
+{# END_FEATURE sass_bootstrap #}
+{% endblock %}
+
+{% block body %}
+
+
Dashboard
+
Hello {{ user.email }}!
+
+
+
+
+
+ {% if sample_objects %}
+
+
+
+ | Name |
+ Created By |
+ Created On |
+ {# START_FEATURE direct_upload #}
+ Attachments |
+ {# END_FEATURE direct_upload #}
+ Actions |
+
+
+
+ {% for sample_object in sample_objects %}
+
+ | {{ sample_object.name }} |
+ {{ sample_object.created_by }} |
+ {{ sample_object.created_on }} |
+ {# START_FEATURE direct_upload #}
+
+ {% for attachment in sample_object.get_attachments %}
+ {% if not attachment.deleted_on %}
+ {{ attachment.name }}
+ {% endif %}
+ {% endfor %}
+ |
+
+
+ |
+ {# END_FEATURE direct_upload #}
+
+ {% endfor %}
+
+
+ {% else %}
+
+
+ No Objects
+
+ {% endif %}
+
+
+
+
+ {# START_FEATURE direct_upload #}
+
+
+
All Attachments
+
+
+
+ {# END_FEATURE direct_upload #}
+
+{% endblock %}
diff --git a/app/templates/app/sample_object_detail.html b/app/templates/app/sample_object_detail.html
new file mode 100644
index 0000000..43073d8
--- /dev/null
+++ b/app/templates/app/sample_object_detail.html
@@ -0,0 +1,54 @@
+{% extends "base_templates/base.html" %}
+
+{% 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 }}
+
+
+
+ {# START_FEATURE direct_upload #}
+
+
+{# END_FEATURE direct_upload #}
+{% endblock %}
diff --git a/app/templates/app/sample_object_form.html b/app/templates/app/sample_object_form.html
new file mode 100644
index 0000000..f3e546b
--- /dev/null
+++ b/app/templates/app/sample_object_form.html
@@ -0,0 +1,12 @@
+{% extends "base_templates/base.html" %}
+
+{% load crispy_forms_tags %}
+
+{% block title %}{{ form.action_title_formatted }}{% endblock %}
+
+{% block body %}
+
+
{{ form.action_title_formatted }}
+ {% crispy form %}
+
+{% endblock %}
diff --git a/app/templates/widgets/direct_upload_file_input.html b/app/templates/widgets/direct_upload_file_input.html
new file mode 100644
index 0000000..53dc993
--- /dev/null
+++ b/app/templates/widgets/direct_upload_file_input.html
@@ -0,0 +1,18 @@
+{# START_FEATURE direct_upload #}
+
+{# END_FEATURE direct_upload #}
diff --git a/app/tests.py b/app/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/app/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/app/urls.py b/app/urls.py
new file mode 100644
index 0000000..63068fb
--- /dev/null
+++ b/app/urls.py
@@ -0,0 +1,60 @@
+from django.urls import path
+
+from app import views
+from app.constants import SAMPLE_OBJECT_PK_URL_KWARG, ATTACHMENT_PK_URL_KWARG
+
+urlpatterns = [
+ path(
+ f"dashboard/",
+ views.DashboardView.as_view(),
+ name='dashboard'
+ ),
+ path(
+ f"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'
+ ),
+
+ # START_FEATURE direct_upload
+ path(
+ f"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'
+ ),
+ # END_FEATURE direct_upload
+]
diff --git a/app/views.py b/app/views.py
new file mode 100644
index 0000000..6c7c37a
--- /dev/null
+++ b/app/views.py
@@ -0,0 +1,189 @@
+import json
+import re
+
+from common.mixins import PermissionRequiredMixin, RequestMixin
+from common.permissions import PermissionType
+from common.s3 import create_presigned_upload_url
+from django.conf import settings
+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.forms import SampleObjectCreateForm, SampleObjectEditForm
+
+# START_FEATURE direct_upload
+from app.constants import ATTACHMENT_PK_URL_KWARG
+from app.models import Attachment, SampleObject
+from app.serializers import AttachmentSerializer
+# END_FEATURE direct_upload
+
+
+class DashboardView(PermissionRequiredMixin, TemplateView):
+ permission_required = PermissionType.dashboard
+ template_name = "app/dashboard.html"
+
+ # START_FEATURE direct_upload
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['storage_backend'] = "s3" if settings.AWS_STORAGE_BUCKET_NAME else "local"
+ context['attachments'] = json.dumps([
+ AttachmentSerializer(attachment).data
+ for attachment in Attachment.objects.filter(deleted_on=None)
+ ])
+ context['sample_objects'] = SampleObject.objects.prefetch_related('attachments')
+ return context
+ # END_FEATURE direct_upload
+
+
+class SampleObjectCreateView(PermissionRequiredMixin, RequestMixin, CreateView):
+ permission_required = PermissionType.dashboard
+ template_name = "app/sample_object_form.html"
+ form_class = SampleObjectCreateForm
+ model = SampleObject
+ success_url = reverse_lazy('dashboard')
+
+
+class SampleObjectDetailView(PermissionRequiredMixin, DetailView):
+ permission_required = PermissionType.dashboard
+ template_name = "app/sample_object_detail.html"
+ model = SampleObject
+ pk_url_kwarg = SAMPLE_OBJECT_PK_URL_KWARG
+ context_object_name = "sample_object"
+
+ # START_FEATURE direct_upload
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['storage_backend'] = "s3" if settings.AWS_STORAGE_BUCKET_NAME else "local"
+ context['attachments'] = json.dumps([
+ AttachmentSerializer(attachment).data
+ for attachment in self.get_object().attachments.filter(deleted_on=None)
+ ])
+ return context
+ # END_FEATURE direct_upload
+
+
+class SampleObjectEditView(PermissionRequiredMixin, RequestMixin, UpdateView):
+ permission_required = PermissionType.dashboard
+ template_name = "app/sample_object_form.html"
+ form_class = SampleObjectEditForm
+ 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.get_object().id,
+ })
+
+
+# START_FEATURE direct_upload
+class FileUploadStartView(PermissionRequiredMixin, View):
+ permission_required = PermissionType.dashboard
+
+ def link_objects(self, attachment: Attachment, request):
+ for key, value in request.GET.items():
+
+ # Expect a query parameter key in this form: '__