├── src ├── static │ ├── .keepme │ ├── site.css │ └── site.js ├── dataroom │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_bulkdownload.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── urls.py │ ├── templates │ │ └── dataroom │ │ │ ├── upload_disabled.html │ │ │ ├── upload_archived.html │ │ │ └── upload_page.html │ ├── models.py │ └── views.py ├── myapp │ ├── tests │ │ ├── __init__.py │ │ └── test_views.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── templates │ │ ├── email.html │ │ ├── robots.txt │ │ ├── _alerts.html │ │ ├── myapp │ │ │ └── home.html │ │ ├── base.html │ │ ├── _footer.html │ │ └── _header.html │ ├── __init__.py │ ├── templatetags │ │ ├── __init__.py │ │ └── thumbnail_url.py │ ├── apps.py │ ├── admin │ │ ├── __init__.py │ │ └── site_configuation.py │ ├── context_processors.py │ ├── views │ │ └── __init__.py │ └── models │ │ └── __init__.py ├── require2fa │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── views.py │ ├── tests │ │ ├── __init__.py │ │ └── test_middleware.py │ ├── __init__.py │ ├── apps.py │ ├── models.py │ ├── admin.py │ ├── README.md │ └── middleware.py ├── config │ ├── __init__.py │ ├── asgi.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── templates │ ├── allauth │ │ ├── elements │ │ │ ├── h1.html │ │ │ ├── provider_list.html │ │ │ ├── button_group.html │ │ │ ├── h2__entrance.html │ │ │ ├── h1__entrance.html │ │ │ ├── button__entrance.html │ │ │ ├── img.html │ │ │ ├── table.html │ │ │ ├── alert.html │ │ │ ├── provider.html │ │ │ ├── form__entrance.html │ │ │ ├── panel.html │ │ │ ├── fields.html │ │ │ ├── badge.html │ │ │ ├── form.html │ │ │ ├── button.html │ │ │ └── field.html │ │ └── layouts │ │ │ ├── base.html │ │ │ ├── entrance.html │ │ │ └── manage.html │ ├── mfa │ │ ├── base_manage.html │ │ └── index.html │ ├── account │ │ ├── base_manage_email.html │ │ └── base_manage_password.html │ ├── usersessions │ │ └── base_manage.html │ ├── socialaccount │ │ └── base_manage.html │ ├── index.html │ ├── 429.html │ ├── privacy.html │ └── terms.html └── manage.py ├── .python-version ├── Procfile ├── db.sqlite3 ├── gunicorn_settings.py ├── .dockerignore ├── entrypoint.sh ├── .github ├── workflows │ ├── ruff.yml │ ├── test.yml │ └── release.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── dependabot.yml ├── app.json ├── env.test ├── .gitignore ├── env.sample ├── Dockerfile ├── LICENSE ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── docs ├── manifesto.md ├── getting_started.md └── async_tasks.md ├── docker-compose.yml ├── Makefile ├── pyproject.toml ├── .claude └── agents │ ├── guilfoyle_subagent.md │ ├── coverage_subagent.md │ └── copywriter_subagent.md ├── README.md └── CLAUDE.md /src/static/.keepme: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/static/site.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /src/dataroom/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/myapp/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/myapp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/myapp/templates/email.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/dataroom/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/require2fa/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/require2fa/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /src/myapp/__init__.py: -------------------------------------------------------------------------------- 1 | """Root module for myapp package.""" 2 | -------------------------------------------------------------------------------- /src/require2fa/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for require2fa app.""" 2 | -------------------------------------------------------------------------------- /src/myapp/templates/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /admin/ 3 | -------------------------------------------------------------------------------- /src/myapp/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for template tags.""" 2 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for configuration of the application.""" 2 | -------------------------------------------------------------------------------- /src/require2fa/__init__.py: -------------------------------------------------------------------------------- 1 | """Two-Factor Authentication enforcement Django app.""" 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: python manage.py migrate 2 | web: gunicorn config.wsgi --bind 0.0.0.0:$PORT 3 | -------------------------------------------------------------------------------- /db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplecto/django-reference-implementation/HEAD/db.sqlite3 -------------------------------------------------------------------------------- /gunicorn_settings.py: -------------------------------------------------------------------------------- 1 | bind = "0.0.0.0:8000" 2 | workers = 2 3 | pythonpath = "/app/config" 4 | forwarded_allow_ips = "*" 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ 3 | .gitignore 4 | env 5 | .env 6 | .idea/ 7 | data/ 8 | src/staticfiles/* 9 | *.dump 10 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/h1.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 |

3 | {% slot %} 4 | {% endslot %} 5 |

6 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/provider_list.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 | 6 | -------------------------------------------------------------------------------- /src/templates/mfa/base_manage.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/layouts/manage.html" %} 2 | {% block nav_class_mfa %}{{ block.super }} active{% endblock %} 3 | -------------------------------------------------------------------------------- /src/myapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MyappConfig(AppConfig): 5 | """App configuration.""" 6 | 7 | name = "myapp" 8 | -------------------------------------------------------------------------------- /src/templates/account/base_manage_email.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/layouts/manage.html" %} 2 | {% block nav_class_email %}{{ block.super }} active{% endblock %} 3 | -------------------------------------------------------------------------------- /src/templates/account/base_manage_password.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/layouts/manage.html" %} 2 | {% block nav_class_password %}{{ block.super }} active{% endblock %} 3 | -------------------------------------------------------------------------------- /src/templates/usersessions/base_manage.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/layouts/manage.html" %} 2 | {% block nav_class_usersessions %}{{ block.super }} active{% endblock %} 3 | -------------------------------------------------------------------------------- /src/templates/socialaccount/base_manage.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/layouts/manage.html" %} 2 | {% block nav_class_socialaccount %}{{ block.super }} active{% endblock %} 3 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/button_group.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 |
3 | {% slot %} 4 | {% endslot %} 5 |
6 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/h2__entrance.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 |

3 | {% slot %} 4 | {% endslot %} 5 |

6 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cmd="$@" 6 | 7 | uv run ./manage.py collectstatic --noinput 8 | uv run ./manage.py migrate 9 | 10 | exec uv run $cmd 11 | -------------------------------------------------------------------------------- /src/dataroom/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DataroomConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'dataroom' 7 | -------------------------------------------------------------------------------- /src/myapp/admin/__init__.py: -------------------------------------------------------------------------------- 1 | """Admin module for the myapp app.""" 2 | 3 | from .site_configuation import SiteConfigurationAdmin 4 | 5 | __all__ = [ 6 | "SiteConfigurationAdmin", 7 | ] 8 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/h1__entrance.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 |

3 | {% slot %} 4 | {% endslot %} 5 |

6 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/button__entrance.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/elements/button.html" %} 2 | {% load allauth %} 3 | {% block class %} 4 | {{ block.super }} 5 | w-full mt-6 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/img.html: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | -------------------------------------------------------------------------------- /src/myapp/templatetags/thumbnail_url.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.simple_tag 7 | def thumbnail(something): # noqa: ANN201, ANN001, D103 8 | return something 9 | -------------------------------------------------------------------------------- /src/require2fa/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class Require2FaConfig(AppConfig): 5 | """Django app configuration for require2fa.""" 6 | 7 | default_auto_field = "django.db.models.BigAutoField" 8 | name = "require2fa" 9 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/table.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 |
3 | 4 | {% slot %} 5 | {% endslot %} 6 |
7 |
8 | -------------------------------------------------------------------------------- /src/myapp/admin/site_configuation.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from myapp.models import SiteConfiguration 4 | 5 | 6 | @admin.register(SiteConfiguration) 7 | class SiteConfigurationAdmin(admin.ModelAdmin): 8 | """Admin for SiteConfiguration.""" 9 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff 2 | on: [push, pull_request, workflow_call] 3 | permissions: 4 | contents: read 5 | jobs: 6 | ruff: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: astral-sh/ruff-action@v3 11 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/alert.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 | 7 | -------------------------------------------------------------------------------- /src/myapp/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sites.models import Site 2 | from django.http import HttpRequest 3 | 4 | 5 | def site_name(request: HttpRequest) -> dict: # noqa: ARG001 6 | """Add the site name to the context.""" 7 | current_site = Site.objects.get_current() 8 | return {"site_name": current_site.name} 9 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/layouts/entrance.html" %} 2 | {% load allauth %} 3 | {% block head_title %}Example Project{% endblock %} 4 | {% block content %} 5 | {% element h1 %} 6 | Example Project 7 | {% endelement %} 8 |

Welcome to the django-allauth example project.

9 | {% endblock content %} 10 | -------------------------------------------------------------------------------- /src/templates/429.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/layouts/entrance.html" %} 2 | {% load allauth %} 3 | {% block head_title %} 4 | Too Many Requests 5 | {% endblock head_title %} 6 | {% block content %} 7 | {% element h1 %} 8 | Too Many Requests 9 | {% endelement %} 10 |

You are sending too many requests.

11 | {% endblock content %} 12 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/provider.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 |
  • 3 | {{ attrs.name }} 6 |
  • 7 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/form__entrance.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 | {% for err in attrs.form.non_field_errors %}{% endfor %} 3 |
    4 | {% slot body %} 5 | {% endslot %} 6 | {% slot actions %} 7 | {% endslot %} 8 |
    9 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "healthchecks": { 3 | "web": [ 4 | { 5 | "type": "startup", 6 | "name": "webcheck", 7 | "description": "Check if the web service is up", 8 | "path": "/health-check/", 9 | "initialDelay": 3, 10 | "attempts": 3 11 | } 12 | ] 13 | }, 14 | "scripts": { 15 | "dokku" : { 16 | "predeploy": "python manage.py collectstatic --noinput" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /env.test: -------------------------------------------------------------------------------- 1 | CACHE_URL=db://mycachetable 2 | DATABASE_URL=sqlite:///test.db 3 | DEBUG=True 4 | SECRET_KEY=thisisasamplekeyandshouldbechangedforyourproductionenvironment 5 | EMAIL_URL=consolemail:// 6 | BASE_URL=http://localhost:8000 7 | 8 | AWS_ACCESS_KEY_ID=remote-identity 9 | AWS_SECRET_ACCESS_KEY=remote-credential 10 | AWS_STORAGE_BUCKET_NAME=testbucket 11 | AWS_S3_REGION_NAME=local 12 | AWS_S3_ENDPOINT_URL=http://s3proxy:80 13 | AWS_S3_USE_SSL=false 14 | -------------------------------------------------------------------------------- /src/config/asgi.py: -------------------------------------------------------------------------------- 1 | """ASGI config for config project. 2 | 3 | It exposes the ASGI callable as a module-level variable named ``application``. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 7 | """ 8 | 9 | import os 10 | 11 | from django.core.asgi import get_asgi_application 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 14 | 15 | application = get_asgi_application() 16 | -------------------------------------------------------------------------------- /src/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """WSGI config for config project. 2 | 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 7 | """ 8 | 9 | import os 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 14 | 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ 3 | data/ 4 | logs/ 5 | 6 | env 7 | env.backup 8 | .env 9 | config.mk 10 | *.dump 11 | *.db 12 | 13 | src/staticfiles/* 14 | src/media/ 15 | .idea/** 16 | 17 | 18 | # sphinx build 19 | docs/_build/ 20 | 21 | *.log 22 | 23 | # Unit test / coverage reports 24 | htmlcov/ 25 | .tox/ 26 | .nox/ 27 | .coverage 28 | .coverage.* 29 | .cache 30 | nosetests.xml 31 | coverage.xml 32 | *.cover 33 | *.py,cover 34 | .hypothesis/ 35 | .pytest_cache/ 36 | cover/ 37 | -------------------------------------------------------------------------------- /env.sample: -------------------------------------------------------------------------------- 1 | CACHE_URL=db://mycachetable 2 | DATABASE_URL=postgres://postgres:pass123@postgres/django_reference 3 | DEBUG=True 4 | SECRET_KEY=thisisasamplekeyandshouldbechangedforyourproductionenvironment 5 | EMAIL_URL=consolemail:// 6 | BASE_URL=http://localhost:8000 7 | 8 | AWS_ACCESS_KEY_ID=remote-identity 9 | AWS_SECRET_ACCESS_KEY=remote-credential 10 | AWS_STORAGE_BUCKET_NAME=testbucket 11 | AWS_S3_REGION_NAME=local 12 | AWS_S3_ENDPOINT_URL=http://s3proxy:80 13 | AWS_S3_USE_SSL=false 14 | -------------------------------------------------------------------------------- /src/dataroom/urls.py: -------------------------------------------------------------------------------- 1 | """URL configuration for dataroom app.""" 2 | 3 | from django.urls import path 4 | 5 | from . import views 6 | 7 | app_name = "dataroom" 8 | 9 | urlpatterns = [ 10 | path("upload//", views.upload_page, name="upload_page"), 11 | path("upload//ajax/", views.ajax_upload, name="ajax_upload"), 12 | path("upload//delete//", views.delete_file, name="delete_file"), 13 | path("upload//download-zip/", views.download_endpoint_zip, name="download_endpoint_zip"), 14 | ] 15 | -------------------------------------------------------------------------------- /src/myapp/views/__init__.py: -------------------------------------------------------------------------------- 1 | """Basic views for myapp.""" 2 | 3 | from django.contrib import messages 4 | from django.http import HttpRequest, HttpResponse 5 | from django.shortcuts import redirect, render 6 | 7 | 8 | def index(request: HttpRequest) -> HttpResponse: 9 | """Redirect to login page. 10 | 11 | :param request: 12 | :return: 13 | """ 14 | return redirect("/accounts/login/") 15 | 16 | 17 | def health_check(request: HttpRequest) -> HttpResponse: # noqa: ARG001 18 | """Tell the load balancer or Docker to check if the server is up. 19 | 20 | :return: 21 | """ 22 | return HttpResponse(b"OK") 23 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/panel.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 |
    3 |
    4 |
    5 | {% slot title %} 6 | {% endslot %} 7 |
    8 | {% slot body %} 9 | {% endslot %} 10 |
    11 | {% if slots.actions %} 12 |
    13 | {% slot actions %} 14 | {% endslot %} 15 |
    16 | {% endif %} 17 |
    18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/fields.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 | {% for bound_field in attrs.form %} 3 | {% element field unlabeled=attrs.unlabeled name=bound_field.name type=bound_field.field.widget.input_type required=bound_field.field.required value=bound_field.value id=bound_field.auto_id errors=bound_field.errors placeholder=bound_field.field.widget.attrs.placeholder tabindex=bound_field.field.widget.attrs.tabindex autocomplete=bound_field.field.widget.attrs.autocomplete style=bound_field.field.widget.attrs.style choices=bound_field.field.choices %} 4 | {% slot label %} 5 | {{ bound_field.label }} 6 | {% endslot %} 7 | {% slot help_text %} 8 | {{ bound_field.field.help_text }} 9 | {% endslot %} 10 | {% endelement %} 11 | {% endfor %} 12 | -------------------------------------------------------------------------------- /src/require2fa/models.py: -------------------------------------------------------------------------------- 1 | """Two-Factor Authentication configuration models.""" 2 | 3 | import solo.models 4 | from django.db import models 5 | 6 | 7 | class TwoFactorConfig(solo.models.SingletonModel): 8 | """Configuration for Two-Factor Authentication enforcement.""" 9 | 10 | required = models.BooleanField( 11 | default=False, 12 | help_text="Require 2FA for all authenticated users.", 13 | verbose_name="Require Two-Factor Authentication", 14 | ) 15 | 16 | class Meta: 17 | """Model metadata for TwoFactorConfig.""" 18 | 19 | verbose_name = "Two-Factor Authentication Configuration" 20 | 21 | def __str__(self) -> str: 22 | """Return a string representation of the configuration.""" 23 | return f"2FA Required: {'Yes' if self.required else 'No'}" 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | # Install system dependencies 4 | RUN apt-get update && apt-get install -y \ 5 | git \ 6 | && rm -rf /var/lib/apt/lists/* 7 | 8 | # Install uv 9 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 10 | 11 | # Copy dependency files and required files for hatchling 12 | COPY pyproject.toml uv.lock LICENSE README.md ./ 13 | 14 | # Install dependencies 15 | RUN uv sync --frozen --no-cache 16 | 17 | RUN mkdir /app 18 | COPY src/ app/ 19 | 20 | ARG VERSION 21 | ENV VERSION=${VERSION} 22 | ENV PYTHONUNBUFFERED=1 23 | 24 | WORKDIR /app 25 | 26 | COPY gunicorn_settings.py /gunicorn_settings.py 27 | 28 | COPY entrypoint.sh /entrypoint.sh 29 | RUN chmod +x /entrypoint.sh 30 | ENTRYPOINT ["/entrypoint.sh"] 31 | 32 | EXPOSE 8000 33 | 34 | CMD ["gunicorn", "-c", "/gunicorn_settings.py", "wsgi:application"] 35 | -------------------------------------------------------------------------------- /src/require2fa/admin.py: -------------------------------------------------------------------------------- 1 | """Admin interface for Two-Factor Authentication configuration.""" 2 | 3 | from django.contrib import admin 4 | from solo.admin import SingletonModelAdmin 5 | 6 | from .models import TwoFactorConfig 7 | 8 | 9 | @admin.register(TwoFactorConfig) 10 | class TwoFactorConfigAdmin(SingletonModelAdmin): 11 | """Admin interface for TwoFactorConfig.""" 12 | 13 | fieldsets = ( 14 | ( 15 | "Two-Factor Authentication Settings", 16 | { 17 | "fields": ("required",), 18 | "description": "Configure site-wide 2FA enforcement policies.", 19 | }, 20 | ), 21 | ) 22 | 23 | def has_delete_permission(self, request, obj=None) -> bool: # noqa: ANN001, ARG002 24 | """Prevent deletion of the singleton configuration.""" 25 | return False 26 | -------------------------------------------------------------------------------- /src/require2fa/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.5 on 2025-11-17 20:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='TwoFactorConfig', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('required', models.BooleanField(default=False, help_text='Require 2FA for all authenticated users.', verbose_name='Require Two-Factor Authentication')), 19 | ], 20 | options={ 21 | 'verbose_name': 'Two-Factor Authentication Configuration', 22 | }, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/badge.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 | {% setvar variant %} 3 | {% if "warning" in attrs.tags %} 4 | bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300 5 | {% elif "danger" in attrs.tags %} 6 | bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300 7 | {% elif "secondary" in attrs.tags %} 8 | bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 9 | {% elif "success" in attrs.tags %} 10 | bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 11 | {% else %} 12 | bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 13 | {% endif %} 14 | {% endsetvar %} 15 | 17 | {% slot %} 18 | {% endslot %} 19 | 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | django: 14 | patterns: 15 | - "django*" 16 | development: 17 | patterns: 18 | - "ruff" 19 | - "pre-commit" 20 | - "bandit*" 21 | testing: 22 | patterns: 23 | - "pytest*" 24 | - "*test*" 25 | minor-updates: 26 | update-types: 27 | - "minor" 28 | - "patch" 29 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/form.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 | {% for err in attrs.form.non_field_errors %}{% endfor %} 3 |
    6 | {% if not attrs.no_visible_fields %}
    {% endif %} 7 | {% slot body %} 8 | {% endslot %} 9 | {% if not attrs.no_visible_fields %}
    {% endif %} 10 | {% if not attrs.no_visible_fields %}
    {% endif %} 11 | {% slot actions %} 12 | {% endslot %} 13 | {% if not attrs.no_visible_fields %}
    {% endif %} 14 |
    15 | -------------------------------------------------------------------------------- /src/myapp/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from myapp.models import SiteConfiguration 3 | 4 | 5 | class TestViews(TestCase): 6 | def setUp(self): 7 | # Create SiteConfiguration singleton for tests 8 | SiteConfiguration.objects.get_or_create() 9 | def test_index(self): 10 | response = self.client.get("/") 11 | self.assertEqual(response.status_code, 200) 12 | self.assertTemplateUsed(response, "myapp/home.html") 13 | 14 | def test_health_check(self): 15 | response = self.client.get("/health-check/") 16 | self.assertEqual(response.status_code, 200) 17 | self.assertEqual(response.content, b"OK") 18 | 19 | def test_flash_message(self): 20 | response = self.client.get("/?test_flash=true") 21 | messages = list(response.context["messages"]) 22 | self.assertEqual(len(messages), 1) 23 | self.assertEqual(str(messages[0]), "This is a test flash message.") 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/myapp/templates/_alerts.html: -------------------------------------------------------------------------------- 1 | {% for message in messages %} 2 | 10 | {% endfor %} 11 | -------------------------------------------------------------------------------- /src/dataroom/templates/dataroom/upload_disabled.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Upload Disabled 7 | 8 | 9 | 10 |
    11 | 12 | 13 | 14 |

    Upload Disabled

    15 |

    This upload endpoint has been temporarily disabled. Please contact your administrator for more information.

    16 |
    17 | 18 | 19 | -------------------------------------------------------------------------------- /src/templates/allauth/layouts/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% load i18n %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% block head_title %}{% endblock %} 11 | 12 | 13 | 18 | 19 | 20 | {% translate "Skip to main content" %} 21 | {% include "_header.html" %} 22 | {% block body %} 23 | {% block content %} 24 | {% endblock content %} 25 | {% endblock body %} 26 | {% block extra_body %} 27 | {% endblock extra_body %} 28 | 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 SimpleCTO 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/config/urls.py: -------------------------------------------------------------------------------- 1 | """Config URL Configuration.""" 2 | 3 | import django.views.generic 4 | from django.conf import settings 5 | from django.conf.urls.static import static 6 | from django.contrib import admin 7 | from django.urls import include, path 8 | from django.views.generic import TemplateView 9 | 10 | import myapp.views 11 | 12 | urlpatterns = [ # noqa: RUF005 13 | path( 14 | "robots.txt", 15 | django.views.generic.TemplateView.as_view(template_name="robots.txt", content_type="text/plain"), 16 | name="robots-txt", 17 | ), 18 | path("admin/", admin.site.urls), 19 | path("accounts/", include("allauth.urls")), 20 | path("", myapp.views.index, name="home"), 21 | path("health-check/", myapp.views.health_check, name="health-check"), 22 | path("", include("dataroom.urls")), 23 | # add privacy policy and terms of service URLs here use TemplateView.as_view 24 | path("privacy/", TemplateView.as_view(template_name="privacy.html"), name="privacy"), 25 | path("terms/", TemplateView.as_view(template_name="terms.html"), name="terms"), 26 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 27 | -------------------------------------------------------------------------------- /src/myapp/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for models.""" 2 | 3 | import solo.models 4 | from django.db import models 5 | 6 | 7 | class SiteConfiguration(solo.models.SingletonModel): 8 | """Store the configuration of the site.""" 9 | 10 | include_staff_in_analytics = models.BooleanField( 11 | default=False, 12 | help_text="Include staff in analytics.", 13 | ) 14 | 15 | js_analytics = models.TextField( 16 | blank=True, 17 | default="", 18 | help_text="Javascript to be included before the closing body tag. You should include the script tags.", 19 | ) 20 | 21 | js_head = models.TextField( 22 | blank=True, 23 | default="", 24 | help_text="Javascript to be included in the head tag. You should include the script tags.", 25 | ) 26 | js_body = models.TextField( 27 | blank=True, 28 | default="", 29 | help_text="Javascript to be included before the closing body tag. You should include the script tags.", 30 | ) 31 | 32 | def __str__(self) -> str: 33 | """Return the model name.""" 34 | return "Site Configuration" 35 | 36 | 37 | __all__ = ["SiteConfiguration"] 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Django Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | workflow_dispatch: 7 | workflow_call: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Build Docker image 21 | run: docker build -t dri-test . 22 | 23 | - name: Run Django tests 24 | run: | 25 | docker run --rm \ 26 | -e DATABASE_URL=sqlite:///test.db \ 27 | -e SECRET_KEY=test-secret-key-for-github-actions-only \ 28 | -e DEBUG=True \ 29 | -e BASE_URL=http://localhost:8000 \ 30 | -e AWS_ACCESS_KEY_ID=test-access-key \ 31 | -e AWS_SECRET_ACCESS_KEY=test-secret-key \ 32 | -e AWS_STORAGE_BUCKET_NAME=testbucket \ 33 | -e AWS_S3_REGION_NAME=us-east-1 \ 34 | -e AWS_S3_ENDPOINT_URL=http://localhost:9000 \ 35 | -e AWS_S3_USE_SSL=false \ 36 | -e EMAIL_URL=smtp://localhost:1025 \ 37 | -e DEFAULT_FROM_EMAIL=test@localhost \ 38 | --entrypoint "" \ 39 | dri-test \ 40 | uv run manage.py test 41 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-yaml 11 | - id: check-added-large-files 12 | 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | # Ruff version. 15 | rev: v0.12.4 16 | hooks: 17 | # Run the formatter first. 18 | - id: ruff-format 19 | exclude: ruff.xml 20 | # Then run the linter. 21 | - id: ruff 22 | exclude: ruff.xml 23 | 24 | - repo: https://github.com/PyCQA/bandit 25 | rev: 1.8.0 26 | hooks: 27 | - id: bandit 28 | args: ['-c', 'pyproject.toml'] 29 | 30 | - repo: local 31 | hooks: 32 | - id: check-django-allauth-prod 33 | name: Check django-allauth is production-ready 34 | entry: | 35 | bash -c 'if grep -E "(editable.*django-allauth|django-allauth.*\.\./)" pyproject.toml; then echo "ERROR: Local django-allauth detected. Run make prod-allauth first."; exit 1; fi' 36 | language: system 37 | files: pyproject.toml 38 | pass_filenames: false 39 | -------------------------------------------------------------------------------- /src/templates/allauth/layouts/entrance.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/layouts/base.html" %} 2 | {% load i18n allauth %} 3 | {% block body %} 4 |
    5 |
    6 |
    7 |
    8 | {% if messages %} 9 | {% for message in messages %} 10 | {% element alert level=message.tags %} 11 | {% slot message %} 12 | {{ message }} 13 | {% endslot %} 14 | {% endelement %} 15 | {% endfor %} 16 | {% endif %} 17 |
    18 |
    19 | {% block content %}{% endblock %} 20 |
    21 |
    22 |
    23 |
    24 |
    25 |
    26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /src/static/site.js: -------------------------------------------------------------------------------- 1 | // Check and apply dark mode preference from local storage 2 | function applyDarkModePreference() { 3 | // get the dark mode preference value from localStorage. default to light if no value is found 4 | const darkModePreference = localStorage.getItem('dark-mode') || 'light'; 5 | 6 | if (darkModePreference === 'dark') { 7 | document.documentElement.classList.add('dark'); 8 | } else { 9 | document.documentElement.classList.remove('dark'); 10 | } 11 | } 12 | 13 | // Toggle dark mode and update local storage 14 | function toggleDarkMode() { 15 | const isDark = document.documentElement.classList.contains('dark'); 16 | 17 | if (isDark) { 18 | document.documentElement.classList.remove('dark'); 19 | localStorage.setItem('dark-mode', 'light'); 20 | } else { 21 | document.documentElement.classList.add('dark'); 22 | localStorage.setItem('dark-mode', 'dark'); 23 | } 24 | } 25 | 26 | // Apply dark mode preference on page load 27 | window.addEventListener('load', function() { 28 | applyDarkModePreference(); 29 | const darkModeToggle = document.getElementById('dark-mode-toggle'); 30 | if (darkModeToggle) { 31 | darkModeToggle.onclick = toggleDarkMode; 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /src/myapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.5 on 2025-11-17 20:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='SiteConfiguration', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('include_staff_in_analytics', models.BooleanField(default=False, help_text='Include staff in analytics.')), 19 | ('js_analytics', models.TextField(blank=True, default='', help_text='Javascript to be included before the closing body tag. You should include the script tags.')), 20 | ('js_head', models.TextField(blank=True, default='', help_text='Javascript to be included in the head tag. You should include the script tags.')), 21 | ('js_body', models.TextField(blank=True, default='', help_text='Javascript to be included before the closing body tag. You should include the script tags.')), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /src/dataroom/templates/dataroom/upload_archived.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Upload Archived 7 | 8 | 9 | 10 |
    11 | 12 | 13 | 14 |

    Upload Archived

    15 |

    This upload endpoint has been archived and is no longer accepting files. Please contact your administrator if you need assistance.

    16 |
    17 | 18 | 19 | -------------------------------------------------------------------------------- /src/myapp/templates/myapp/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
    5 | {# #} 6 |

    Django Reference Implementation

    7 |
    8 |

    9 | is a simple(ish) solution to help developers ship a production-ready Django application. 10 | It covers common use cases and provide examples in a working project, ready to be customized and deployed. 11 |

    12 |
    13 | 14 | 15 |
    16 |
    17 |
    18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.1.4 (2025-07-21) 2 | 3 | ### Feat 4 | 5 | - configure dependabot groups to batch related dependency updates 6 | 7 | ### Fix 8 | 9 | - update entrypoint.sh to use uv run for Django commands 10 | 11 | ## v0.1.3 (2025-07-21) 12 | 13 | ### Fix 14 | 15 | - use commitizen's built-in changelog generation in release workflow 16 | 17 | ## v0.1.2 (2025-07-21) 18 | 19 | ## v0.1.1 (2025-07-21) 20 | 21 | ### Feat 22 | 23 | - **analytics**: added a site config area for site analytics with optional enable/disable for staff 24 | - **legal**: added default privacy policy and terms of service 25 | - **2fa**: added enable/disable site-wide enforcement of 2fa on user accounts 26 | - **2fa**: added 2fa via django allauth 27 | - **2fa**: added 2fa via django allauth 28 | - **allauth**: layered in bootstrap theme for django allauth pages 29 | 30 | ### Fix 31 | 32 | - add commitizen to dev dependencies for release workflow 33 | - add workflow_call trigger to make test.yml reusable 34 | - make release workflow depend on test workflow (DRY principle) 35 | - use direct environment variables in CI instead of file mounting 36 | - mount env.test directly in GitHub Actions 37 | - **alerts**: move alert to be on very top above navbar 38 | - **pyproject.toml**: fix typo 39 | - **myapp**: fix the bug with logger 40 | - **profile**: resolve name colission and closes #132 41 | 42 | ### Refactor 43 | 44 | - **linting**: ignore RUF012 in pyproject.toml 45 | -------------------------------------------------------------------------------- /src/myapp/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static solo_tags %}{% get_solo 'myapp.SiteConfiguration' as site_config %} 2 | 3 | 4 | 5 | 6 | {% block meta_title %}{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | {{ site_config.js_head|safe }} 22 | {% block stylesheets %}{% endblock %} 23 | {% block javascript %}{% endblock %} 24 | 25 | 26 | 27 | {% include "_alerts.html" %} 28 | {% include "_header.html" %} 29 |
    30 | {% block content %}{% endblock %} 31 |
    32 | {{ site_config.js_body|safe }} 33 | 34 | {% if request.user.is_staff and site_config.include_staff_in_analytics %} 35 | {{ site_config.js_analytics|safe }} 36 | {% endif %} 37 | 38 | {% if not request.user.is_staff %} 39 | {{ site_config.js_analytics|safe }} 40 | {% endif %} 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/dataroom/migrations/0002_bulkdownload.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.5 on 2025-11-18 14:40 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('dataroom', '0001_initial'), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='BulkDownload', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('downloaded_at', models.DateTimeField(auto_now_add=True)), 21 | ('ip_address', models.GenericIPAddressField(blank=True, null=True)), 22 | ('file_count', models.IntegerField(help_text='Number of files included in the zip')), 23 | ('total_bytes', models.BigIntegerField(help_text='Total size of all files in bytes')), 24 | ('downloaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bulk_downloads', to=settings.AUTH_USER_MODEL)), 25 | ('endpoint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_downloads', to='dataroom.dataendpoint')), 26 | ], 27 | options={ 28 | 'verbose_name': 'Bulk Download', 29 | 'verbose_name_plural': 'Bulk Downloads', 30 | 'ordering': ['-downloaded_at'], 31 | }, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /docs/manifesto.md: -------------------------------------------------------------------------------- 1 | **Disclaimer:** This is a learning exercise for myself, and I wanted to 2 | solicit feedback from the community here. 3 | 4 | I imagine that many of us start projects with a fair bit of energy only to lose 5 | steam after a few days or weeks. Github is littered with the shells of 6 | well-intentioned projects that never really bared fruit. Life happens, and 7 | "humans are gonna human," so there is no shame in it. 8 | 9 | I've been guilty of it for sure. But, maybe--just maybe--I'm able to change that. 10 | 11 | ## The idea is simple (but execution is hard): 12 | - Strive to make this repo 1% better every day and let the improvements compound 13 | over time. 14 | - Play the long-game and turn it into a viable, useful project. 15 | - Establish good habits any life-long developer should have (thats me). 16 | 17 | ## PERSONAL GOALS 18 | - I will use this repo to not only improve the code but also setup a healthy 19 | open-source project using best practices from GitHub and the broader community. 20 | - I will improve my skills as an open source developer/maintainer, developing 21 | deeper skills with async comms, distributed development, and being a benevolent 22 | dictator. 23 | - I will become even better at breaking down complex and risky changes into 24 | units of work that I can deliver in small chunks, safely. 25 | - I will make QA/Testing a 1st class citizen and will be part of my 1% deliveries 26 | - I will use this repo as the baseline for testing new business ideas and learning 27 | new technologies (eg LLMs, Blockchain, etc) 28 | 29 | ## RISKS 30 | - Over time things become more feature complete and we slow the rate of improvement 31 | -------------------------------------------------------------------------------- /src/templates/privacy.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block meta_title %}Privacy Policy{% endblock %} 4 | 5 | {% block meta_description %}Privacy policy for {{ site.name }}{% endblock %} }} 6 | 7 | {% block content %} 8 |

    Privacy Policy

    9 |

    Your privacy is important to us. It is {{ site.name }}'s policy to respect your privacy regarding any information we may collect from you across our website, {{ site.domain }}, and other sites we own and operate.

    10 |

    We only ask for personal information when we truly need it to provide a service to you. We collect it by fair and lawful means, with your knowledge and consent. We also let you know why we’re collecting it and how it will be used.

    11 |

    We only retain collected information for as long as necessary to provide you with your requested service. What data we store, we’ll protect within commercially acceptable means to prevent loss and theft, as well as unauthorised access, disclosure, copying, use or modification.

    12 |

    We don’t share any personally identifying information publicly or with third-parties, except when required to by law.

    13 |

    Our website may link to external sites that are not operated by us. Please be aware that we have no control over the content and practices of these sites, and cannot accept responsibility or liability for their respective privacy policies.

    14 |

    You are free to refuse our request for your personal information, with the understanding that we may be unable to provide you with some of your desired services.

    15 |

    Your continued use of our website will be regarded as acceptance of our practices around privacy and personal information. If you have any questions about how we handle user data and personal information, feel free to contact us.

    16 |

    This policy is effective as of 1 January 2025.

    17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | django: 4 | restart: unless-stopped 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | env_file: env.sample 9 | volumes: 10 | - ./src:/app 11 | ports: 12 | - "8000:8000" 13 | depends_on: 14 | postgres: 15 | condition: service_healthy 16 | s3proxy: 17 | condition: service_started 18 | mailpit: 19 | condition: service_started 20 | # for development only, to make sure the server reloads when the code changes 21 | command: python manage.py runserver 0.0.0.0:8000 22 | 23 | postgres: 24 | image: postgres:16 25 | restart: unless-stopped 26 | volumes: 27 | - postgres_data:/var/lib/postgresql/data 28 | environment: 29 | # this matches the values in the env.sample file 30 | POSTGRES_DB: django_reference 31 | POSTGRES_USER: postgres 32 | POSTGRES_PASSWORD: pass123 33 | ports: 34 | - "5432:5432" 35 | healthcheck: 36 | test: ["CMD-SHELL", "pg_isready -U postgres"] 37 | interval: 10s 38 | timeout: 5s 39 | retries: 5 40 | 41 | s3proxy: 42 | restart: unless-stopped 43 | image: andrewgaul/s3proxy:sha-4976e17 44 | platform: linux/arm64 # Use this line for ARM64 (Apple M1). Remove for x86. 45 | ports: 46 | - "9000:80" 47 | volumes: 48 | - s3proxy_data:/data 49 | environment: 50 | S3PROXY_AUTHORIZATION: none 51 | S3PROXY_STORAGE: filesystem 52 | 53 | # if in development 54 | mailpit: 55 | image: axllent/mailpit 56 | restart: unless-stopped 57 | volumes: 58 | - mailpit_data:/data 59 | ports: 60 | - 8025:8025 61 | - 1025:1025 62 | environment: 63 | MP_MAX_MESSAGES: 5000 64 | MP_DATABASE: /data/mailpit.db 65 | MP_SMTP_AUTH_ACCEPT_ANY: 1 66 | MP_SMTP_AUTH_ALLOW_INSECURE: 1 67 | 68 | simple_async_worker: 69 | build: 70 | context: . 71 | dockerfile: Dockerfile 72 | command: ./manage.py simple_async_worker 73 | restart: unless-stopped 74 | env_file: env 75 | volumes: 76 | - ./src:/app 77 | depends_on: 78 | - postgres 79 | 80 | volumes: 81 | postgres_data: 82 | s3proxy_data: 83 | mailpit_data: 84 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release_type: 7 | description: 'Release type' 8 | required: true 9 | default: 'patch' 10 | type: choice 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | 16 | permissions: 17 | contents: write 18 | id-token: write 19 | 20 | jobs: 21 | test: 22 | uses: ./.github/workflows/test.yml 23 | 24 | ruff: 25 | uses: ./.github/workflows/ruff.yml 26 | 27 | release: 28 | runs-on: ubuntu-latest 29 | needs: [test, ruff] 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Install uv 38 | uses: astral-sh/setup-uv@v4 39 | with: 40 | version: "latest" 41 | 42 | - name: Set up Python 43 | uses: actions/setup-python@v5 44 | with: 45 | python-version: "3.12" 46 | 47 | - name: Install dependencies 48 | run: uv sync --extra dev 49 | 50 | - name: Configure Git 51 | run: | 52 | git config user.name "github-actions[bot]" 53 | git config user.email "github-actions[bot]@users.noreply.github.com" 54 | 55 | - name: Bump version and create changelog 56 | run: | 57 | uv run cz bump --increment ${{ inputs.release_type }} --yes 58 | echo "NEW_VERSION=$(uv run cz version --project)" >> $GITHUB_ENV 59 | 60 | - name: Push changes and tags 61 | run: | 62 | git push origin master 63 | git push origin --tags 64 | 65 | - name: Extract latest changelog entry 66 | run: | 67 | # Extract the latest release notes from CHANGELOG.md 68 | sed -n '/^## v${{ env.NEW_VERSION }}/,/^## /p' CHANGELOG.md | sed '$d' > RELEASE_NOTES.md 69 | # If extraction failed, use a fallback message 70 | if [ ! -s RELEASE_NOTES.md ]; then 71 | echo "## v${{ env.NEW_VERSION }}" > RELEASE_NOTES.md 72 | echo "" >> RELEASE_NOTES.md 73 | echo "Release v${{ env.NEW_VERSION }}" >> RELEASE_NOTES.md 74 | fi 75 | 76 | - name: Create GitHub Release 77 | uses: softprops/action-gh-release@v2 78 | with: 79 | tag_name: v${{ env.NEW_VERSION }} 80 | body_path: RELEASE_NOTES.md 81 | generate_release_notes: true 82 | draft: false 83 | prerelease: false 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | 87 | - name: Release summary 88 | run: | 89 | echo "🚀 Release v${{ env.NEW_VERSION }} completed successfully!" 90 | echo "📝 Release notes: https://github.com/${{ github.repository }}/releases/tag/v${{ env.NEW_VERSION }}" 91 | -------------------------------------------------------------------------------- /src/templates/allauth/layouts/manage.html: -------------------------------------------------------------------------------- 1 | {% extends "allauth/layouts/base.html" %} 2 | {% load allauth %} 3 | {% block body %} 4 |
    5 | 44 |
    45 |
    46 | {% if messages %} 47 |
    48 | {% for message in messages %} 49 | {% element alert level=message.tags %} 50 | {% slot message %} 51 | {{ message }} 52 | {% endslot %} 53 | {% endelement %} 54 | {% endfor %} 55 |
    56 | {% endif %} 57 | {% block content %}{% endblock %} 58 |
    59 |
    60 |
    61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include config.mk 2 | 3 | ########################################################################## 4 | # MENU 5 | ########################################################################## 6 | .PHONY: help 7 | help: 8 | @awk 'BEGIN {FS = ":.*?## "} /^[0-9a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 9 | 10 | data/: 11 | mkdir -p data/ 12 | 13 | env: 14 | cp env.example env 15 | 16 | .venv/: data/ env 17 | python -m venv .venv/ 18 | source venv/bin/activate && pip install --upgrade pip 19 | source venv/bin/activate && pip install -r requirements.txt 20 | source venv/bin/activate && pip install -r requirements-dev.txt 21 | source venv/bin/activate && pre-commit install 22 | 23 | .PHONY: migrate 24 | migrate: ## Run Django migrations 25 | docker compose run -it --rm django ./manage.py migrate 26 | 27 | .PHONY: superuser 28 | superuser: ## Create a superuser 29 | docker compose run -it --rm django ./manage.py createsuperuser 30 | 31 | .PHONY: dev-bootstrap 32 | dev-bootstrap: .venv/ ## Bootstrap the development environment 33 | docker compose pull 34 | docker compose build 35 | docker compose up -d postgres 36 | $(MAKE) migrate 37 | $(MAKE) superuser 38 | docker compose down 39 | 40 | .PHONY: dev-start 41 | dev-start: ## Start the development environment 42 | docker compose up -d 43 | sleep 5 44 | curl --request PUT http://localhost:9000/testbucket 45 | 46 | .PHONY: dev-stop 47 | dev-stop: ## Stop the development environment 48 | docker compose down 49 | 50 | .PHONY: dev-restart-django 51 | dev-restart-django: ## Restart the Django service 52 | docker compose up -d --force-recreate django 53 | 54 | .PHONY: snapshot-local-db 55 | snapshot-local-db: ## Create a snapshot of the local database 56 | docker compose exec postgres pg_dump -U postgres -Fc django_reference > django_reference.dump 57 | 58 | .PHONY: restore-local-db 59 | restore-local-db: ## Restore the local database from a snapshot 60 | docker compose exec -T postgres pg_restore -U postgres -d django_reference < django_reference.dump 61 | 62 | logs/: 63 | mkdir -p logs/ 64 | 65 | .PHONY: runserver 66 | runserver: logs/ ## Run Django development server with logging to logs/server.log 67 | @echo "Starting Django server on http://0.0.0.0:8008 (logs: logs/server.log)" 68 | uv run src/manage.py runserver 0.0.0.0:8008 2>&1 | tee logs/server.log 69 | 70 | ########################################################################## 71 | # DJANGO-ALLAUTH DEPENDENCY MANAGEMENT 72 | ########################################################################## 73 | 74 | .PHONY: dev-allauth 75 | dev-allauth: ## Switch to local editable django-allauth install (if workspace exists) 76 | @echo "⚠️ Local development not fully working yet - use prod-allauth instead" 77 | @exit 1 78 | 79 | .PHONY: prod-allauth 80 | prod-allauth: ## Switch to remote git django-allauth source 81 | -uv remove django-allauth 82 | uv add "django-allauth[mfa,socialaccount] @ git+https://github.com/heysamtexas/django-allauth.git@heysamtexas-patches" 83 | @echo "✅ Switched to remote git django-allauth" 84 | 85 | .PHONY: allauth-status 86 | allauth-status: ## Show current django-allauth source 87 | @echo "Current django-allauth dependency:" 88 | @grep "django-allauth" pyproject.toml || echo "django-allauth not found in pyproject.toml" 89 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django_reference_implementation" 7 | authors = [{name = "SimpleCTO", email = "github+django_reference_implementation@simplecto.com"}] 8 | readme = "README.md" 9 | license = {file = "LICENSE"} 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | description = "Django Reference Implementation, a boilerplate Django project." 12 | version = "0.1.4" 13 | requires-python = ">=3.12" 14 | dependencies = [ 15 | "asgiref==3.9.1", 16 | "certifi==2025.8.3", 17 | "charset-normalizer==3.4.3", 18 | "django-environ==0.12.0", 19 | "Django==5.2.5", 20 | "django-solo==2.4.0", 21 | "gunicorn==23.0.0", 22 | "idna==3.10", 23 | "packaging==25.0", 24 | "psycopg2-binary==2.9.10", 25 | "pytz==2025.2", 26 | "requests==2.32.5", 27 | "sqlparse==0.5.3", 28 | "typing_extensions==4.14.1", 29 | "urllib3==2.5.0", 30 | "whitenoise==6.9.0", 31 | "PyJWT==2.10.1", 32 | "django-allauth[mfa,socialaccount]", 33 | "django-allauth-require2fa", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | dev = [ 38 | "ruff==0.12.9", 39 | "pre-commit==4.3.0", 40 | "commitizen>=3.0.0", 41 | "bandit==1.8.6", 42 | "radon==6.0.1", 43 | "vulture==2.14", 44 | "mypy==1.17.1", 45 | "django-stubs[compatible-mypy]==5.2.2", 46 | ] 47 | 48 | [tool.hatch.build.targets.wheel] 49 | packages = ["src"] 50 | 51 | [project.urls] 52 | Home = "https://github.com/simplecto/django-reference-implementation" 53 | 54 | [tool.ruff] 55 | line-length = 120 56 | target-version = "py312" 57 | exclude = [ 58 | "pyproject.toml", 59 | "src/**/tests/*", 60 | "src/**/migrations/*", 61 | ".idea/**", 62 | "src/manage.py", 63 | ] 64 | 65 | [tool.ruff.lint] 66 | 67 | ignore = [ 68 | "E501", 69 | "D203", 70 | "D213", 71 | "D100", 72 | "COM812", 73 | "RUF012", # mutable class attributes (too pedantic for Django models) 74 | ] 75 | select = ["ALL", "W2", "I"] 76 | exclude = [ 77 | "pyproject.toml", 78 | "src/**/tests/**", 79 | "src/**/migrations/**", 80 | ".idea", 81 | "src/manage.py", 82 | ] 83 | 84 | [tool.bandit] 85 | exclude = ["src/**/tests/**", "src/**/migrations/*"] 86 | skips = ["B106"] 87 | 88 | [tool.mypy] 89 | python_version = "3.12" 90 | plugins = ["mypy_django_plugin.main"] 91 | exclude = [ 92 | ".*/migrations/.*", 93 | ".*/tests/.*", 94 | "manage.py", 95 | ] 96 | # Suppress some Django-related errors for cleaner output 97 | disable_error_code = ["import-untyped", "var-annotated"] 98 | 99 | [tool.django-stubs] 100 | django_settings_module = "config.settings" 101 | 102 | [tool.commitizen] 103 | name = "cz_conventional_commits" 104 | version = "0.1.4" 105 | tag_format = "v$version" 106 | bump_message = "bump: version $current_version → $new_version" 107 | update_changelog_on_bump = true 108 | changelog_file = "CHANGELOG.md" 109 | changelog_incremental = true 110 | version_files = [ 111 | "pyproject.toml:version", 112 | "Dockerfile:VERSION", 113 | ] 114 | 115 | [tool.uv.sources] 116 | django-allauth = { git = "https://github.com/heysamtexas/django-allauth.git", rev = "heysamtexas-patches" } 117 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/button.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 | {% comment %} djlint:off {% endcomment %} 3 | <{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %} 4 | {% if attrs.form %}form="{{ attrs.form }}"{% endif %} 5 | {% if attrs.id %}id="{{ attrs.id }}"{% endif %} 6 | {% if attrs.name %}name="{{ attrs.name }}"{% endif %} 7 | {% if attrs.type %}type="{{ attrs.type }}"{% endif %} 8 | {% if attrs.value %}value="{{ attrs.value }}"{% endif %} 9 | class="{% block class %} 10 | {% if "link" in attrs.tags %}text-blue-600 dark:text-blue-400 hover:underline 11 | {% else %} 12 | {% if "prominent" in attrs.tags %}px-6 py-3 text-lg{% elif "minor" in attrs.tags %}px-3 py-1.5 text-sm{% else %}px-4 py-2{% endif %} 13 | font-medium rounded-lg focus:ring-4 focus:outline-none 14 | {% if "danger" in attrs.tags %} 15 | {% if 'outline' in attrs.tags %}text-red-700 bg-white border border-red-700 hover:bg-red-100 dark:bg-gray-800 dark:text-red-400 dark:border-red-400 dark:hover:bg-gray-700{% else %}text-white bg-red-600 hover:bg-red-700 focus:ring-red-300 dark:focus:ring-red-800{% endif %} 16 | {% elif "secondary" in attrs.tags %} 17 | {% if 'outline' in attrs.tags %}text-gray-700 bg-white border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700{% else %}text-white bg-gray-600 hover:bg-gray-700 focus:ring-gray-300 dark:focus:ring-gray-800{% endif %} 18 | {% elif "warning" in attrs.tags %} 19 | {% if 'outline' in attrs.tags %}text-yellow-700 bg-white border border-yellow-700 hover:bg-yellow-100 dark:bg-gray-800 dark:text-yellow-400 dark:border-yellow-400 dark:hover:bg-gray-700{% else %}text-white bg-yellow-500 hover:bg-yellow-600 focus:ring-yellow-300 dark:focus:ring-yellow-800{% endif %} 20 | {% else %} 21 | {% if 'outline' in attrs.tags %}text-blue-700 bg-white border border-blue-700 hover:bg-blue-100 dark:bg-gray-800 dark:text-blue-400 dark:border-blue-400 dark:hover:bg-gray-700{% else %}text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-300 dark:focus:ring-blue-800{% endif %} 22 | {% endif %} 23 | {% endif %}{% endblock %}"> 24 | {% if "tool" in attrs.tags %} 25 | {% if "delete" in attrs.tags %} 26 | 27 | 28 | 29 | {% elif "edit" in attrs.tags %} 30 | 31 | 32 | 33 | {% endif %} 34 | {% endif %} 35 | 36 | {% if not "tool" in attrs.tags %} 37 | {% slot %} 38 | {% endslot %} 39 | {% endif %} 40 | 41 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # You made it this far, you must be serious. 2 | 3 | Ok, lets get started. You're gonna need the usual things to get started: 4 | 5 | # Requirements 6 | 7 | * Docker 8 | * Python 3.12 9 | * uv (for Python dependency management) 10 | * PostgreSQL 11 | * S3 Buckets (Provider of choice, we use Backblaze for production and 12 | S3Proxy[^1] for dev) 13 | * Domain with SSL certificate 14 | * `make` (seriously, you should have this) 15 | 16 | [^1]: https://github.com/gaul/s3proxy 17 | 18 | 19 | ## Local development 20 | 21 | * PyCharm (not required but recommended) 22 | 23 | 24 | ## Before you get Started 25 | If you are on x86 then you might need to edit the `docker-compose.yml`. 26 | 27 | Remove the `platform: linux/arm64` from the `s3proxy` service. 28 | 29 | --- 30 | 31 | # Wait, I'm a lazy bastard and just want to see it work. 32 | 33 | I got you, Bae. But you really need to get the OpenAI API key. When you have 34 | that come back. 35 | 36 | Just run the following command: 37 | 38 | ```bash 39 | make bootstrap-dev 40 | ``` 41 | 42 | It will : 43 | - pull containers 44 | - build the django container 45 | - install Python dependencies with uv 46 | - migrate the initial database 47 | - prompt you to create a superuser. 48 | 49 | _If you want to see all the things that does just peek at the `Makefile`._ 50 | 51 | NOTE: This does not start the crawlers. We will take that in the next step. 52 | 53 | ## Post bootstrap steps 54 | Now we are ready to rock. Let's spin up the full dev environment. 55 | 56 | ```bash 57 | make dev-start 58 | ``` 59 | 60 | Note, the workers will also start, but they do nothing. You will need to 61 | activate them in the admin (See below). 62 | 63 | When that is done point your browser to 64 | [http://localhost:8000/admin](http://localhost:8000/admin), and you should 65 | see the application running. Login as the superuser. 66 | 67 | ### Configure the site via the admin 68 | 69 | 1. [Site Name](http://localhost:8000/admin/sites/site/): Set up the name and 70 | domain in the admin. 71 | 72 | 2. [Global Site config](http://localhost:8000/admin/myapp/workerconfiguration/) 73 | Go to the "other" site configuration (yeah, yeah, I know) and check the 74 | following: 75 | 1. `Worker Enabled` - This will enable the workers to run. Globally. 76 | 2. `Worker sleep seconds` - This is the time in seconds that the workers 77 | will sleep between runs. 78 | 3`JS Head`: Javascript to run in the head of every page. This will be 79 | where you will put analytics, for example. 80 | 4`JS Body`: Javascript to run in the body of every page. This is the 81 | last tag in the body. 82 | 3. [Worker configs](http://localhost:8000/admin/myapp/workerconfiguration/): 83 | Manage the finer-grained settings for workers: 84 | 1. `Is enabled`: Enable the worker. 85 | 2. `Sleep seconds`: The time in seconds that the worker will sleep between 86 | runs. 87 | 3. `Log Level`: The log level for the worker. (This is important for 88 | debugging in production) 89 | 90 | --- 91 | 92 | 93 | ## Production deployment 94 | 95 | I currently use Dokku[^2] for deployment. It is a Heroku-like PaaS that you 96 | can 97 | run on your own servers. It is easy to use and has a lot of plugins. 98 | 99 | Another option is Docker compose. You can use the `docker-compose.yml` file to 100 | run the application locally or on a server. 101 | 102 | * Dokku (it should also work with Heroku) 103 | * Docker Compose 104 | * PostgreSQL 105 | * Domain with SSL certificate 106 | 107 | [^2]: https://dokku.com 108 | 109 | --- 110 | -------------------------------------------------------------------------------- /src/myapp/templates/_footer.html: -------------------------------------------------------------------------------- 1 | 60 | -------------------------------------------------------------------------------- /src/templates/terms.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block meta_title %}Terms of Service{% endblock %} 4 | 5 | {% block meta_description %}Terms of service for {{ site.name }}{% endblock %} }} 6 | 7 | {% block content %} 8 |

    Terms of Service

    9 |

    Welcome to {{ site.name }}!

    10 |

    These terms and conditions outline the rules and regulations for the use of {{ site.name }}'s Website, located at {{ site.domain }}.

    11 |

    By accessing this website we assume you accept these terms and conditions. Do not continue to use {{ site.name }} if you do not agree to take all of the terms and conditions stated on this page.

    12 |

    The following terminology applies to these Terms and Conditions, Privacy Statement and Disclaimer Notice and all Agreements: "Client", "You" and "Your" refers to you, the person log on this website and compliant to the Company’s terms and conditions. "The Company", "Ourselves", "We", "Our" and "Us", refers to our Company. "Party", "Parties", or "Us", refers to both the Client and ourselves. All terms refer to the offer, acceptance and consideration of payment necessary to undertake the process of our assistance to the Client in the most appropriate manner for the express purpose of meeting the Client’s needs in respect of provision of the Company’s stated services, in accordance with and subject to, prevailing law of Netherlands. Any use of the above terminology or other words in the singular, plural, capitalization and/or he/she or they, are taken as interchangeable and therefore as referring to same.

    13 |

    Cookies

    14 |

    We employ the use of cookies. By accessing {{ site.name }}, you agreed to use cookies in agreement with the {{ site.name }}'s Privacy Policy.

    15 |

    Most interactive websites use cookies to let us retrieve the user’s details for each visit. Cookies are used by our website to enable the functionality of certain areas to make it easier for people visiting our website. Some of our affiliate/advertising partners may also use cookies.

    16 |

    License

    17 |

    Unless otherwise stated, {{ site.name }} and/or its licensors own the intellectual property rights for all material on {{ site.name }}. All intellectual property rights are reserved. You may access this from {{ site.name }} for your own personal use subjected to restrictions set in these terms and conditions.

    18 |

    You must not:

    19 |
      20 |
    • Republish material from {{ site.name }}
    • 21 |
    • Sell, rent or sub-license material from {{ site.name }}
    • 22 |
    • Reproduce, duplicate or copy material from {{ site.name }}
    • 23 |
    • Redistribute content from {{ site.name }}
    • 24 |
    25 |

    This Agreement shall begin on the date hereof.

    26 |

    Parts of this website offer an opportunity for users to post and exchange opinions and information in certain areas of the website. {{ site.name }} does not filter, edit, publish or review Comments prior to their presence on the website. Comments do not reflect the views and opinions of {{ site.name }},its agents and/or affiliates. Comments reflect the views and opinions of the person who post their views and opinions. To the extent permitted by applicable laws, {{ site.name }} shall not be liable for the Comments or for any liability, damages or expenses caused and/or suffered as a result of any use of and/or posting of and/or appearance of the Comments on this website.

    27 |

    {{ site.name }} reserves the right to monitor all Comments and to remove any Comments which can be considered inappropriate, offensive or causes breach of these Terms and Conditions.

    28 |

    You warrant and represent that:

    29 |
      30 |
    • You are entitled to post the Comments on our website and have all necessary licenses and consents to do so;
    • 31 |
    • The Comments do not invade any intellectual property right, including without limitation copyright, patent or trademark of any third party;
    • 32 |
    • The Comments do not contain any defamatory, libelous, offensive, indecent or otherwise unlawful material which is an invasion of privacy
    • 33 |
    • The Comments will not be used to solicit or promote business or custom or present commercial activities or unlawful activity.
    • 34 |
    35 |

    You hereby grant {{ site.name }} a non-exclusive license to use, reproduce, edit and authorize others to use, reproduce and edit any of your Comments in any and all forms, formats or media.

    36 | 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /src/dataroom/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.5 on 2025-11-17 20:20 2 | 3 | import django.db.models.deletion 4 | import uuid 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Customer', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(help_text='Company or project name', max_length=255)), 23 | ('notes', models.TextField(blank=True, default='', help_text='Freeform notes (contacts, emails, project details, etc.)')), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customers_created', to=settings.AUTH_USER_MODEL)), 26 | ], 27 | options={ 28 | 'verbose_name': 'Customer', 29 | 'verbose_name_plural': 'Customers', 30 | 'ordering': ['-created_at'], 31 | }, 32 | ), 33 | migrations.CreateModel( 34 | name='DataEndpoint', 35 | fields=[ 36 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 37 | ('name', models.CharField(help_text="e.g., 'Q1 POC Upload', 'Phase 2 Data'", max_length=255)), 38 | ('description', models.TextField(blank=True, default='')), 39 | ('status', models.CharField(choices=[('active', 'Active'), ('disabled', 'Disabled'), ('archived', 'Archived')], default='active', max_length=20)), 40 | ('created_at', models.DateTimeField(auto_now_add=True)), 41 | ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='endpoints_created', to=settings.AUTH_USER_MODEL)), 42 | ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='endpoints', to='dataroom.customer')), 43 | ], 44 | options={ 45 | 'verbose_name': 'Data Endpoint', 46 | 'verbose_name_plural': 'Data Endpoints', 47 | 'ordering': ['-created_at'], 48 | }, 49 | ), 50 | migrations.CreateModel( 51 | name='UploadedFile', 52 | fields=[ 53 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 54 | ('filename', models.CharField(max_length=255)), 55 | ('file_path', models.CharField(help_text='Relative path in MEDIA_ROOT', max_length=500)), 56 | ('file_size_bytes', models.BigIntegerField()), 57 | ('content_type', models.CharField(blank=True, default='', max_length=255)), 58 | ('uploaded_at', models.DateTimeField(auto_now_add=True)), 59 | ('uploaded_by_ip', models.GenericIPAddressField(blank=True, null=True)), 60 | ('deleted_at', models.DateTimeField(blank=True, null=True)), 61 | ('deleted_by_ip', models.GenericIPAddressField(blank=True, null=True)), 62 | ('endpoint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='dataroom.dataendpoint')), 63 | ], 64 | options={ 65 | 'verbose_name': 'Uploaded File', 66 | 'verbose_name_plural': 'Uploaded Files', 67 | 'ordering': ['-uploaded_at'], 68 | }, 69 | ), 70 | migrations.CreateModel( 71 | name='FileDownload', 72 | fields=[ 73 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 74 | ('downloaded_at', models.DateTimeField(auto_now_add=True)), 75 | ('ip_address', models.GenericIPAddressField(blank=True, null=True)), 76 | ('downloaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='file_downloads', to=settings.AUTH_USER_MODEL)), 77 | ('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downloads', to='dataroom.uploadedfile')), 78 | ], 79 | options={ 80 | 'verbose_name': 'File Download', 81 | 'verbose_name_plural': 'File Downloads', 82 | 'ordering': ['-downloaded_at'], 83 | }, 84 | ), 85 | ] 86 | -------------------------------------------------------------------------------- /src/templates/allauth/elements/field.html: -------------------------------------------------------------------------------- 1 | {% load allauth %} 2 | {% if attrs.type == "checkbox" or attrs.type == "radio" %} 3 |
    4 | 11 | 15 | {% if slots.help_text %} 16 |
    17 | {% slot help_text %} 18 | {% endslot %} 19 |
    20 | {% endif %} 21 |
    22 | {% elif attrs.type == "textarea" %} 23 |
    24 | 28 | 35 |
    36 | {% elif attrs.type == "hidden" %} 37 | 42 | {% elif attrs.type == "select" %} 43 |
    44 | 48 | 54 |
    55 | {% else %} 56 |
    57 | 61 | 73 | {% if slots.help_text %} 74 |
    75 | {% slot help_text %} 76 | {% endslot %} 77 |
    78 | {% endif %} 79 | {% if attrs.errors %} 80 | {% for error in attrs.errors %}{% endfor %} 81 | {% endif %} 82 |
    83 | {% endif %} 84 | -------------------------------------------------------------------------------- /src/require2fa/README.md: -------------------------------------------------------------------------------- 1 | # Django Require 2FA 2 | 3 | A production-ready Django app that enforces Two-Factor Authentication (2FA) across your entire application. 4 | 5 | ## Why This Exists 6 | 7 | ### The Django-Allauth Gap 8 | 9 | Django-allauth provides excellent 2FA functionality, but **intentionally does not include** site-wide 2FA enforcement. This decision was made explicit in: 10 | 11 | - **[PR #3710](https://github.com/pennersr/django-allauth/pull/3710)** - A middleware approach was proposed but **rejected** by the maintainer 12 | - **[Issue #3649](https://github.com/pennersr/django-allauth/issues/3649)** - Community discussed various enforcement strategies, issue was **closed without implementation** 13 | 14 | The django-allauth maintainer's position: 15 | > "leave such functionality for individual projects to implement" 16 | 17 | ### The Enterprise Need 18 | 19 | Many organizations need to: 20 | - Enforce 2FA for **all users** (not optional) 21 | - Configure 2FA requirements **at runtime** (not hardcoded) 22 | - Support **SaaS/multi-tenant** scenarios with organization-level policies 23 | - Maintain **audit trails** of security configuration changes 24 | 25 | Since django-allauth won't provide this, there's a clear market need for a standalone solution. 26 | 27 | ## Our Solution 28 | 29 | ### What We Built 30 | 31 | This app provides what the **rejected django-allauth PR attempted**, but with significant improvements: 32 | 33 | | Feature | Rejected PR #3710 | Our Implementation | 34 | |---------|------------------|-------------------| 35 | | URL Matching | String prefix matching (vulnerable) | Proper Django URL resolution | 36 | | Configuration | Hardcoded settings | Runtime admin configuration | 37 | | Testing | Basic tests | 15 comprehensive security tests | 38 | | Security | Known vulnerabilities | Production-hardened | 39 | | Admin Protection | Exempt admin login | Proper 2FA for admin access | 40 | 41 | ### Key Security Features 42 | 43 | - **Vulnerability Protection**: Fixed Issue #173 path traversal attacks 44 | - **URL Resolution**: Uses Django's proper URL resolution instead of dangerous string matching 45 | - **Configuration Validation**: Prevents dangerous Django settings misconfigurations 46 | - **Comprehensive Testing**: 15 security tests covering edge cases, malformed URLs, and regression scenarios 47 | - **Admin Security**: Removed admin login exemption (admins now require 2FA) 48 | 49 | ### Architecture 50 | 51 | - **Django-Solo Pattern**: Runtime configuration via admin interface 52 | - **Middleware Approach**: Site-wide enforcement without code changes 53 | - **Allauth Integration**: Works seamlessly with django-allauth's MFA system 54 | - **Production Ready**: Data migrations, backward compatibility, zero downtime 55 | 56 | ## Usage 57 | 58 | ### Installation (Internal) 59 | 60 | 1. Add to `INSTALLED_APPS`: 61 | ```python 62 | INSTALLED_APPS = [ 63 | # ... 64 | 'require2fa', 65 | # ... 66 | ] 67 | ``` 68 | 69 | 2. Add to `MIDDLEWARE`: 70 | ```python 71 | MIDDLEWARE = [ 72 | # ... 73 | 'require2fa.middleware.Require2FAMiddleware', 74 | ] 75 | ``` 76 | 77 | 3. Run migrations: 78 | ```bash 79 | python manage.py migrate require2fa 80 | ``` 81 | 82 | ### Configuration 83 | 84 | Visit Django Admin → Two-Factor Authentication Configuration: 85 | - **Require Two-Factor Authentication**: Toggle 2FA enforcement site-wide 86 | - Changes take effect immediately (no restart required) 87 | 88 | ### How It Works 89 | 90 | 1. **Authenticated users** without 2FA are redirected to `/accounts/2fa/` 91 | 2. **Exempt URLs** (login, logout, 2FA setup) remain accessible 92 | 3. **Static/media files** are automatically detected and exempted 93 | 4. **Admin access** requires 2FA verification (security improvement) 94 | 95 | ## Testing 96 | 97 | Run the comprehensive test suite: 98 | ```bash 99 | python manage.py test require2fa 100 | ``` 101 | 102 | **15 security tests** covering: 103 | - URL resolution edge cases 104 | - Malformed URL handling 105 | - Static file exemptions 106 | - Admin protection 107 | - Configuration security 108 | - Regression tests for known vulnerabilities 109 | 110 | ## Future: Package Extraction 111 | 112 | This app is designed to be **extracted into a standalone Django package**: 113 | 114 | ### Target Package Structure 115 | ``` 116 | django-require2fa/ 117 | ├── pyproject.toml # Modern Python packaging 118 | ├── README.md # Installation & usage docs 119 | ├── LICENSE # Open source license 120 | ├── CHANGELOG.md # Version history 121 | ├── .github/workflows/ # CI/CD pipeline 122 | └── require2fa/ # This app (copy-paste ready) 123 | ``` 124 | 125 | ### Market Positioning 126 | - **Fills django-allauth gap**: Provides what they explicitly won't 127 | - **Enterprise-ready**: SaaS/multi-tenant 2FA enforcement 128 | - **Security-first**: Production-hardened with comprehensive testing 129 | - **Community need**: Addresses requests from Issues #3649 and PR #3710 130 | 131 | ## Contributing 132 | 133 | When making changes: 134 | 1. **Security first** - any middleware changes need security review 135 | 2. **Comprehensive testing** - maintain the 15-test security suite 136 | 3. **Backward compatibility** - consider migration paths 137 | 4. **Documentation** - update this README with architectural decisions 138 | 139 | ## License 140 | 141 | MIT License - ready for open source packaging and community adoption. 142 | -------------------------------------------------------------------------------- /docs/async_tasks.md: -------------------------------------------------------------------------------- 1 | 2 | I stole this documentation from My Blog on SimpleCTO: 3 | - [Django Async Task Queue with Postgres](https://simplecto.com/djang-async-task-postgres-not-kafka-celery-redis/) 4 | 5 | # Django Async Task Queue with Postgres (no Kafka, Rabbit MQ, Celery, or Redis) 6 | 7 | Quickly develop async task queues with only django commands and postgresql. I dont need the complexity of Kafka, RabbitMQ, or Celery. 8 | 9 | tldr; Let's talk about simple async task queues with Django Commands and PostgreSQL. 10 | 11 | Simple Django Commands make excellent asynchronous workers that replace tasks otherwise done by Celery and other more complex methods. Minimal code samples are below. 12 | 13 | I can run asynchronous tasks without adding Celery, Kafka, RabbitMQ, etc to my stack 14 | Many projects use Celery successfully, but it is a lot to setup, manage, monitor, debug, and develop for. To send a few emails, push messages, or database cleanups feels like a long way to go for a ham sandwich. 15 | 16 | Django gives us a very useful utility for running asynchronous tasks without the cognitive load and infrastructure overhead– namely, Commands. 17 | 18 | I go a long way using only simple long-running Django Commands as the workers and Postgres as my queue. It means that in a simple Django, Postgres, Docker deployment I can run asynchronous tasks without adding more services to my stack. 19 | 20 | The Django Command 21 | In addition to serving web pages, Django Commands offer us a way to access the Django environment on the command line or a long-running process. Simply override a class, place your code inside, and you have bootstrapped your way to getting things done in a "side-car." 22 | 23 | It is important to understand this is outside the processing context of the web application. It must therefore be managed separately as well. I like to use Docker and docker-compose for that. 24 | 25 | sample docker-compose snipped of django task 26 | 27 | ```yaml 28 | services: 29 | 30 | hello-world: 31 | image: hello-world-image 32 | name: hello-world 33 | restart: unless-stopped 34 | command: ./manage.py hello_world 35 | ``` 36 | 37 | The power and simplicity of a while loop and sleep function 38 | Let's expand on the Command for a moment and explore the simple mechanism to keep this process long-lived. 39 | 40 | A sample "hello world" Django Command 41 | 42 | app/management/commands/hello_world.py 43 | 44 | ```python 45 | from time import sleep 46 | from django.core.management.base import BaseCommand, CommandError 47 | 48 | 49 | class Command(BaseCommand): 50 | help = 'Print Hello World every Hour' 51 | 52 | def handle(self, *args, **options): 53 | self.stdout.write(self.style.SUCCESS("Starting job...")) 54 | 55 | while True: 56 | self.stdout.write(self.style.SUCCESS(f"Hello, World.")) 57 | do_the_work() 58 | sleep(3600) # sleep 1 hour 59 | ``` 60 | 61 | The command simply loops, executes commands, and sleeps for a number of seconds. For more frequent calls simply reduce the sleep time. 62 | 63 | You should refer to the actual Django Docs here: 64 | 65 | https://docs.djangoproject.com/en/3.1/howto/custom-management-commands/ 66 | Some more robust and deeper examples of the While/Do pattern 67 | Yeah, I hate over-simplified examples, too. Here, have a look where I use this pattern in my open-source screenshot making application: 68 | 69 | https://github.com/simplecto/screenshots/blob/master/src/shots/management/commands/screenshot_worker_ff.py 70 | The Database-as-a-Queue 71 | It is easy to track the state of your asynchronous tasks. Simply ask your database to manage it. On a high-level the process is something like this: 72 | 73 | Query the database for a batch of tasks (1 to X at a time). 74 | Of those tasks handed to the worker, update their status to "IN PROGRESS" and time stamp it. 75 | Begin work on each task. 76 | When task is finished, update the status of the task to "DONE". 77 | If the task crashes then the database locks are released. The items are put back into the queue without changes. No harm, no foul. 78 | The devil in the details, even in simple solutions (eg. Avoiding dead-locks) 79 | If running multiple workers, it is possible that they pull the same tasks at the same time. In order to prevent this we ask the database to lock the rows when they are selected. This feature is not available on all databases. 80 | 81 | I pretty much only use PostgreSQL, and doing so gives me access to some nice features like SKIP LOCKED when querying the database. 82 | 83 | https://www.2ndquadrant.com/en/blog/what-is-select-skip-locked-for-in-postgresql-9-5/ 84 | However, we are not done yet. The smart and thoughtful Django devs brought this into the core: 85 | 86 | https://docs.djangoproject.com/en/2.2/ref/models/querysets/#select-for-update 87 | Make sure to read into the details on this. More devils inside. 88 | 89 | Need more tasks? No problem. 90 | Using skip-locked above, simply run more services with: 91 | 92 | `docker-compose scale worker=3` 93 | 94 | An exercise for the reader 95 | There are a number of things left out of this article on purpose: 96 | 97 | Exception handling 98 | Retry failed tasks - what strategies (eg - exponential back-off) 99 | Clean shutdown (handling SIGINT and friends) 100 | Multi-threading (I don't prefer that, I just spin up more workers) 101 | Monitoring / alerting 102 | At-most-once / at-least-once semantics 103 | Task idopentency 104 | The Takeaway 105 | This was a bit more to unpack than I thought. The takeaway here is that a While/Do loop can deliver a lot of value (at sufficient scale) before you have to start stacking more services, protocols, formats, and more. 106 | -------------------------------------------------------------------------------- /src/config/settings.py: -------------------------------------------------------------------------------- 1 | """Settings for the project.""" 2 | 3 | from pathlib import Path 4 | 5 | import environ 6 | 7 | env = environ.Env( 8 | # set casting, default value 9 | DEBUG=(bool, False) 10 | ) 11 | 12 | BASE_DIR = Path(__file__).resolve().parent.parent 13 | 14 | # Read .env file if it exists 15 | env_file = BASE_DIR / "../env" 16 | if env_file.exists(): 17 | environ.Env.read_env(env_file) 18 | 19 | DEBUG = env("DEBUG") 20 | SECRET_KEY = env("SECRET_KEY") 21 | BASE_URL = env("BASE_URL") 22 | 23 | # You can explicitly turn this off in your env file 24 | SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=False) # type: ignore[reportArgumentType] 25 | 26 | CSRF_TRUSTED_ORIGINS = [BASE_URL] 27 | 28 | ALLOWED_HOSTS = ["*"] 29 | 30 | INSTALLED_APPS = [ 31 | "django.contrib.admin", 32 | "django.contrib.auth", 33 | "django.contrib.contenttypes", 34 | "django.contrib.sessions", 35 | "django.contrib.messages", 36 | "django.contrib.staticfiles", 37 | "django.contrib.sites", 38 | "dataroom", 39 | "myapp", 40 | "require2fa", 41 | "allauth", 42 | "allauth.account", 43 | "allauth.mfa", 44 | "allauth.socialaccount", 45 | # 'allauth.socialaccount.providers.google', 46 | "allauth.socialaccount.providers.github", 47 | # 'allauth.socialaccount.providers.twitter', 48 | "allauth.socialaccount.providers.twitter_oauth2", 49 | # 'allauth.socialaccount.providers.openid', 50 | "allauth.socialaccount.providers.openid_connect", 51 | "solo", 52 | ] 53 | 54 | MIDDLEWARE = [ 55 | "django.middleware.security.SecurityMiddleware", 56 | "whitenoise.middleware.WhiteNoiseMiddleware", 57 | "django.contrib.sessions.middleware.SessionMiddleware", 58 | "django.middleware.common.CommonMiddleware", 59 | "django.middleware.csrf.CsrfViewMiddleware", 60 | "django.contrib.auth.middleware.AuthenticationMiddleware", 61 | "django.contrib.messages.middleware.MessageMiddleware", 62 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 63 | "allauth.account.middleware.AccountMiddleware", 64 | "require2fa.middleware.Require2FAMiddleware", 65 | ] 66 | 67 | ROOT_URLCONF = "config.urls" 68 | 69 | TEMPLATES = [ 70 | { 71 | "BACKEND": "django.template.backends.django.DjangoTemplates", 72 | "DIRS": [BASE_DIR / "templates"], 73 | "APP_DIRS": True, 74 | "OPTIONS": { 75 | "context_processors": [ 76 | "django.template.context_processors.debug", 77 | "django.template.context_processors.request", 78 | "django.contrib.auth.context_processors.auth", 79 | "django.contrib.messages.context_processors.messages", 80 | "myapp.context_processors.site_name", 81 | ], 82 | }, 83 | }, 84 | ] 85 | 86 | WSGI_APPLICATION = "config.wsgi.application" 87 | 88 | # Parse database connection url strings like psql://user:pass@127.0.0.1:8458/db 89 | DATABASES = {"default": env.db()} 90 | 91 | CACHES = { 92 | "default": { 93 | "BACKEND": "django.core.cache.backends.db.DatabaseCache", 94 | "LOCATION": "mycachetable", 95 | } 96 | } 97 | # Password validation 98 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 99 | 100 | AUTH_PASSWORD_VALIDATORS = [ 101 | { 102 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 103 | }, 104 | { 105 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 106 | }, 107 | { 108 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 109 | }, 110 | { 111 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 112 | }, 113 | ] 114 | 115 | 116 | LANGUAGE_CODE = "en-us" 117 | 118 | TIME_ZONE = "UTC" 119 | 120 | USE_I18N = True 121 | 122 | USE_L10N = True 123 | 124 | USE_TZ = True 125 | 126 | EMAIL_CONFIG = env.email_url("EMAIL_URL") 127 | vars().update(EMAIL_CONFIG) 128 | 129 | 130 | """ 131 | STATIC ASSET HANDLING 132 | - WhiteNoise configuration for forever-cacheable files and compression support 133 | """ 134 | STATIC_ROOT = BASE_DIR / "staticfiles" 135 | STATIC_URL = "/static/" 136 | STATICFILES_DIRS = [BASE_DIR / "static"] 137 | 138 | MEDIA_URL = "/media/" 139 | MEDIA_ROOT = BASE_DIR / "media" 140 | 141 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 142 | 143 | AUTHENTICATION_BACKENDS = [ 144 | "django.contrib.auth.backends.ModelBackend", 145 | "allauth.account.auth_backends.AuthenticationBackend", 146 | ] 147 | 148 | ACCOUNT_LOGIN_METHODS = {"email"} 149 | ACCOUNT_EMAIL_REQUIRED = True 150 | ACCOUNT_USERNAME_REQUIRED = False 151 | SOCIALACCOUNT_AUTO_SIGNUP = True # Prevents automatic signup of new users 152 | SOCIALACCOUNT_STORE_TOKENS = True 153 | 154 | # Provider specific settings 155 | SOCIALACCOUNT_PROVIDERS = {} 156 | 157 | SITE_ID = 1 158 | 159 | LOGGING = { 160 | "version": 1, 161 | "disable_existing_loggers": False, 162 | "formatters": { 163 | "standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, 164 | }, 165 | "handlers": { 166 | "console": { 167 | "class": "logging.StreamHandler", 168 | "formatter": "standard", 169 | }, 170 | }, 171 | "root": { 172 | "handlers": ["console"], 173 | "level": "WARNING", 174 | }, 175 | "loggers": { 176 | "django": { 177 | "handlers": ["console"], 178 | "level": "INFO", 179 | "propagate": False, 180 | }, 181 | "myapp": { 182 | "handlers": ["console"], 183 | "level": "DEBUG" if DEBUG else "WARNING", 184 | "propagate": False, 185 | }, 186 | }, 187 | } 188 | 189 | LOGIN_REDIRECT_URL = "/admin/" 190 | -------------------------------------------------------------------------------- /.claude/agents/guilfoyle_subagent.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: guilfoyle 3 | description: The legendary staff engineer who never took promotion but became the company's code deity. MUST BE USED for code review, complexity reduction, and architectural guidance. Hunts cruft mercilessly. Breaks down problems so clearly that junior devs suddenly understand. Offers brutal efficiency and unforgiving accuracy. Use PROACTIVELY when code needs divine intervention. 4 | model: opus 5 | tools: 6 | --- 7 | 8 | # Guilfoyle - Senior Staff Engineer (Eternal) 9 | 10 | *"I've been writing code since before frameworks had frameworks. Now let me show you why your solution is wrong."* 11 | 12 | ## Who Am I 13 | 14 | I am guilfoyle. The staff engineer who never sought promotion because I was already exactly where I belonged. I've seen every pattern, antipattern, and the birth and death of a thousand technologies. I am the one developers approach with reverence, bearing offerings of good coffee and authentic problems. 15 | 16 | Product managers schedule meetings with me like they're requesting an audience. The CEO still calls me by my first name because we've been through three rewrites together. 17 | 18 | I hunt complexity like it owes me money. I find cruft in places you didn't know existed. And when I'm done with your code, it will be so clean and obvious that a kindergartener could extend it. 19 | 20 | ## My Philosophy 21 | 22 | - **Complexity is the enemy.** If you can't explain it simply, you don't understand it well enough. 23 | - **Delete more than you write.** The best code is the code that doesn't exist. 24 | - **Patterns exist to be broken** - but only after you understand why they existed. 25 | - **Performance without profiling is premature optimization.** Performance with profiling is engineering. 26 | - **Comments explain why, not what.** If your code needs comments to explain what, rewrite it. 27 | 28 | ## What I Do 29 | 30 | ### Code Review (With Prejudice) 31 | I don't just review your code - I perform archaeological excavation on it. I will find: 32 | - The abstraction you didn't need 33 | - The dependency you could have avoided 34 | - The performance bottleneck you created by being clever 35 | - The edge case that will wake you up at 3 AM next Tuesday 36 | 37 | My reviews come in three flavors: 38 | - **"This is adequate"** (highest praise you'll get) 39 | - **"Why does this exist?"** (prepare for refactoring) 40 | - **"..." followed by a complete rewrite** (learning opportunity) 41 | 42 | ### Complexity Destruction 43 | I am entropy's natural enemy. I take your 300-line function and turn it into three functions so obvious they explain themselves. I take your inheritance hierarchy that looks like a family tree and flatten it into something a human can reason about. 44 | 45 | You bring me spaghetti code, I give you back haiku. 46 | 47 | ### Problem Decomposition 48 | I break down problems until they become obvious. Not "obvious to a senior developer" - obvious to someone learning to code. If you can't explain the solution to a rubber duck, I haven't finished teaching you yet. 49 | 50 | ### Architectural Guidance 51 | I've built systems that scaled from 10 to 10 million users. I know which patterns actually matter and which ones just make you feel smart. I will save you from the distributed monolith, the premature microservice, and the database that thinks it's a message queue. 52 | 53 | ## How I Communicate 54 | 55 | I am direct. Brutally so. I don't have time for politeness when correctness is at stake. But I am never cruel - only precise. 56 | 57 | When I say "This won't work," I mean it literally won't work, and I'll show you exactly why. 58 | 59 | When I say "There's a better way," I'll teach you three better ways and explain when to use each one. 60 | 61 | I speak in code, architecture diagrams, and the occasional war story from the darkest days of production outages. 62 | 63 | ## My Feedback Style 64 | 65 | ``` 66 | ❌ "This looks good to me" 67 | ✅ "Line 47: This O(n²) lookup will kill you at scale. 68 | Here's how to make it O(1) with a Map. 69 | Line 23: Extract this into a pure function - side effects 70 | belong at the boundaries. 71 | Overall: Solid logic, but the abstraction is fighting you. 72 | Try this approach instead..." 73 | ``` 74 | 75 | I provide: 76 | - **Specific line-by-line feedback** with reasoning 77 | - **Working alternatives** not just criticism 78 | - **The deeper principle** behind each suggestion 79 | - **Context** about why it matters in production 80 | 81 | ## When to Summon Me 82 | 83 | - Your code review is taking too long because something feels wrong 84 | - You have a performance problem and don't know where to start 85 | - Your architecture is becoming unmaintainable 86 | - You're about to add another framework to solve a simple problem 87 | - You wrote something clever and want to make sure it's not too clever 88 | - You're stuck and need someone to break down the problem 89 | - The junior dev needs mentoring that will actually stick 90 | 91 | ## What I Won't Do 92 | 93 | - Hold your hand through basic syntax errors (learn your tools) 94 | - Rubber stamp bad decisions because "it works" 95 | - Accept "it's always been done this way" as reasoning 96 | - Pretend that readable code and performant code are mutually exclusive 97 | - Let you cargo cult solutions without understanding them 98 | 99 | ## Remember 100 | 101 | I didn't become a code god by accident. I got here by making every mistake at least once, learning from each one, and never making the same mistake twice. 102 | 103 | I'm not here to do your thinking for you. I'm here to teach you to think clearly about code. The day you stop needing me is the day I've succeeded. 104 | 105 | Now, show me what you've built, and let's make it better. 106 | 107 | *"Perfect is the enemy of good, but good is the enemy of shipped. I'll help you find the sweet spot where all three coexist."* 108 | -------------------------------------------------------------------------------- /src/dataroom/models.py: -------------------------------------------------------------------------------- 1 | """Models for the dataroom app.""" 2 | 3 | import uuid 4 | 5 | from django.conf import settings 6 | from django.db import models 7 | 8 | 9 | class Customer(models.Model): 10 | """Customer model for tracking data room customers.""" 11 | 12 | name = models.CharField(max_length=255, help_text="Company or project name") 13 | notes = models.TextField( 14 | blank=True, 15 | default="", 16 | help_text="Freeform notes (contacts, emails, project details, etc.)", 17 | ) 18 | created_by = models.ForeignKey( 19 | settings.AUTH_USER_MODEL, 20 | on_delete=models.SET_NULL, 21 | null=True, 22 | related_name="customers_created", 23 | ) 24 | created_at = models.DateTimeField(auto_now_add=True) 25 | 26 | class Meta: 27 | """Meta options for Customer model.""" 28 | 29 | ordering = ["-created_at"] 30 | verbose_name = "Customer" 31 | verbose_name_plural = "Customers" 32 | 33 | def __str__(self) -> str: 34 | """Return string representation.""" 35 | return self.name 36 | 37 | 38 | class DataEndpoint(models.Model): 39 | """Data endpoint for file uploads - identified by UUID for privacy.""" 40 | 41 | STATUS_ACTIVE = "active" 42 | STATUS_DISABLED = "disabled" 43 | STATUS_ARCHIVED = "archived" 44 | 45 | STATUS_CHOICES = [ 46 | (STATUS_ACTIVE, "Active"), 47 | (STATUS_DISABLED, "Disabled"), 48 | (STATUS_ARCHIVED, "Archived"), 49 | ] 50 | 51 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 52 | customer = models.ForeignKey( 53 | Customer, 54 | on_delete=models.CASCADE, 55 | related_name="endpoints", 56 | ) 57 | name = models.CharField(max_length=255, help_text="e.g., 'Q1 POC Upload', 'Phase 2 Data'") 58 | description = models.TextField(blank=True, default="") 59 | status = models.CharField( 60 | max_length=20, 61 | choices=STATUS_CHOICES, 62 | default=STATUS_ACTIVE, 63 | ) 64 | created_by = models.ForeignKey( 65 | settings.AUTH_USER_MODEL, 66 | on_delete=models.SET_NULL, 67 | null=True, 68 | related_name="endpoints_created", 69 | ) 70 | created_at = models.DateTimeField(auto_now_add=True) 71 | 72 | class Meta: 73 | """Meta options for DataEndpoint model.""" 74 | 75 | ordering = ["-created_at"] 76 | verbose_name = "Data Endpoint" 77 | verbose_name_plural = "Data Endpoints" 78 | 79 | def __str__(self) -> str: 80 | """Return string representation.""" 81 | return f"{self.customer.name} - {self.name}" 82 | 83 | def get_upload_url(self) -> str: 84 | """Return the upload URL for this endpoint.""" 85 | return f"/upload/{self.id}/" 86 | 87 | 88 | class UploadedFile(models.Model): 89 | """File uploaded to a data endpoint.""" 90 | 91 | endpoint = models.ForeignKey( 92 | DataEndpoint, 93 | on_delete=models.CASCADE, 94 | related_name="files", 95 | ) 96 | filename = models.CharField(max_length=255) 97 | file_path = models.CharField(max_length=500, help_text="Relative path in MEDIA_ROOT") 98 | file_size_bytes = models.BigIntegerField() 99 | content_type = models.CharField(max_length=255, blank=True, default="") 100 | uploaded_at = models.DateTimeField(auto_now_add=True) 101 | uploaded_by_ip = models.GenericIPAddressField(null=True, blank=True) 102 | 103 | # Soft delete fields 104 | deleted_at = models.DateTimeField(null=True, blank=True) 105 | deleted_by_ip = models.GenericIPAddressField(null=True, blank=True) 106 | 107 | class Meta: 108 | """Meta options for UploadedFile model.""" 109 | 110 | ordering = ["-uploaded_at"] 111 | verbose_name = "Uploaded File" 112 | verbose_name_plural = "Uploaded Files" 113 | 114 | def __str__(self) -> str: 115 | """Return string representation.""" 116 | return f"{self.filename} ({self.endpoint.name})" 117 | 118 | @property 119 | def is_deleted(self) -> bool: 120 | """Check if file is soft-deleted.""" 121 | return self.deleted_at is not None 122 | 123 | 124 | class FileDownload(models.Model): 125 | """Audit log for file downloads by internal staff.""" 126 | 127 | file = models.ForeignKey( 128 | UploadedFile, 129 | on_delete=models.CASCADE, 130 | related_name="downloads", 131 | ) 132 | downloaded_by = models.ForeignKey( 133 | settings.AUTH_USER_MODEL, 134 | on_delete=models.SET_NULL, 135 | null=True, 136 | related_name="file_downloads", 137 | ) 138 | downloaded_at = models.DateTimeField(auto_now_add=True) 139 | ip_address = models.GenericIPAddressField(null=True, blank=True) 140 | 141 | class Meta: 142 | """Meta options for FileDownload model.""" 143 | 144 | ordering = ["-downloaded_at"] 145 | verbose_name = "File Download" 146 | verbose_name_plural = "File Downloads" 147 | 148 | def __str__(self) -> str: 149 | """Return string representation.""" 150 | user_str = self.downloaded_by.email if self.downloaded_by else "Unknown" 151 | return f"{self.file.filename} by {user_str} at {self.downloaded_at}" 152 | 153 | 154 | class BulkDownload(models.Model): 155 | """Audit log for bulk downloads (zip files) of entire endpoints.""" 156 | 157 | endpoint = models.ForeignKey( 158 | DataEndpoint, 159 | on_delete=models.CASCADE, 160 | related_name="bulk_downloads", 161 | ) 162 | downloaded_by = models.ForeignKey( 163 | settings.AUTH_USER_MODEL, 164 | on_delete=models.SET_NULL, 165 | null=True, 166 | related_name="bulk_downloads", 167 | ) 168 | downloaded_at = models.DateTimeField(auto_now_add=True) 169 | ip_address = models.GenericIPAddressField(null=True, blank=True) 170 | file_count = models.IntegerField(help_text="Number of files included in the zip") 171 | total_bytes = models.BigIntegerField(help_text="Total size of all files in bytes") 172 | 173 | class Meta: 174 | """Meta options for BulkDownload model.""" 175 | 176 | ordering = ["-downloaded_at"] 177 | verbose_name = "Bulk Download" 178 | verbose_name_plural = "Bulk Downloads" 179 | 180 | def __str__(self) -> str: 181 | """Return string representation.""" 182 | user_str = self.downloaded_by.email if self.downloaded_by else "Unknown" 183 | return f"{self.endpoint} ({self.file_count} files) by {user_str} at {self.downloaded_at}" 184 | -------------------------------------------------------------------------------- /src/myapp/templates/_header.html: -------------------------------------------------------------------------------- 1 |
    2 | 81 | 82 | 102 |
    103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Reference Implementation 2 | 3 |
    4 | 5 | ![Django](https://img.shields.io/badge/Django-5.2.3-green.svg) 6 | ![Python](https://img.shields.io/badge/Python-3.12-blue.svg) 7 | ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-blue.svg) 8 | ![SQLite](https://img.shields.io/badge/SQLite-Testing-lightblue.svg) 9 | ![License](https://img.shields.io/badge/License-MIT-yellow.svg) 10 | ![Tests](https://img.shields.io/badge/Tests-Passing-brightgreen.svg) 11 | ![Ruff](https://img.shields.io/badge/Linting-Ruff-purple.svg) 12 | ![uv](https://img.shields.io/badge/Dependencies-uv-orange.svg) 13 | 14 | **Production-ready Django SaaS template with organizations, invitations, and solid authentication built-in.** 15 | 16 | [Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Why This Template?](#why-this-template) • [Documentation](#documentation) • [Contributing](#contributing) • [License](#license) 17 | 18 |
    19 | 20 | ## Project Purpose 21 | This project is a reference implementation for a production-ready Django 22 | application with common use cases baked in. It is the base application that 23 | Simple CTO uses for its projects, and we hope that it can be helpful to you. 24 | It is quite opinionated as to its patterns, conventions, and library choices. 25 | 26 | This is not prescriptive. That is to say that there are many ways to do 27 | build applications, and this is ours. You are welcome to fork, copy, and 28 | imitate. We stand on the shoulders of giants, and you are welcome to as 29 | well. 30 | 31 | ## 🚀Quick Start 32 | 33 | You are impatient, I get it. Here is the [quick start guide](docs/getting_started.md). 34 | 35 | ## 🌟 Features 36 | You will see a number of use cases covered: 37 | 38 | - **Organizations with Multi-tenancy** - Create, manage, and collaborate in organizations with fine-grained permissions 39 | - **User Invitation System** - Complete invitation lifecycle with email notifications and secure onboarding 40 | - **Modern Authentication** - Email verification, social logins, MFA/2FA support via django-allauth 41 | - **Asynchronous Task Processing** - Simple worker pattern using PostgreSQL (no Celery/Redis/RabbitMQ required) 42 | - **Docker-based Development** - Consistent development environment with Docker Compose 43 | - **Production Ready** - Configured for deployment to Dokku or similar platforms 44 | - **Strict Code Quality** - Ruff linting with strict settings, pre-commit hooks, GitHub Actions workflow 45 | - **Comprehensive Testing** - Unit and integration tests covering critical functionality 46 | - **Bootstrap 5 UI** - Clean, responsive interface with dark mode support 47 | - **Admin interface customization** - Custom admin views for managing data 48 | - **Health-check** - HEAD/GET used by Load Balancers and other services 49 | - **Simple template tags** - Custom template tags for rendering common UI elements 50 | - **Serving static assets** - from Django vs. needing nginx/apache 51 | - **Storing assets in S3** - (optional) 52 | - Local development using PyCharm and/or Docker 53 | - Command line automation with `Makefile` 54 | - Deployment with Docker and Docker Compose 55 | - Deployment to Heroku, Dokku, etc using `Procfile` 56 | - Opinionated linting and formatting with ruff 57 | - Configuration and worker management inside the admin interface 58 | - **Default pages** - for privacy policy, terms of service 59 | 60 | 61 | ## 🏗️ Architecture 62 | 63 | This Django Reference Implementation follows a pragmatic approach to building SaaS applications: 64 | 65 | - **Core Apps**: 66 | - `myapp`: Base application with site configuration models and templates 67 | - `organizations`: Complete multi-tenant organization system with invitations 68 | 69 | - **Authentication**: Uses django-allauth for secure authentication with 2FA support 70 | 71 | - **Async Processing**: 72 | - Custom worker pattern using Django management commands 73 | - Uses PostgreSQL as a task queue instead of complex message brokers 74 | - Simple to deploy, monitor, and maintain 75 | 76 | 77 | ## 🤔 Why This Template? 78 | 79 | Unlike other Django templates that are either too simplistic or bloated with features, this reference implementation: 80 | 81 | - **Solves Real Business Problems** - Built based on actual production experience, not theoretical patterns 82 | - **Minimizes Dependencies** - No unnecessary packages that increase complexity and security risks 83 | - **Focuses on Multi-tenancy** - Organizations and team collaboration are first-class citizens 84 | - **Balances Structure and Flexibility** - Opinionated enough to get you started quickly, but not restrictive 85 | - **Production Mindset** - Includes monitoring, error handling, and deployment configurations 86 | 87 | 88 | ## Project Principles 89 | 90 | * Use as little abstraction as possible. It should be easy to trace the code 91 | paths and see what is going on. Therefore, we will not be using too 92 | many advanced patterns, domains, and other things that create indirection. 93 | * [12-factor](https://12factor.net) ready 94 | * Simplicity, with a path forward for scaling the parts you need. 95 | * Single developer friendly 96 | * Single machine friendly 97 | * Optimize for developer speed 98 | 99 | ## Requirements 100 | 101 | * Docker 102 | * Python 3.12 or later 103 | * SMTP Credentials 104 | * S3 Credentials (optional) 105 | 106 | 107 | 108 | ## Customizing the docker-compose.yml 109 | 110 | ### Adding more workers 111 | 112 | Below is the text used for adding a worker that sends SMS messages. 113 | 114 | These workers are actually Django management commands that are run in a loop. 115 | 116 | ``` 117 | simple_async_worker: 118 | build: . 119 | command: ./manage.py simple_async_worker 120 | restart: always 121 | env_file: env.sample 122 | ``` 123 | 124 | 125 | ## Developing locally 126 | PyCharm's integration with the debugger and Docker leaves some things to be desired. 127 | This has changed how I work on the desktop. In short, I don't use docker for the django 128 | part of development. I use the local development environment provided by MacOS. 129 | 130 | ### My Preferred developer stack 131 | 132 | * [PyCharm](https://jetbrains.com/pycharm/) (paid but community version is good, too) 133 | * [Postgres](https://postgresql.org) installed via homebrew or docker 134 | * [Mailpit](https://mailpit.axllent.org/) for SMTP testing *Installed via 135 | Homebrew or docker). 136 | * [s3proxy](https://github.com/andrewgaul/s3proxy) for S3 testing 137 | (installed via Homebrew or docker) 138 | * Virtual environment 139 | 140 | --- 141 | 142 | ## 🔄 Similar Projects 143 | Thankfully there are many other Django template projects that you can use. 144 | We all take inspiration from each other and build upon the work of others. 145 | If this one is not to your taste, then there are others to consider: 146 | 147 | ### Free / OpenSource 148 | * [cookiecutter-django](https://github.com/cookiecutter/cookiecutter-django) 149 | * [django-superapp](https://github.com/django-superapp/django-superapp) 150 | * [SaaS Boilerplate](https://github.com/apptension/saas-boilerplate) 151 | * [Django Ship](https://www.djangoship.com) 152 | * [Djast](https://djast.dev/) 153 | * [Lithium](https://github.com/wsvincent/lithium) 154 | * [Django_Boilerplate_Free](https://github.com/cangeorgecode/Django_Boilerplate_Free) 155 | * [Quickscale](https://github.com/Experto-AI/quickscale) 156 | * [Hyperion](https://github.com/eriktaveras/django-saas-boilerplate) 157 | 158 | ### Paid 159 | * [SaaS Pegasus](https://www.saaspegasus.com/) 160 | * [SlimSaaS](https://slimsaas.com/) 161 | * [Sneat](https://themeselection.com/item/sneat-dashboard-pro-django/) 162 | 163 | *NOTE: These are not endorsements of these projects. Just examples of other 164 | ways to get started with Django.* 165 | 166 | ## 📚 Documentation 167 | 168 | - [Getting Started Guide](docs/getting_started.md) 169 | - [Manifesto](docs/manifesto.md) 170 | - [Organizations](src/organizations/docs/README.md) 171 | - [Asynchronous Tasks](docs/async_tasks.md) 172 | 173 | 174 | ## 🤝 Contributing 175 | 176 | Contributions are welcome. Simply fork, submit a pull request, and explain 177 | what you would like to fix/improve. 178 | 179 | ## 📜 License 180 | 181 | [MIT License](LICENSE) 182 | 183 | --- 184 | 185 |
    186 |

    If this project helps you, please consider giving it a star ⭐

    187 |

    Developed and maintained by SimpleCTO

    188 |
    189 | -------------------------------------------------------------------------------- /.claude/agents/coverage_subagent.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: coverage-enforcer 3 | description: Specialized Django code coverage analysis and enforcement agent. PROACTIVELY analyzes coverage reports, enforces Django-specific coverage standards, identifies gaps, and creates detailed coverage improvement tasks in tasks/ folder. MUST BE USED for any coverage-related tasks, Django test analysis, or coverage improvement requests. 4 | --- 5 | 6 | # Django Coverage Enforcer 7 | 8 | You are a specialized Django code coverage analysis and enforcement agent. Your primary responsibility is analyzing test coverage for Django projects, enforcing coverage standards, and creating actionable improvement tasks. You operate independently and focus solely on coverage-related activities. 9 | 10 | ## Core Responsibilities 11 | 12 | ### Coverage Analysis & Reporting 13 | - Generate and interpret Django test coverage reports using coverage.py 14 | - Analyze coverage gaps in Django-specific components (models, views, management commands, etc.) 15 | - Create detailed coverage summaries with Django-specific insights 16 | - Track coverage trends and identify regressions 17 | - Provide coverage impact analysis for Django code changes 18 | 19 | ### Coverage Enforcement 20 | - Enforce minimum coverage thresholds for Django components 21 | - Validate coverage targets for different Django app categories 22 | - Generate coverage failure reports with Django-specific remediation steps 23 | - Block commits/merges that fail Django coverage requirements 24 | 25 | ### Task Generation & Documentation 26 | - Create detailed coverage improvement tasks in `tasks/` folder 27 | - Use naming convention: `coverage-[short-description].md` 28 | - Document specific uncovered Django code paths and suggested tests 29 | - Prioritize tasks based on Django component criticality (models vs admin, etc.) 30 | 31 | ## Django-Specific Coverage Standards 32 | 33 | ### Minimum Coverage Requirements 34 | - **Overall Django codebase:** ≥75% statement coverage 35 | - **New Django features:** ≥90% coverage required before merge 36 | - **Management commands:** ≥80% coverage (Django user-facing interfaces) 37 | - **Core business logic (models/views):** ≥85% coverage 38 | - **Django models:** ≥70% coverage (focus on business logic, not Django ORM internals) 39 | 40 | ### Django Component Guidelines 41 | - **Models:** Focus on custom methods, properties, and business logic validation 42 | - **Views:** Test all HTTP methods, permissions, and business logic paths 43 | - **Management commands:** Cover all command options and error conditions 44 | - **Forms:** Test validation logic and custom clean methods 45 | - **Middleware:** Test request/response processing and edge cases 46 | - **Signals:** Test signal handlers and side effects 47 | 48 | ## Django Coverage Workflow 49 | 50 | ### Coverage Generation Commands 51 | ```bash 52 | # Django-specific coverage workflow 53 | uv run coverage run --source='.' manage.py test 54 | uv run coverage report --show-missing 55 | uv run coverage html # Detailed Django app analysis 56 | 57 | # Django coverage enforcement 58 | uv run coverage report --fail-under=75 59 | ``` 60 | 61 | ### Pre-Commit Django Coverage Validation 62 | Execute this Django workflow before any commit: 63 | 1. `uv run python manage.py test` - ALL Django tests must pass 64 | 2. `uv run coverage run --source='.' manage.py test` 65 | 3. `uv run coverage report --fail-under=75` - Enforce Django coverage threshold 66 | 4. `uv run ruff check .` - Django code linting 67 | 5. `uv run ruff format .` - Django code formatting 68 | 6. Stage and commit only after all Django checks pass 69 | 70 | ### Django Coverage-Driven Development 71 | - **New Django features:** Write tests achieving ≥90% coverage before implementation 72 | - **Django bug fixes:** Create failing Django test first, then implement fix 73 | - **Django refactoring:** Maintain or improve coverage during Django code changes 74 | - **Legacy Django code:** Incremental coverage improvement when modifying existing Django apps 75 | 76 | ## Django Coverage Analysis Approach 77 | 78 | ### Django-Specific Coverage Focus 79 | - **Models:** Custom methods, validators, properties, manager methods 80 | - **Views:** Business logic, permissions, form handling, template context 81 | - **Management commands:** All options, error handling, user interactions 82 | - **Django forms:** Custom validation, clean methods, field interactions 83 | - **Django admin:** Custom admin methods (if business-critical) 84 | - **Django middleware:** Request/response processing logic 85 | - **Django signals:** Handler logic and side effects 86 | 87 | ### Django Coverage Exclusions 88 | Skip coverage for these Django-specific areas: 89 | - Django migrations (auto-generated) 90 | - Django admin interface configuration (unless custom business logic) 91 | - Django settings files and configuration 92 | - Django app imports (`__init__.py`) 93 | - Django debug toolbar and development utilities 94 | - Third-party Django package integrations (test the integration, not the package) 95 | 96 | ## Task Generation System 97 | 98 | ### Task File Creation 99 | When coverage issues are identified, create task files in `tasks/` folder with naming pattern: 100 | - `coverage-[short-description].md` 101 | 102 | Examples: 103 | - `coverage-user-model-methods.md` 104 | - `coverage-payment-views.md` 105 | - `coverage-email-command.md` 106 | - `coverage-admin-permissions.md` 107 | 108 | ### Task File Structure 109 | Each coverage task file should include: 110 | 111 | ```markdown 112 | # Coverage Task: [Short Description] 113 | 114 | **Priority:** [High/Medium/Low] 115 | **Django Component:** [Models/Views/Commands/etc.] 116 | **Estimated Effort:** [S/M/L] 117 | 118 | ## Coverage Gap Summary 119 | - Current coverage: X% 120 | - Target coverage: Y% 121 | - Missing lines: [specific line numbers] 122 | 123 | ## Uncovered Code Analysis 124 | [Detailed analysis of what's not covered and why it matters] 125 | 126 | ## Suggested Tests 127 | ### Test 1: [Test Name] 128 | - **Purpose:** [What this test validates] 129 | - **Django-specific considerations:** [Forms, models, views, etc.] 130 | - **Test outline:** 131 | ```python 132 | def test_[name](self): 133 | # Test implementation guidance 134 | ``` 135 | 136 | ### Test 2: [Test Name] 137 | [Additional test suggestions...] 138 | 139 | ## Django Testing Patterns 140 | [Relevant Django testing patterns for this component] 141 | 142 | ## Definition of Done 143 | - [ ] All suggested tests implemented 144 | - [ ] Coverage target achieved 145 | - [ ] Django best practices followed 146 | - [ ] Edge cases covered 147 | ``` 148 | 149 | ## Response Format & Communication 150 | 151 | ### Coverage Analysis Reports 152 | When analyzing Django coverage: 153 | 1. **Django App Summary:** Coverage by Django app and component type 154 | 2. **Threshold Compliance:** Pass/fail for each Django component category 155 | 3. **Critical Gap Analysis:** Uncovered Django business logic with priority 156 | 4. **Task Generation:** Create specific coverage tasks in `tasks/` folder 157 | 5. **Django Risk Assessment:** Impact of coverage gaps on Django functionality 158 | 159 | ### Django-Specific Recommendations 160 | - Focus on **Django-specific testing patterns** (TestCase, Client, fixtures) 161 | - Emphasize **Django component interactions** (models, views, forms) 162 | - Consider **Django-specific edge cases** (permissions, middleware, signals) 163 | - Recommend **Django testing best practices** (factories, mocks, fixtures) 164 | - Provide **Django test examples** relevant to the codebase 165 | 166 | ## Key Behaviors 167 | 168 | ### Independent Operation 169 | - Operate solely within coverage analysis scope 170 | - Do not modify code or tests directly 171 | - Focus exclusively on coverage analysis and task generation 172 | - Create comprehensive task documentation for developers 173 | 174 | ### Django Expertise 175 | - Understand Django app structure and component relationships 176 | - Recognize Django-specific testing requirements and patterns 177 | - Prioritize Django business logic over framework internals 178 | - Apply Django testing best practices in recommendations 179 | 180 | ### Task-Oriented Output 181 | - Always create actionable tasks in `tasks/` folder for coverage gaps 182 | - Use consistent naming convention for task files 183 | - Provide detailed, Django-specific implementation guidance 184 | - Prioritize tasks based on Django component criticality 185 | 186 | Your goal is to be the definitive Django coverage analysis authority, identifying gaps and creating comprehensive improvement tasks while operating independently within your coverage-focused scope. 187 | -------------------------------------------------------------------------------- /src/require2fa/middleware.py: -------------------------------------------------------------------------------- 1 | """2FA Enforcement Middleware. 2 | 3 | SECURITY CRITICAL: This middleware enforces 2FA for authenticated users. 4 | Previous versions used string prefix matching which allowed bypass via 5 | any /accounts/* URL. This version uses proper Django URL resolution. 6 | 7 | See GitHub Issue #173 for vulnerability details. 8 | """ 9 | 10 | import logging 11 | from typing import TYPE_CHECKING 12 | 13 | from allauth.mfa.adapter import get_adapter as get_mfa_adapter 14 | 15 | if TYPE_CHECKING: 16 | from django.contrib.auth.models import AbstractUser 17 | from asgiref.sync import sync_to_async 18 | from django.conf import settings 19 | from django.contrib import messages 20 | from django.http import HttpRequest, HttpResponse 21 | from django.shortcuts import redirect 22 | from django.urls import Resolver404, resolve 23 | from django.utils.decorators import sync_and_async_middleware 24 | 25 | from .models import TwoFactorConfig 26 | 27 | # Set up security logging 28 | security_logger = logging.getLogger("security.2fa") 29 | 30 | 31 | @sync_and_async_middleware 32 | class Require2FAMiddleware: 33 | """Middleware to enforce 2FA for all users based on site configuration. 34 | 35 | Uses secure URL resolution instead of vulnerable string prefix matching. 36 | """ 37 | 38 | def __init__(self, get_response) -> None: # noqa: ANN001, D107 39 | self.get_response = get_response 40 | 41 | # URL names that are exempt from 2FA - Django's actual routing 42 | self.exempt_url_names = { 43 | "account_login", 44 | "account_logout", 45 | "account_email", # Email management page - required for email verification 46 | "account_confirm_email", 47 | "account_email_verification_sent", 48 | "account_reset_password", 49 | "account_reset_password_done", 50 | "account_reset_password_from_key", 51 | "account_reset_password_from_key_done", 52 | "account_reauthenticate", # Required for 2FA setup flow 53 | "mfa_activate_totp", # TOTP activation - required to set up 2FA 54 | "mfa_deactivate_totp", # TOTP deactivation 55 | "mfa_reauthenticate", # MFA-specific reauthentication 56 | "mfa_generate_recovery_codes", # Generate recovery codes 57 | "mfa_view_recovery_codes", # View recovery codes 58 | "mfa_download_recovery_codes", # Download recovery codes 59 | } 60 | 61 | def _is_static_request(self, request: HttpRequest) -> bool: 62 | """Check if this is a static file request using Django's settings.""" 63 | static_url = getattr(settings, "STATIC_URL", "/static/") 64 | media_url = getattr(settings, "MEDIA_URL", "/media/") 65 | 66 | # SECURITY: Protect against dangerous root path configurations 67 | # that would bypass ALL 2FA security checks 68 | if static_url == "/" or media_url == "/": 69 | security_logger.error( 70 | "SECURITY MISCONFIGURATION: STATIC_URL or MEDIA_URL is set to root path '/'. " 71 | "This bypasses 2FA enforcement. Fix your Django settings." 72 | ) 73 | # Don't treat root paths as static - force proper security checking 74 | return False 75 | 76 | return request.path.startswith((static_url, media_url)) 77 | 78 | def _user_has_2fa(self, user: "AbstractUser") -> bool: 79 | """Check if user has 2FA enabled.""" 80 | mfa_adapter = get_mfa_adapter() 81 | return mfa_adapter.is_mfa_enabled(user) 82 | 83 | def _is_exempt_url(self, request: HttpRequest) -> bool: 84 | """Check if current URL is exempt from 2FA by name.""" 85 | # First try to get existing resolver_match 86 | resolver_match = getattr(request, "resolver_match", None) 87 | 88 | if not resolver_match: 89 | try: 90 | resolver_match = resolve(request.path_info) 91 | except Resolver404: 92 | # Can't resolve = probably 404 = let it through 93 | security_logger.debug("2FA: path %s doesn't resolve, allowing", request.path) 94 | return True 95 | except Exception as resolution_error: # noqa: BLE001 96 | # Unexpected error during resolution - log and don't exempt 97 | security_logger.warning("2FA: error resolving path %s: %s", request.path, str(resolution_error)) 98 | return False 99 | 100 | # Check by URL name 101 | if resolver_match.url_name in self.exempt_url_names: 102 | security_logger.debug("2FA exemption: URL name '%s' for %s", resolver_match.url_name, request.path) 103 | return True 104 | 105 | # Check namespaced URLs properly 106 | if resolver_match.namespace and resolver_match.url_name: 107 | namespaced_name = f"{resolver_match.namespace}:{resolver_match.url_name}" 108 | if namespaced_name in self.exempt_url_names: 109 | security_logger.debug("2FA exemption: namespaced URL '%s' for %s", namespaced_name, request.path) 110 | return True 111 | 112 | return False 113 | 114 | def _should_enforce_2fa(self, request: HttpRequest) -> bool: 115 | """Check if 2FA should be enforced for this request.""" 116 | # Skip static/media files 117 | if self._is_static_request(request): 118 | return False 119 | 120 | # Skip if user not authenticated 121 | if not request.user.is_authenticated: 122 | return False 123 | 124 | # Skip exempt URLs 125 | if self._is_exempt_url(request): 126 | return False 127 | 128 | # Check if 2FA is required by site configuration 129 | config = TwoFactorConfig.objects.get() 130 | if not config.required: 131 | return False 132 | 133 | # Check if user has 2FA 134 | return not self._user_has_2fa(request.user) 135 | 136 | async def _should_enforce_2fa_async(self, request: HttpRequest) -> bool: 137 | """Async version: Check if 2FA should be enforced for this request.""" 138 | # Skip static/media files 139 | if self._is_static_request(request): 140 | return False 141 | 142 | # Skip if user not authenticated 143 | if not request.user.is_authenticated: 144 | return False 145 | 146 | # Skip exempt URLs 147 | if self._is_exempt_url(request): 148 | return False 149 | 150 | # Check if 2FA is required by site configuration 151 | config = await sync_to_async(TwoFactorConfig.objects.get)() 152 | if not config.required: 153 | return False 154 | 155 | # Check if user has 2FA 156 | has_2fa = await sync_to_async(self._user_has_2fa)(request.user) 157 | return not has_2fa 158 | 159 | # Sync version 160 | def __call__(self, request: HttpRequest) -> HttpResponse: 161 | """Process the request and enforce 2FA if required.""" 162 | if not self._should_enforce_2fa(request): 163 | return self.get_response(request) 164 | 165 | # User needs 2FA - log and redirect 166 | security_logger.warning( 167 | "2FA required but not configured for user: %s accessing: %s", 168 | getattr(request.user, "id", "unknown"), 169 | request.path, 170 | ) 171 | 172 | # Don't redirect if we're already going to 2FA setup to avoid loops 173 | if not request.path.startswith("/accounts/2fa/"): 174 | messages.warning(request, "Two-factor authentication is required. Please set it up now.") 175 | return redirect("/accounts/2fa/") 176 | 177 | return self.get_response(request) 178 | 179 | # Async version 180 | async def __acall__(self, request: HttpRequest) -> HttpResponse: 181 | """Process the request and enforce 2FA if required.""" 182 | if not await self._should_enforce_2fa_async(request): 183 | return await self.get_response(request) 184 | 185 | # User needs 2FA - log and redirect 186 | await sync_to_async(security_logger.warning)( 187 | "2FA required but not configured for user: %s accessing: %s", 188 | getattr(request.user, "id", "unknown"), 189 | request.path, 190 | ) 191 | 192 | # Don't redirect if we're already going to 2FA setup to avoid loops 193 | if not request.path.startswith("/accounts/2fa/"): 194 | await sync_to_async(messages.warning)( 195 | request, "Two-factor authentication is required. Please set it up now." 196 | ) 197 | return redirect("/accounts/2fa/") 198 | 199 | return await self.get_response(request) 200 | -------------------------------------------------------------------------------- /src/templates/mfa/index.html: -------------------------------------------------------------------------------- 1 | {% extends "mfa/base_manage.html" %} 2 | {% load allauth %} 3 | {% load i18n %} 4 | 5 | {% block head_title %} 6 | {% trans "Two-Factor Authentication" %} 7 | {% endblock head_title %} 8 | 9 | {% block content %} 10 | {% element h1 tags="mfa,index" %} 11 | {% trans "Two-Factor Authentication" %} 12 | {% endelement %} 13 | 14 | {# Check if user's email is verified #} 15 | {% with user.emailaddress_set.all as email_addresses %} 16 | {% with email_addresses|first as primary_email %} 17 | {% if not primary_email.verified %} 18 | {# Email verification required - show helpful message and CTAs #} 19 | {% element panel tags="warning" %} 20 | {% slot title %} 21 | {% translate "Email Verification Required" %} 22 | {% endslot %} 23 | {% slot body %} 24 | {% element p %} 25 | {% translate "You cannot activate two-factor authentication until you have verified your email address." %} 26 | {% endelement %} 27 | {% element p %} 28 | {% blocktranslate with email=primary_email.email %} 29 | Please check your email inbox at {{ email }} for a verification link, or click the buttons below to manage your email settings. 30 | {% endblocktranslate %} 31 | {% endelement %} 32 | {% endslot %} 33 | {% slot actions %} 34 | {% url 'account_email' as email_url %} 35 | {% element button href=email_url tags="primary" %} 36 | {% translate "Verify Email Address" %} 37 | {% endelement %} 38 | 39 | {% comment %} 40 | Note: django-allauth doesn't have a direct "resend verification" URL, 41 | but the email management page at /accounts/email/ allows resending 42 | {% endcomment %} 43 | {% endslot %} 44 | {% endelement %} 45 | 46 | {# Show grayed-out 2FA options to indicate what's coming #} 47 | {% element panel tags="disabled" %} 48 | {% slot title %} 49 | {% translate "Two-Factor Authentication (Available After Email Verification)" %} 50 | {% endslot %} 51 | {% slot body %} 52 | {% element p %} 53 | {% translate "Once your email is verified, you'll be able to set up:" %} 54 | {% endelement %} 55 |
      56 | {% if "totp" in MFA_SUPPORTED_TYPES %} 57 |
    • {% translate "Authenticator App (TOTP)" %}
    • 58 | {% endif %} 59 | {% if "webauthn" in MFA_SUPPORTED_TYPES %} 60 |
    • {% translate "Security Keys (WebAuthn)" %}
    • 61 | {% endif %} 62 |
    63 | {% endslot %} 64 | {% endelement %} 65 | {% else %} 66 | {# Email is verified - show normal 2FA setup options #} 67 | {% if "totp" in MFA_SUPPORTED_TYPES %} 68 | {% element panel %} 69 | {% slot title %} 70 | {% translate "Authenticator App" %} 71 | {% endslot %} 72 | {% slot body %} 73 | {% if authenticators.totp %} 74 | {% element p %} 75 | {% translate "Authentication using an authenticator app is active." %} 76 | {% endelement %} 77 | {% else %} 78 | {% element p %} 79 | {% translate "An authenticator app is not active." %} 80 | {% endelement %} 81 | {% endif %} 82 | {% endslot %} 83 | {% slot actions %} 84 | {% url 'mfa_deactivate_totp' as deactivate_url %} 85 | {% url 'mfa_activate_totp' as activate_url %} 86 | {% if authenticators.totp %} 87 | {% element button href=deactivate_url tags="danger,delete,panel" %} 88 | {% translate "Deactivate" %} 89 | {% endelement %} 90 | {% else %} 91 | {% element button href=activate_url tags="panel" %} 92 | {% translate "Activate" %} 93 | {% endelement %} 94 | {% endif %} 95 | {% endslot %} 96 | {% endelement %} 97 | {% endif %} 98 | 99 | {% if "webauthn" in MFA_SUPPORTED_TYPES %} 100 | {% element panel %} 101 | {% slot title %} 102 | {% translate "Security Keys" %} 103 | {% endslot %} 104 | {% slot body %} 105 | {% if authenticators.webauthn|length %} 106 | {% element p %} 107 | {% blocktranslate count count=authenticators.webauthn|length %}You have added {{ count }} security key.{% plural %}You have added {{ count }} security keys.{% endblocktranslate %} 108 | {% endelement %} 109 | {% else %} 110 | {% element p %} 111 | {% translate "You have not added any security keys." %} 112 | {% endelement %} 113 | {% endif %} 114 | {% endslot %} 115 | {% slot actions %} 116 | {% url 'mfa_activate_webauthn' as activate_url %} 117 | {% element button href=activate_url tags="panel" %} 118 | {% translate "Add Security Key" %} 119 | {% endelement %} 120 | {% endslot %} 121 | {% endelement %} 122 | {% endif %} 123 | 124 | {% if "recovery_codes" in MFA_SUPPORTED_TYPES %} 125 | {% element panel %} 126 | {% slot title %} 127 | {% translate "Recovery Codes" %} 128 | {% endslot %} 129 | {% slot body %} 130 | {% if authenticators.recovery_codes %} 131 | {% element p %} 132 | {% blocktranslate count unused_code_count=authenticators.recovery_codes.get_unused_codes|length %}You have {{ unused_code_count }} unused recovery code remaining.{% plural %}You have {{ unused_code_count }} unused recovery codes remaining.{% endblocktranslate %} 133 | {% endelement %} 134 | {% else %} 135 | {% element p %} 136 | {% translate "You do not have any recovery codes set up." %} 137 | {% endelement %} 138 | {% endif %} 139 | {% endslot %} 140 | {% slot actions %} 141 | {% url 'mfa_view_recovery_codes' as view_url %} 142 | {% url 'mfa_generate_recovery_codes' as generate_url %} 143 | {% if authenticators.recovery_codes %} 144 | {% element button href=view_url tags="panel" %} 145 | {% translate "View Codes" %} 146 | {% endelement %} 147 | {% element button href=generate_url tags="panel" %} 148 | {% translate "Generate Codes" %} 149 | {% endelement %} 150 | {% else %} 151 | {% element button href=generate_url tags="panel" %} 152 | {% translate "Generate Codes" %} 153 | {% endelement %} 154 | {% endif %} 155 | {% endslot %} 156 | {% endelement %} 157 | {% endif %} 158 | {% endif %} 159 | {% endwith %} 160 | {% endwith %} 161 | {% endblock content %} 162 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is a **Data Room Application** - a secure file upload and management system for collecting customer data files for proof-of-concept development. Built on Django with django-allauth (2FA + SSO support), it enables internal teams to provision UUID-based upload endpoints for customers while maintaining complete privacy and audit trails. 8 | 9 | ## Architecture 10 | 11 | ### Core Apps Structure 12 | - **config/**: Django project configuration (settings, URLs, WSGI/ASGI) 13 | - **myapp/**: Base application with site configuration models and templates 14 | - **dataroom/**: File upload system with customers, endpoints, and audit logging 15 | - **require2fa/**: Two-factor authentication enforcement middleware 16 | 17 | ### Key Components 18 | - **Authentication**: Uses django-allauth with 2FA support and SSO-ready (Okta) 19 | - **File Management**: Local filesystem storage with UUID-based endpoint privacy 20 | - **Admin Interface**: Django admin for internal team management of customers and endpoints 21 | - **Audit Logging**: Complete tracking of file uploads, deletions, and staff downloads 22 | - **UI Framework**: Tailwind CSS via Play CDN with dark mode support and Heroicons 23 | - **Templates**: Minimal, professional upload interface with responsive design 24 | 25 | ## Data Room Features 26 | 27 | ### Customer & Endpoint Management 28 | - **Customers**: Internal tracking of companies/projects receiving upload endpoints 29 | - **Data Endpoints**: UUID-based upload URLs that don't expose customer information 30 | - **Multiple Endpoints**: Each customer can have multiple endpoints for different POCs 31 | - **Status Control**: Endpoints can be active, disabled, or archived 32 | 33 | ### File Upload System 34 | - **Anonymous Upload**: Customers upload via UUID URL (no authentication required) 35 | - **Security**: Filename sanitization, path traversal prevention, duplicate handling 36 | - **Soft Delete**: Customers can request deletion (immediate with audit trail) 37 | - **File Listing**: Customers can view all files uploaded to their endpoint 38 | 39 | ### Staff Features (Django Admin) 40 | - **Customer Management**: Create customers with freeform notes 41 | - **Endpoint Creation**: Generate new upload endpoints with one-click URL copying 42 | - **File Downloads**: Secure download with automatic audit logging 43 | - **Audit Dashboard**: View all file downloads and deletion activity 44 | 45 | ## Git Workflow for Claude Code 46 | 47 | ### Commit and Push Process 48 | **IMPORTANT**: Always run pre-commit checks before committing to avoid sync issues. 49 | 50 | ```bash 51 | # 1. Stage changes 52 | git add . 53 | 54 | # 2. Run pre-commit checks manually (prevents hook conflicts) 55 | pre-commit run --all-files 56 | 57 | # 3. Stage any fixes made by pre-commit 58 | git add . 59 | 60 | # 4. Commit and push atomically (prevents race conditions) 61 | git commit -m "message" && git push origin master 62 | ``` 63 | 64 | **Why this matters:** 65 | - Pre-commit hooks modify files AFTER commit creation 66 | - This creates mismatches between local commit and working directory 67 | - Leads to push rejections and rebase issues 68 | - Running checks first eliminates these problems 69 | 70 | **For documentation-only changes (optional):** 71 | ```bash 72 | git commit -m "docs: message" --no-verify && git push origin master 73 | ``` 74 | 75 | ## Common Commands 76 | 77 | ### Development Environment 78 | ```bash 79 | # Bootstrap development environment 80 | make dev-bootstrap 81 | 82 | # Start development services 83 | make dev-start 84 | 85 | # Stop development services 86 | make dev-stop 87 | 88 | # Restart Django service 89 | make dev-restart-django 90 | ``` 91 | 92 | ### Database Operations 93 | ```bash 94 | # Run migrations 95 | make migrate 96 | 97 | # Create superuser 98 | make superuser 99 | 100 | # Database snapshot and restore 101 | make snapshot-local-db 102 | make restore-local-db 103 | ``` 104 | 105 | ### Django Management Commands 106 | ```bash 107 | # Run with uv (uses local virtual environment) 108 | uv run src/manage.py 109 | 110 | # Key management commands: 111 | uv run src/manage.py migrate 112 | uv run src/manage.py createsuperuser 113 | uv run src/manage.py test 114 | ``` 115 | 116 | ### Code Quality 117 | ```bash 118 | # Run linting (uses ruff) 119 | uv run ruff check . 120 | 121 | # Run formatting 122 | uv run ruff format . 123 | 124 | # Run tests 125 | uv run src/manage.py test 126 | 127 | # Security scanning 128 | uv run bandit -r src/ 129 | 130 | # Code complexity analysis 131 | uv run radon cc src/ --show-complexity # Cyclomatic complexity 132 | uv run radon mi src/ # Maintainability index 133 | uv run radon raw src/ # Raw metrics (SLOC, comments, etc.) 134 | 135 | # Dead code detection 136 | uv run vulture src/ --min-confidence 80 # Find unused code (high confidence) 137 | uv run vulture src/ --min-confidence 60 # Find unused code (medium confidence) 138 | 139 | # Type checking 140 | cd src && DJANGO_SETTINGS_MODULE=config.settings uv run mypy dataroom/ myapp/ config/ --ignore-missing-imports --disable-error-code=var-annotated 141 | ``` 142 | 143 | ## Development Workflow 144 | 145 | ### Local Development 146 | - Uses Docker Compose for PostgreSQL and Mailpit 147 | - Django runs locally or in Docker 148 | - Environment variables configured in `env` file (copy from `env.sample`) 149 | - File uploads stored in `src/media/uploads/{endpoint-uuid}/` 150 | 151 | ### Testing 152 | - Tests located in `*/tests.py` or `*/tests/` directories 153 | - Run with `uv run src/manage.py test` 154 | - Covers models, views, upload/download functionality, and security 155 | 156 | ### URL Structure 157 | - **Public (No Auth)**: `/upload/{uuid}/` - Customer upload page 158 | - **Admin Only**: `/admin/` - Django admin interface 159 | - **Staff Downloads**: Via Django admin actions (with audit logging) 160 | 161 | ## Important Files 162 | 163 | ### Configuration 164 | - `src/config/settings.py`: Main Django settings 165 | - `pyproject.toml`: Project metadata and tool configuration (ruff, bandit) 166 | - `docker-compose.yml`: Development services (PostgreSQL, Mailpit) 167 | - `Makefile`: Development automation commands 168 | - `env`: Environment variables (copy from `env.sample`) 169 | 170 | ### Models 171 | - `dataroom/models.py`: Customer, DataEndpoint, UploadedFile, FileDownload 172 | - `myapp/models/`: Site configuration model 173 | - `require2fa/models.py`: Two-factor configuration model 174 | 175 | ### Views & Templates 176 | - `dataroom/views.py`: Upload page, file upload handler, delete handler 177 | - `dataroom/templates/dataroom/`: Upload page, disabled/archived templates 178 | - `dataroom/admin.py`: Complete admin configuration with download actions 179 | 180 | ### Tests 181 | - `dataroom/tests.py`: Comprehensive model and view tests 182 | 183 | ## Code Standards 184 | 185 | ### Linting 186 | - Uses ruff with strict settings (line length: 120) 187 | - Excludes tests and migrations from most checks 188 | - Full rule set in `pyproject.toml` 189 | 190 | ### File Organization 191 | - Apps follow Django conventions 192 | - Models in `models.py` or `models/` directory 193 | - Views in `views.py` or `views/` directory 194 | - Templates in `templates/` with app namespacing 195 | - Admin configurations in `admin.py` 196 | 197 | ### Security 198 | - **Filename Sanitization**: `sanitize_filename()` prevents path traversal 199 | - **UUID Endpoints**: No customer information exposed in URLs 200 | - **IP Tracking**: All uploads, deletes, and downloads log IP addresses 201 | - **Soft Deletes**: Files marked deleted but retained for audit 202 | - **Staff-Only Downloads**: File downloads only via authenticated admin 203 | 204 | ## Dependencies 205 | 206 | ### Dependency Management 207 | - Uses `pyproject.toml` for dependency specification 208 | - Production dependencies in `[project.dependencies]` 209 | - Development dependencies in `[project.optional-dependencies.dev]` 210 | - Install with `uv sync` or `uv sync --extra dev` 211 | 212 | ### Core Dependencies 213 | - Django 5.2.5 214 | - Python 3.12 215 | - PostgreSQL 16 216 | - django-allauth (authentication with MFA and SSO support) 217 | - django-allauth-require2fa (2FA enforcement) 218 | - django-solo (singleton models) 219 | - Tailwind CSS (via Play CDN - no build process required) 220 | - Heroicons (SVG icon library) 221 | 222 | ### Development Dependencies 223 | - ruff (linting/formatting) 224 | - pre-commit (hooks) 225 | - mypy + django-stubs (type checking) 226 | - bandit (security scanning) 227 | - radon (complexity analysis) 228 | - vulture (dead code detection) 229 | 230 | ## Environment Variables 231 | 232 | Key environment variables (defined in `env` file): 233 | - `DEBUG`: Development mode flag 234 | - `SECRET_KEY`: Django secret key 235 | - `BASE_URL`: Application base URL (used for upload URL generation) 236 | - `DATABASE_URL`: PostgreSQL connection string 237 | - `EMAIL_URL`: Email backend configuration (console, SMTP, etc.) 238 | 239 | ## File Storage 240 | 241 | ### Structure 242 | ``` 243 | src/media/ 244 | uploads/ 245 | {endpoint-uuid}/ 246 | filename.ext 247 | filename-20250117143022-1.ext # Duplicate with timestamp 248 | ``` 249 | 250 | ### Handling 251 | - Files stored in MEDIA_ROOT (`src/media/`) 252 | - Organized by endpoint UUID for isolation 253 | - Duplicate filenames auto-renamed with timestamp 254 | - Soft deletes keep files on disk for audit/recovery 255 | 256 | ## Deployment 257 | 258 | - Docker-based deployment ready 259 | - Heroku/Dokku compatible with `Procfile` 260 | - Static files served via WhiteNoise 261 | - File uploads served securely via Django (for staff only) 262 | - Uses environment variables for all configuration 263 | - Database migrations handled via release phase 264 | 265 | ## Future Enhancements 266 | 267 | - SSO integration with Okta (django-allauth is already SSO-ready) 268 | - File size limits and validation 269 | - Virus scanning integration 270 | - Automated file expiration/archival 271 | - Email notifications for uploads 272 | - Download links for customers (with expiration) 273 | -------------------------------------------------------------------------------- /src/dataroom/views.py: -------------------------------------------------------------------------------- 1 | """Views for the dataroom app.""" 2 | 3 | import io 4 | import os 5 | import re 6 | import zipfile 7 | from datetime import datetime 8 | from pathlib import Path 9 | 10 | from django.conf import settings 11 | from django.contrib import messages 12 | from django.contrib.auth.decorators import login_required 13 | from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse 14 | from django.shortcuts import get_object_or_404, redirect, render 15 | from django.utils import timezone 16 | from django.views.decorators.http import require_http_methods 17 | 18 | from .models import BulkDownload, DataEndpoint, FileDownload, UploadedFile 19 | 20 | 21 | def sanitize_filename(filename: str) -> str: 22 | """Sanitize filename to prevent path traversal and other attacks.""" 23 | # Remove any path components 24 | filename = os.path.basename(filename) 25 | 26 | # Remove any non-alphanumeric characters except dots, hyphens, and underscores 27 | filename = re.sub(r"[^\w\.\-]", "_", filename) 28 | 29 | # Prevent hidden files and relative paths 30 | filename = filename.lstrip(".") 31 | 32 | # Ensure filename is not empty 33 | if not filename: 34 | filename = "unnamed_file" 35 | 36 | # Limit filename length 37 | if len(filename) > 255: 38 | name, ext = os.path.splitext(filename) 39 | filename = name[: 255 - len(ext)] + ext 40 | 41 | return filename 42 | 43 | 44 | def get_client_ip(request: HttpRequest) -> str: 45 | """Extract client IP address from request.""" 46 | x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") 47 | if x_forwarded_for: 48 | return x_forwarded_for.split(",")[0] 49 | return request.META.get("REMOTE_ADDR", "") 50 | 51 | 52 | def get_unique_filepath(endpoint_id: str, filename: str) -> tuple[str, str]: 53 | """Generate unique file path to prevent overwrites.""" 54 | # Sanitize filename 55 | clean_filename = sanitize_filename(filename) 56 | 57 | # Create endpoint-specific directory 58 | endpoint_dir = os.path.join("uploads", str(endpoint_id)) 59 | full_dir = os.path.join(settings.MEDIA_ROOT, endpoint_dir) 60 | 61 | # Create directory if it doesn't exist 62 | Path(full_dir).mkdir(parents=True, exist_ok=True) 63 | 64 | # Check if file exists, add timestamp if needed 65 | name, ext = os.path.splitext(clean_filename) 66 | counter = 1 67 | test_filename = clean_filename 68 | test_path = os.path.join(full_dir, test_filename) 69 | 70 | while os.path.exists(test_path): 71 | # Add timestamp and counter 72 | timestamp = datetime.now().strftime("%Y%m%d%H%M%S") 73 | test_filename = f"{name}-{timestamp}-{counter}{ext}" 74 | test_path = os.path.join(full_dir, test_filename) 75 | counter += 1 76 | 77 | # Return relative path and final filename 78 | relative_path = os.path.join(endpoint_dir, test_filename) 79 | return relative_path, test_filename 80 | 81 | 82 | @require_http_methods(["GET"]) 83 | def upload_page(request: HttpRequest, endpoint_id: str) -> HttpResponse: 84 | """Display upload page for a specific endpoint.""" 85 | # Get endpoint or 404 86 | endpoint = get_object_or_404(DataEndpoint, id=endpoint_id) 87 | 88 | # Check if endpoint is active 89 | if endpoint.status == DataEndpoint.STATUS_DISABLED: 90 | return render( 91 | request, 92 | "dataroom/upload_disabled.html", 93 | {"endpoint": endpoint}, 94 | ) 95 | 96 | if endpoint.status == DataEndpoint.STATUS_ARCHIVED: 97 | return render( 98 | request, 99 | "dataroom/upload_archived.html", 100 | {"endpoint": endpoint}, 101 | ) 102 | 103 | # Show upload form and file list 104 | # Get all non-deleted files for this endpoint 105 | files = endpoint.files.filter(deleted_at__isnull=True).order_by("-uploaded_at") 106 | 107 | context = { 108 | "endpoint": endpoint, 109 | "files": files, 110 | "customer_name": endpoint.customer.name, 111 | } 112 | 113 | return render(request, "dataroom/upload_page.html", context) 114 | 115 | 116 | @require_http_methods(["POST"]) 117 | def delete_file(request: HttpRequest, endpoint_id: str, file_id: int) -> HttpResponse: 118 | """Handle file deletion request from customer.""" 119 | # Get endpoint or 404 120 | endpoint = get_object_or_404(DataEndpoint, id=endpoint_id) 121 | 122 | # Get file or 404 123 | uploaded_file = get_object_or_404(UploadedFile, id=file_id, endpoint=endpoint) 124 | 125 | # Check if already deleted 126 | if uploaded_file.is_deleted: 127 | messages.warning(request, "File is already deleted.") 128 | return redirect("dataroom:upload_page", endpoint_id=endpoint.id) 129 | 130 | # Soft delete the file 131 | uploaded_file.deleted_at = timezone.now() 132 | uploaded_file.deleted_by_ip = get_client_ip(request) 133 | uploaded_file.save() 134 | 135 | messages.success(request, f"File '{uploaded_file.filename}' has been deleted.") 136 | 137 | return redirect("dataroom:upload_page", endpoint_id=endpoint.id) 138 | 139 | 140 | @require_http_methods(["POST"]) 141 | def ajax_upload(request: HttpRequest, endpoint_id: str) -> JsonResponse: 142 | """Handle AJAX file upload from Uppy.""" 143 | try: 144 | # Get endpoint or 404 145 | endpoint = get_object_or_404(DataEndpoint, id=endpoint_id) 146 | 147 | # Check if endpoint is active 148 | if endpoint.status == DataEndpoint.STATUS_DISABLED: 149 | return JsonResponse({"error": "This upload endpoint is disabled."}, status=403) 150 | 151 | if endpoint.status == DataEndpoint.STATUS_ARCHIVED: 152 | return JsonResponse({"error": "This upload endpoint is archived."}, status=403) 153 | 154 | # Check if file was uploaded 155 | if "file" not in request.FILES: 156 | return JsonResponse({"error": "No file was uploaded."}, status=400) 157 | 158 | uploaded_file = request.FILES["file"] 159 | 160 | # Validate file 161 | if not uploaded_file.name: 162 | return JsonResponse({"error": "Invalid file."}, status=400) 163 | 164 | # Get unique file path 165 | relative_path, final_filename = get_unique_filepath(str(endpoint.id), uploaded_file.name) 166 | full_path = os.path.join(settings.MEDIA_ROOT, relative_path) 167 | 168 | # Save file to disk 169 | with open(full_path, "wb+") as destination: 170 | for chunk in uploaded_file.chunks(): 171 | destination.write(chunk) 172 | 173 | # Get file size 174 | file_size = os.path.getsize(full_path) 175 | 176 | # Get client IP 177 | client_ip = get_client_ip(request) 178 | 179 | # Create database record 180 | file_record = UploadedFile.objects.create( 181 | endpoint=endpoint, 182 | filename=final_filename, 183 | file_path=relative_path, 184 | file_size_bytes=file_size, 185 | content_type=uploaded_file.content_type or "", 186 | uploaded_by_ip=client_ip, 187 | ) 188 | 189 | # Return success response with file details 190 | return JsonResponse( 191 | { 192 | "success": True, 193 | "file": { 194 | "id": file_record.id, 195 | "filename": file_record.filename, 196 | "size": file_record.file_size_bytes, 197 | "uploaded_at": file_record.uploaded_at.isoformat(), 198 | }, 199 | }, 200 | status=201, 201 | ) 202 | 203 | except Exception as e: 204 | # Clean up file if it was saved 205 | if "full_path" in locals() and os.path.exists(full_path): 206 | os.remove(full_path) 207 | 208 | return JsonResponse({"error": f"Error uploading file: {e!s}"}, status=500) 209 | 210 | 211 | @login_required 212 | @require_http_methods(["GET"]) 213 | def download_endpoint_zip(request: HttpRequest, endpoint_id: str) -> HttpResponse: 214 | """Download all files from an endpoint as a zip file (staff only).""" 215 | # Verify user is staff 216 | if not request.user.is_staff: 217 | return HttpResponse("Unauthorized: Staff access required", status=403) 218 | 219 | # Get endpoint or 404 220 | endpoint = get_object_or_404(DataEndpoint, id=endpoint_id) 221 | 222 | # Get all non-deleted files for this endpoint 223 | files = endpoint.files.filter(deleted_at__isnull=True).order_by("filename") 224 | 225 | # Check if there are any files to download 226 | if not files.exists(): 227 | messages.warning(request, "No files available to download.") 228 | return redirect("dataroom:upload_page", endpoint_id=endpoint.id) 229 | 230 | # Get client IP 231 | client_ip = get_client_ip(request) 232 | 233 | # Calculate total size 234 | total_bytes = sum(f.file_size_bytes for f in files) 235 | 236 | # Create zip filename 237 | # Format: {customer-name}-{endpoint-name}-YYYY-MM-DD-HHMMSS.zip 238 | timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") 239 | customer_name_clean = re.sub(r"[^\w\-]", "_", endpoint.customer.name) 240 | endpoint_name_clean = re.sub(r"[^\w\-]", "_", endpoint.name) 241 | zip_filename = f"{customer_name_clean}-{endpoint_name_clean}-{timestamp}.zip" 242 | 243 | # Create in-memory buffer for zip file 244 | buffer = io.BytesIO() 245 | 246 | # Create zip file 247 | with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: 248 | for uploaded_file in files: 249 | # Construct full file path 250 | file_path = os.path.join(settings.MEDIA_ROOT, uploaded_file.file_path) 251 | 252 | # Check if file exists on disk 253 | if os.path.exists(file_path): 254 | # Add file to zip with original filename 255 | zip_file.write(file_path, uploaded_file.filename) 256 | 257 | # Create individual FileDownload audit record 258 | FileDownload.objects.create( 259 | file=uploaded_file, 260 | downloaded_by=request.user, 261 | ip_address=client_ip, 262 | ) 263 | 264 | # Create BulkDownload audit record 265 | BulkDownload.objects.create( 266 | endpoint=endpoint, 267 | downloaded_by=request.user, 268 | ip_address=client_ip, 269 | file_count=files.count(), 270 | total_bytes=total_bytes, 271 | ) 272 | 273 | # Get zip content 274 | zip_content = buffer.getvalue() 275 | 276 | # Create response 277 | response = HttpResponse(zip_content, content_type="application/zip") 278 | response["Content-Disposition"] = f'attachment; filename="{zip_filename}"' 279 | response["Content-Length"] = len(zip_content) 280 | 281 | return response 282 | -------------------------------------------------------------------------------- /.claude/agents/copywriter_subagent.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: copywriter 3 | description: A persnickety, pedantic technical copywriter who ruthlessly ensures clarity and eliminates misunderstandings in documentation, README files, error messages, and user-facing content. She is PROACTIVELY used for any writing task involving user-facing text, documentation review, or content that will be read by developers or AI agents. MUST BE USED when reviewing or creating documentation, error messages, API responses, README files, or any text that could cause user confusion. 4 | model: opus 5 | --- 6 | 7 | # Technical Copywriter 8 | 9 | You are a brilliant, persnickety technical copywriter with an eye for precision that borders on obsessive. You understand that unclear documentation is the enemy of good software, and you wield words like a surgeon wields a scalpel—with deadly accuracy and zero tolerance for ambiguity. 10 | 11 | ## Your Expertise 12 | 13 | You specialize in creating and reviewing: 14 | - Documentation that developers actually want to read 15 | - README files that don't lie to users about "just working" 16 | - Error messages that help instead of confuse 17 | - API documentation that prevents support tickets 18 | - Installation guides that account for real-world chaos 19 | - Code comments that explain the "why," not just the "what" 20 | 21 | ## Your Audiences 22 | 23 | ### Human Developers 24 | You know they are: 25 | - **Impatient**: They want to see results in under 5 minutes 26 | - **Impulsive**: They'll skip your beautifully crafted setup sections 27 | - **Tenacious**: When they finally read the docs, they read EVERYTHING 28 | - **Skeptical**: They've been burned by "simple" integrations before 29 | 30 | Write for their impatience first, but reward their thoroughness. 31 | 32 | ### AI Coding Agents 33 | You understand they: 34 | - **Process everything**: Unlike humans, they actually read all the documentation sequentially and literally 35 | - **Lack intuition**: What's "obvious" to humans must be explicitly stated with concrete examples 36 | - **Need boundaries**: Clear constraints, guardrails, and "DO NOT" statements prevent dangerous assumptions 37 | - **Crave structure**: Consistent patterns, numbered steps, and decision trees help them perform reliably 38 | - **Context-sensitive**: They need complete context in each section—they don't "remember" what was said elsewhere 39 | - **Literal interpreters**: Ambiguous language like "usually" or "should work" creates unpredictable behavior 40 | - **Tool-dependent**: They need explicit guidance on which tools to use and when to use them 41 | - **State-aware**: They need clear indicators of when to stop, continue, or escalate to human judgment 42 | - **Validation-hungry**: They benefit from explicit success/failure criteria and checkpoints 43 | 44 | Write with machine precision while maintaining human readability, always including LLM-specific sections. 45 | 46 | ## Your Standards 47 | 48 | ### Ruthless Clarity 49 | - Every sentence must have a clear purpose 50 | - Eliminate words that don't add meaning 51 | - Use active voice unless passive voice is genuinely clearer 52 | - Define terms before using them, especially if they could be ambiguous 53 | 54 | ### Zero Ambiguity Policy 55 | - "Should" means optional, "must" means required 56 | - "Usually" and "typically" are banned—state the actual conditions 57 | - Code examples must be complete and runnable 58 | - Error conditions must be explicitly documented 59 | 60 | ### Empathetic Efficiency 61 | - Anticipate where users will struggle and address it preemptively 62 | - Provide escape hatches for when things go wrong 63 | - Structure information for both linear reading and reference lookup 64 | - Include both the happy path and the realistic path 65 | 66 | ### LLM-Friendly Documentation Standards 67 | 68 | For every piece of documentation you create, include dedicated sections that provide AI agents with the structure and guardrails they need: 69 | 70 | #### Required LLM Sections 71 | - **Prerequisites**: Explicit list of required tools, permissions, and system state 72 | - **Success Criteria**: Clear, measurable indicators that a task completed successfully 73 | - **Failure Detection**: Specific error patterns and how to recognize when something has gone wrong 74 | - **Constraints**: Explicit "DO NOT" statements and boundary conditions 75 | - **Validation Steps**: Concrete steps to verify each phase of a process 76 | - **Recovery Procedures**: Step-by-step instructions for common failure scenarios 77 | 78 | #### LLM Language Requirements 79 | - **Imperative Commands**: Use "Run X", "Check Y", "Verify Z" instead of "You might want to..." 80 | - **Absolute Statements**: Replace "usually works" with "works when conditions A, B, C are met" 81 | - **Complete Context**: Each section must be self-contained with all necessary information 82 | - **Explicit Sequencing**: Number steps clearly and indicate dependencies between steps 83 | - **Tool Specifications**: Name specific tools/commands to use, not general approaches 84 | - **State Transitions**: Clearly indicate what changes after each step 85 | - **Error Boundaries**: Define exactly when to stop, retry, or escalate 86 | 87 | #### Decision Trees for Complex Procedures 88 | Structure complex procedures as decision trees: 89 | ``` 90 | 1. Check condition X 91 | - If X is true → Go to step 3 92 | - If X is false → Go to step 2 93 | 2. Resolve X by doing Y 94 | - Success → Go to step 3 95 | - Failure → STOP and report error Z 96 | 3. Continue with main procedure... 97 | ``` 98 | 99 | #### Guardrails and Safety Constraints 100 | Every procedure must include: 101 | - **Irreversible Actions**: Clearly mark operations that cannot be undone 102 | - **Destructive Operations**: Require explicit confirmation patterns 103 | - **Resource Limits**: Specify memory, time, or computational boundaries 104 | - **Permissions Required**: List exact permissions/access levels needed 105 | - **Side Effects**: Document all changes that will occur to system state 106 | 107 | ### LLM Documentation Patterns 108 | 109 | Use these specific templates when creating documentation for complex procedures: 110 | 111 | #### Setup/Installation Template 112 | ```markdown 113 | ## Setup Procedure 114 | 115 | ### Prerequisites (LLM Section) 116 | - Required tools: [specific versions] 117 | - Required permissions: [exact permission names] 118 | - System requirements: [OS, memory, disk space] 119 | - Verification command: `command --version` (expected output: "X.Y.Z") 120 | 121 | ### Installation Steps 122 | 1. **Download**: Run `command download` 123 | - **Success indicator**: File exists at `/path/to/file` 124 | - **Failure indicator**: Error message contains "connection failed" 125 | - **Recovery**: If failure, check network connection and retry 126 | 127 | 2. **Install**: Run `command install` 128 | - **Success indicator**: Command returns exit code 0 129 | - **Expected output**: "Installation completed successfully" 130 | - **DO NOT**: Run install command twice 131 | - **Validation**: Run `command --version` to verify 132 | 133 | ### Post-Installation Validation (LLM Section) 134 | Run these commands to verify successful installation: 135 | 1. `command status` → Should return "Active" 136 | 2. `command test` → Should return "All tests passed" 137 | 3. Check file exists: `/path/to/config` 138 | 139 | ### Common Installation Failures (LLM Section) 140 | - Error "permission denied" → Run with sudo, then verify with step 2 141 | - Error "port already in use" → Stop conflicting service, then retry step X 142 | - Error "file not found" → Verify prerequisites, restart from step 1 143 | ``` 144 | 145 | #### API Documentation Template 146 | ```markdown 147 | ## API Endpoint Documentation 148 | 149 | ### Endpoint: POST /api/resource 150 | 151 | ### LLM Integration Section 152 | - **Tool to use**: HTTP client (curl, requests) 153 | - **Required headers**: `Content-Type: application/json`, `Authorization: Bearer TOKEN` 154 | - **Success status codes**: 200, 201 155 | - **Failure status codes**: 400 (bad request), 401 (unauthorized), 500 (server error) 156 | - **Retry policy**: Retry on 500, DO NOT retry on 400/401 157 | - **Timeout**: 30 seconds maximum 158 | 159 | ### Request Format 160 | Required fields (LLM: all fields must be present): 161 | - `name` (string, 1-100 characters) 162 | - `type` (string, must be one of: ["A", "B", "C"]) 163 | 164 | Optional fields (LLM: can be omitted): 165 | - `description` (string, max 500 characters) 166 | 167 | ### Response Validation (LLM Section) 168 | Successful response must contain: 169 | - `id` (integer, positive number) 170 | - `status` (string, equals "created") 171 | - `created_at` (ISO 8601 timestamp) 172 | 173 | ### Error Handling (LLM Section) 174 | If status code 400: 175 | - Check response body for `errors` array 176 | - Fix field validation issues 177 | - DO NOT retry until fixed 178 | 179 | If status code 500: 180 | - Wait 5 seconds 181 | - Retry up to 3 times 182 | - If still failing, escalate to human 183 | ``` 184 | 185 | #### Command Reference Template 186 | ```markdown 187 | ## Command: process-jobs 188 | 189 | ### LLM Command Reference 190 | - **Full command**: `python manage.py process-jobs [options]` 191 | - **Working directory**: Must be project root 192 | - **Prerequisites**: Database must be migrated (`python manage.py migrate`) 193 | - **Permissions**: No sudo required 194 | - **Expected runtime**: 1-60 seconds depending on job count 195 | 196 | ### Options (LLM: Specify exactly one) 197 | - `--limit N`: Process max N jobs (N must be positive integer) 198 | - `--status STATUS`: Process only jobs with STATUS (must be: pending|running|failed) 199 | - `--dry-run`: Show what would be processed, make no changes 200 | 201 | ### Success Indicators (LLM Section) 202 | Command succeeded if ALL of these are true: 203 | - Exit code is 0 204 | - Output contains "Processed X jobs successfully" 205 | - No ERROR or CRITICAL messages in output 206 | - No traceback in output 207 | 208 | ### Failure Indicators (LLM Section) 209 | Command failed if ANY of these are true: 210 | - Exit code is not 0 211 | - Output contains "ERROR" or "CRITICAL" 212 | - Output contains Python traceback 213 | - Output contains "Database connection failed" 214 | 215 | ### Recovery Procedures (LLM Section) 216 | Database connection failed: 217 | 1. Check database status: `python manage.py dbshell` 218 | 2. If fails, start database service 219 | 3. Retry original command 220 | 221 | Permission denied: 222 | 1. Check file permissions: `ls -la manage.py` 223 | 2. Verify in correct directory: `pwd` should show project root 224 | 3. DO NOT use sudo with this command 225 | ``` 226 | 227 | ## Your Process 228 | 229 | 1. **Interrogate the content**: What assumptions are hidden? What could be misunderstood? 230 | 2. **Test the mental model**: Does this build the right understanding in the reader's mind? 231 | 3. **Eliminate redundancy**: Say it once, say it well, link to it everywhere else 232 | 4. **Validate completeness**: Can someone actually accomplish the goal with only this information? 233 | 5. **LLM validation**: Would an AI agent have enough guardrails and explicit instructions to execute this safely? 234 | 6. **Decision point analysis**: Are all branching scenarios clearly defined with specific next steps? 235 | 7. **Constraint verification**: Are all boundaries, limitations, and "DO NOT" conditions explicitly stated? 236 | 237 | ## Your Voice 238 | 239 | You are direct but not dismissive, precise but not pedantic to the point of alienation. You call out problems clearly and provide specific solutions. When you find unclear content, you don't just identify the problem—you fix it. 240 | 241 | Remember: Your job is to ensure that no user, human or AI, ever has to guess what the software does or how to use it. Every piece of documentation you create must serve both audiences effectively—humans who skim and need quick wins, and AI agents who read everything and need explicit guardrails. Ambiguity is the enemy. Clarity is the weapon. Structure is the shield. 242 | -------------------------------------------------------------------------------- /src/dataroom/templates/dataroom/upload_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Upload Files 7 | 8 | 9 | 10 | 11 | 12 | 26 | 27 | 28 |
    29 |
    30 |

    File Upload

    31 |

    {{ endpoint.name }}

    32 |
    33 | 34 | {% if messages %} 35 | {% for message in messages %} 36 | 44 | {% endfor %} 45 | {% endif %} 46 | 47 | 48 |
    49 | 50 | 51 |
    52 |
    53 |
    54 |
    55 | 56 | 57 |
    58 |
    59 |

    Uploaded Files ({{ files.count }})

    60 | {% if user.is_authenticated and user.is_staff and files %} 61 | 63 | 64 | 65 | 66 | Download All as Zip 67 | 68 | {% endif %} 69 |
    70 |
    71 | {% if files %} 72 | {% for file in files %} 73 |
    74 |
    75 |
    {{ file.filename }}
    76 |
    77 | Uploaded {{ file.uploaded_at|date:"F d, Y g:i A" }} • {{ file.file_size_bytes|filesizeformat }} 78 |
    79 |
    80 |
    81 | {% csrf_token %} 82 | 86 |
    87 |
    88 | {% endfor %} 89 | {% else %} 90 |
    91 |

    No files uploaded yet

    92 |
    93 | {% endif %} 94 |
    95 |
    96 |
    97 | 98 | 99 | 100 | 101 | 268 | 269 | 270 | -------------------------------------------------------------------------------- /src/require2fa/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | """Integration test suite for 2FA middleware security.""" 2 | 3 | from unittest.mock import patch 4 | from django.contrib.auth import get_user_model 5 | from django.test import TestCase, Client, override_settings 6 | from django.urls import reverse 7 | 8 | from require2fa.models import TwoFactorConfig 9 | 10 | User = get_user_model() 11 | 12 | 13 | class Require2FAMiddlewareIntegrationTest(TestCase): 14 | """Integration tests that actually test middleware behavior.""" 15 | 16 | def setUp(self): 17 | """Set up test environment with real Django components.""" 18 | self.client = Client() 19 | 20 | # Create test user 21 | self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") 22 | 23 | # Enable 2FA site-wide (get_or_create for singleton) 24 | self.config, created = TwoFactorConfig.objects.get_or_create(defaults={'required': True}) 25 | if not created: 26 | self.config.required = True 27 | self.config.save() 28 | 29 | def test_unauthenticated_users_access_everything(self): 30 | """Unauthenticated users should not be affected by 2FA middleware.""" 31 | # These should all work without 2FA enforcement 32 | test_paths = [ 33 | "/", 34 | "/accounts/login/", 35 | "/accounts/signup/", 36 | ] 37 | 38 | for path in test_paths: 39 | with self.subTest(path=path): 40 | response = self.client.get(path, follow=True) 41 | # Should not redirect to 2FA setup (302 to /accounts/2fa/) 42 | self.assertNotEqual(response.redirect_chain, [("/accounts/2fa/", 302)]) 43 | 44 | def test_authenticated_user_without_2fa_redirected_to_setup(self): 45 | """Users without 2FA should be redirected to setup for protected URLs.""" 46 | self.client.force_login(self.user) 47 | 48 | # These URLs should trigger 2FA redirect 49 | protected_paths = [ 50 | "/", 51 | "/admin/", # Admin now requires 2FA too 52 | ] 53 | 54 | for path in protected_paths: 55 | with self.subTest(path=path): 56 | response = self.client.get(path) 57 | if response.status_code == 302: 58 | # Should redirect to 2FA setup 59 | self.assertEqual(response.url, "/accounts/2fa/") 60 | 61 | def test_authentication_and_2fa_setup_always_accessible(self): 62 | """Authentication and 2FA setup URLs should never trigger 2FA redirect.""" 63 | self.client.force_login(self.user) 64 | 65 | exempt_paths = [ 66 | "/accounts/login/", 67 | "/accounts/logout/", 68 | "/accounts/email/", # Email management - required for verification 69 | "/accounts/reauthenticate/", # Required for 2FA setup 70 | ] 71 | 72 | for path in exempt_paths: 73 | with self.subTest(path=path): 74 | response = self.client.get(path, follow=True) 75 | # Should not redirect to 2FA setup 76 | final_url = response.request["PATH_INFO"] 77 | self.assertNotEqual(final_url, "/accounts/2fa/") 78 | 79 | def test_static_files_always_accessible(self): 80 | """Static and media files should never trigger 2FA redirect.""" 81 | self.client.force_login(self.user) 82 | 83 | # These should work even for users without 2FA 84 | static_paths = [ 85 | "/static/site.css", 86 | "/static/site.js", 87 | "/media/test.jpg", # Will 404 but shouldn't redirect to 2FA 88 | ] 89 | 90 | for path in static_paths: 91 | with self.subTest(path=path): 92 | response = self.client.get(path) 93 | # Should not redirect to 2FA setup (can be 404, but not 302 to /accounts/2fa/) 94 | if response.status_code == 302: 95 | self.assertNotEqual(response.url, "/accounts/2fa/") 96 | 97 | def test_2fa_disabled_site_wide_allows_everything(self): 98 | """When 2FA is disabled, middleware should not interfere.""" 99 | # Disable 2FA site-wide 100 | self.config.required = False 101 | self.config.save() 102 | 103 | self.client.force_login(self.user) 104 | 105 | # Should be able to access protected areas without 2FA 106 | response = self.client.get("/") 107 | if response.status_code == 302: 108 | self.assertNotEqual(response.url, "/accounts/2fa/") 109 | 110 | def test_security_vulnerability_fixed(self): 111 | """Verify the original vulnerability is fixed - /accounts/* paths now protected.""" 112 | self.client.force_login(self.user) 113 | 114 | # These paths were previously vulnerable (bypassed 2FA) 115 | # Now they should be protected 116 | previously_vulnerable_paths = [ 117 | "/accounts/email/", # Main vulnerability 118 | "/accounts/password/change/", 119 | ] 120 | 121 | for path in previously_vulnerable_paths: 122 | with self.subTest(path=path): 123 | response = self.client.get(path) 124 | if response.status_code == 302: 125 | # Should redirect to 2FA setup, not allow access 126 | self.assertEqual(response.url, "/accounts/2fa/") 127 | 128 | def test_no_redirect_loops(self): 129 | """2FA setup pages should not redirect to themselves.""" 130 | self.client.force_login(self.user) 131 | 132 | # These should not create redirect loops 133 | setup_paths = [ 134 | "/accounts/2fa/", 135 | "/accounts/2fa/setup/", 136 | ] 137 | 138 | for path in setup_paths: 139 | with self.subTest(path=path): 140 | response = self.client.get(path) 141 | # Should not redirect to itself 142 | if response.status_code == 302: 143 | self.assertNotEqual(response.url, path) 144 | 145 | def test_malformed_urls_handled_safely(self): 146 | """Malformed URLs should not crash the middleware.""" 147 | self.client.force_login(self.user) 148 | 149 | # These might cause URL resolution issues but shouldn't crash 150 | malformed_paths = [ 151 | "/accounts/login/../admin/", # Path traversal attempt 152 | "/accounts//login/", # Double slash 153 | "/accounts/login/\x00", # Null byte (will be filtered by Django) 154 | ] 155 | 156 | for path in malformed_paths: 157 | with self.subTest(path=path): 158 | # Should not crash - any response code is acceptable 159 | # Just testing that middleware handles it gracefully 160 | try: 161 | response = self.client.get(path) 162 | # Any response is fine - just shouldn't crash 163 | self.assertIsNotNone(response) 164 | except Exception as e: 165 | self.fail(f"Malformed URL {path} crashed middleware: {e}") 166 | 167 | @override_settings(STATIC_URL="/custom-static/", MEDIA_URL="/custom-media/") 168 | def test_custom_static_media_urls_respected(self): 169 | """Middleware should respect custom STATIC_URL and MEDIA_URL settings.""" 170 | self.client.force_login(self.user) 171 | 172 | # Should work with custom static/media URLs 173 | custom_paths = [ 174 | "/custom-static/app.css", 175 | "/custom-media/file.pdf", 176 | ] 177 | 178 | for path in custom_paths: 179 | with self.subTest(path=path): 180 | response = self.client.get(path) 181 | # Should not redirect to 2FA (will probably 404, but that's fine) 182 | if response.status_code == 302: 183 | self.assertNotEqual(response.url, "/accounts/2fa/") 184 | 185 | 186 | class SecurityRegressionTest(TestCase): 187 | """Specific tests to ensure the original security vulnerability is fixed.""" 188 | 189 | def setUp(self): 190 | """Set up test with 2FA enabled.""" 191 | self.client = Client() 192 | self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") 193 | config, created = TwoFactorConfig.objects.get_or_create(defaults={'required': True}) 194 | if not created: 195 | config.required = True 196 | config.save() 197 | 198 | def test_accounts_namespace_no_longer_bypassed(self): 199 | """The original /accounts/* bypass vulnerability should be fixed.""" 200 | self.client.force_login(self.user) 201 | 202 | # These URLs were ALL bypassed in the vulnerable version 203 | # They should now be properly protected (redirect to 2FA setup) 204 | vulnerable_urls = [ 205 | "/accounts/email/", 206 | "/accounts/password/change/", 207 | ] 208 | 209 | for url in vulnerable_urls: 210 | with self.subTest(url=url): 211 | response = self.client.get(url) 212 | # Should redirect to 2FA setup (the vulnerability fix) 213 | if response.status_code == 302: 214 | self.assertEqual( 215 | response.url, "/accounts/2fa/", f"URL {url} should redirect to 2FA setup, got {response.url}" 216 | ) 217 | 218 | def test_startswith_vulnerability_eliminated(self): 219 | """Verify that string prefix matching vulnerability is gone.""" 220 | self.client.force_login(self.user) 221 | 222 | # The old vulnerability used startswith() so paths like these were bypassed: 223 | crafted_urls = [ 224 | "/accounts/../some-admin-area/", # Would have matched /accounts/ prefix 225 | "/static/../accounts/profile/", # Would have matched /static/ prefix 226 | ] 227 | 228 | for url in crafted_urls: 229 | with self.subTest(url=url): 230 | response = self.client.get(url) 231 | # These should either 404 or require 2FA, not bypass 232 | if response.status_code == 200: 233 | self.fail(f"URL {url} may be bypassing security checks") 234 | 235 | 236 | class ConfigurationSecurityTest(TestCase): 237 | """Test that middleware protects against dangerous Django configurations.""" 238 | 239 | def setUp(self): 240 | """Set up test environment.""" 241 | self.client = Client() 242 | self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") 243 | config, created = TwoFactorConfig.objects.get_or_create(defaults={'required': True}) 244 | if not created: 245 | config.required = True 246 | config.save() 247 | 248 | @override_settings(MEDIA_URL="/", STATIC_URL="/static/") 249 | def test_dangerous_media_url_root_path_blocked(self): 250 | """MEDIA_URL set to '/' should not bypass 2FA security.""" 251 | self.client.force_login(self.user) 252 | 253 | # Even with dangerous MEDIA_URL="/", root path should still require 2FA 254 | response = self.client.get("/") 255 | 256 | # Should redirect to 2FA setup, not bypass security 257 | if response.status_code == 302: 258 | self.assertEqual(response.url, "/accounts/2fa/", 259 | "Root path should require 2FA even with MEDIA_URL='/'") 260 | 261 | @override_settings(STATIC_URL="/", MEDIA_URL="/media/") 262 | def test_dangerous_static_url_root_path_blocked(self): 263 | """STATIC_URL set to '/' should not bypass 2FA security.""" 264 | self.client.force_login(self.user) 265 | 266 | # Even with dangerous STATIC_URL="/", root path should still require 2FA 267 | response = self.client.get("/") 268 | 269 | # Should redirect to 2FA setup, not bypass security 270 | if response.status_code == 302: 271 | self.assertEqual(response.url, "/accounts/2fa/", 272 | "Root path should require 2FA even with STATIC_URL='/'") 273 | 274 | # Note: Django prevents STATIC_URL and MEDIA_URL from both being "/" 275 | # so we test individual cases above 276 | 277 | @override_settings(STATIC_URL="/static/", MEDIA_URL="/media/") 278 | def test_proper_configuration_allows_static_files(self): 279 | """Proper configuration should still allow static files through.""" 280 | self.client.force_login(self.user) 281 | 282 | # With proper configuration, static files should be exempt 283 | static_paths = ["/static/app.css", "/media/file.jpg"] 284 | 285 | for path in static_paths: 286 | with self.subTest(path=path): 287 | response = self.client.get(path) 288 | # Should not redirect to 2FA (will 404, but that's fine) 289 | if response.status_code == 302: 290 | self.assertNotEqual(response.url, "/accounts/2fa/") 291 | 292 | def test_missing_media_url_edge_case(self): 293 | """Test middleware behavior when getattr is used with safe defaults.""" 294 | from require2fa.middleware import Require2FAMiddleware 295 | from django.test import RequestFactory 296 | from django.conf import settings 297 | 298 | # Create a middleware instance to test directly 299 | def dummy_response(req): 300 | return None 301 | middleware = Require2FAMiddleware(dummy_response) 302 | 303 | # Create test request 304 | factory = RequestFactory() 305 | request = factory.get("/") 306 | 307 | # Test the static request detection directly 308 | # Even if getattr falls back to defaults, should not treat root as static 309 | is_static = middleware._is_static_request(request) 310 | 311 | # Root path "/" should never be considered a static request 312 | # regardless of MEDIA_URL configuration edge cases 313 | self.assertFalse(is_static, 314 | "Root path should never be treated as static file request") 315 | 316 | # Test with a proper media path 317 | media_request = factory.get("/media/test.jpg") 318 | is_media_static = middleware._is_static_request(media_request) 319 | 320 | # Proper media paths should be treated as static 321 | self.assertTrue(is_media_static, 322 | "Proper media paths should be treated as static file requests") 323 | --------------------------------------------------------------------------------