From e4af507286115be425edb30b4c5367394890d7aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 17:15:51 +0000 Subject: [PATCH 1/5] Initial plan From 071deaf1c500132101af054e53a88279a4a52f5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 17:28:12 +0000 Subject: [PATCH 2/5] Revert repository to state at commit 91157cf55db4b7c3cf9d5cdaf9abeec88ac5f942 Co-authored-by: heysamtexas <351998+heysamtexas@users.noreply.github.com> --- .gitignore | 2 - CLAUDE.md | 150 +- Makefile | 8 - pyproject.toml | 1 + src/config/settings.py | 13 +- src/config/urls.py | 7 +- src/dataroom/admin.py | 381 --- src/dataroom/apps.py | 6 - src/dataroom/migrations/0001_initial.py | 85 - src/dataroom/migrations/0002_bulkdownload.py | 33 - src/dataroom/models.py | 183 -- .../templates/dataroom/upload_archived.html | 18 - .../templates/dataroom/upload_disabled.html | 18 - .../templates/dataroom/upload_page.html | 269 --- src/dataroom/tests.py | 537 ----- src/dataroom/urls.py | 14 - src/dataroom/views.py | 281 --- src/myapp/admin/__init__.py | 4 + src/myapp/admin/worker_configurations.py | 20 + src/myapp/admin/worker_errors.py | 17 + src/myapp/management/__init__.py | 1 + src/myapp/management/commands/__init__.py | 1 + src/myapp/management/commands/_base.py | 116 + .../commands/send_email_confirmation.py | 22 + .../commands/simple_async_worker.py | 12 + src/myapp/migrations/0001_initial.py | 8 +- ...teconfiguration_worker_enabled_and_more.py | 28 + ...ation_js_body_siteconfiguration_js_head.py | 32 + ...lter_siteconfiguration_js_body_and_more.py | 34 + .../0005_workerconfiguration_workererror.py | 86 + ...lter_siteconfiguration_js_body_and_more.py | 35 + .../0007_siteconfiguration_required_2fa.py | 18 + ...ion_include_staff_in_analytics_and_more.py | 23 + .../0009_remove_required_2fa_field.py | 17 + src/myapp/models/__init__.py | 10 +- src/myapp/models/worker_configurations.py | 39 + src/myapp/models/worker_errors.py | 34 + src/myapp/templates/_alerts.html | 10 +- src/myapp/templates/_footer.html | 88 +- src/myapp/templates/_header.html | 130 +- src/myapp/templates/base.html | 12 +- src/myapp/templates/myapp/home.html | 16 +- src/myapp/views/__init__.py | 10 +- src/organizations/__init__.py | 1 + src/organizations/admin.py | 40 + src/organizations/apps.py | 8 + src/organizations/docs/README.md | 63 + src/organizations/docs/invitation_log.md | 25 + src/organizations/docs/invitations.md | 140 ++ src/organizations/docs/members.md | 108 + src/organizations/docs/organizations.md | 110 + src/organizations/forms.py | 122 + src/organizations/management/__init__.py | 1 + .../management/commands/__init__.py | 1 + .../management/commands/send_email_invite.py | 40 + src/organizations/migrations/0001_initial.py | 98 + .../migrations/0002_invitation.py | 75 + .../0003_alter_invitation_invite_key.py | 17 + .../0004_alter_invitation_organization.py | 22 + .../migrations/0005_invitation_email_sent.py | 17 + ...006_alter_invitation_user_invitationlog.py | 63 + .../migrations}/__init__.py | 0 src/organizations/models.py | 222 ++ src/organizations/services.py | 25 + .../templates/organizations/_breadcrumb.html | 11 + .../organizations/accept_invite.html | 14 + .../accept_invite_change_password.html | 34 + .../templates/organizations/base.html | 66 + .../templates/organizations/create.html | 34 + .../organizations/decline_invite.html | 11 + .../organizations/decline_invite_success.html | 6 + .../organizations/delete_organization.html | 40 + .../templates/organizations/detail.html | 103 + .../templates/organizations/index.html | 34 + .../templates/organizations/invite.html | 31 + .../templates/organizations/invite_logs.html | 13 + src/organizations/templatetags/__init__.py | 1 + .../templatetags/organization_extras.py | 34 + .../tests}/__init__.py | 0 src/organizations/tests/test_forms.py | 135 ++ .../tests/test_invitation_model.py | 65 + src/organizations/tests/views/__init__.py | 1 + src/organizations/tests/views/test_members.py | 314 +++ .../tests/views/test_organizations.py | 48 + src/organizations/urls.py | 28 + src/organizations/views/__init__.py | 1 + src/organizations/views/members.py | 246 ++ src/organizations/views/organizations.py | 150 ++ src/require2fa/migrations/0001_initial.py | 2 +- .../migrations/0002_copy_2fa_setting.py | 61 + src/static/site.js | 25 +- .../font/bootstrap-icons.css | 2078 ++++++++++++++++ .../font/bootstrap-icons.json | 2052 ++++++++++++++++ .../font/bootstrap-icons.min.css | 5 + .../font/bootstrap-icons.scss | 2090 +++++++++++++++++ .../font/fonts/bootstrap-icons.woff | Bin 0 -> 176032 bytes .../font/fonts/bootstrap-icons.woff2 | Bin 0 -> 130396 bytes .../vendor/bootstrap/bootstrap.bundle.min.js | 7 + src/static/vendor/bootstrap/bootstrap.min.css | 6 + src/templates/allauth/elements/alert.html | 2 +- src/templates/allauth/elements/badge.html | 12 +- src/templates/allauth/elements/button.html | 27 +- .../allauth/elements/button__entrance.html | 2 +- .../allauth/elements/button_group.html | 2 +- src/templates/allauth/elements/field.html | 60 +- src/templates/allauth/elements/form.html | 8 +- .../allauth/elements/form__entrance.html | 10 +- .../allauth/elements/h1__entrance.html | 2 +- .../allauth/elements/h2__entrance.html | 2 +- src/templates/allauth/elements/panel.html | 8 +- src/templates/allauth/elements/provider.html | 4 +- src/templates/allauth/elements/table.html | 10 +- src/templates/allauth/layouts/base.html | 19 +- src/templates/allauth/layouts/entrance.html | 12 +- src/templates/allauth/layouts/manage.html | 32 +- 115 files changed, 9746 insertions(+), 2239 deletions(-) delete mode 100644 src/dataroom/admin.py delete mode 100644 src/dataroom/apps.py delete mode 100644 src/dataroom/migrations/0001_initial.py delete mode 100644 src/dataroom/migrations/0002_bulkdownload.py delete mode 100644 src/dataroom/models.py delete mode 100644 src/dataroom/templates/dataroom/upload_archived.html delete mode 100644 src/dataroom/templates/dataroom/upload_disabled.html delete mode 100644 src/dataroom/templates/dataroom/upload_page.html delete mode 100644 src/dataroom/tests.py delete mode 100644 src/dataroom/urls.py delete mode 100644 src/dataroom/views.py create mode 100644 src/myapp/admin/worker_configurations.py create mode 100644 src/myapp/admin/worker_errors.py create mode 100644 src/myapp/management/__init__.py create mode 100644 src/myapp/management/commands/__init__.py create mode 100644 src/myapp/management/commands/_base.py create mode 100644 src/myapp/management/commands/send_email_confirmation.py create mode 100644 src/myapp/management/commands/simple_async_worker.py create mode 100644 src/myapp/migrations/0002_siteconfiguration_worker_enabled_and_more.py create mode 100644 src/myapp/migrations/0003_siteconfiguration_js_body_siteconfiguration_js_head.py create mode 100644 src/myapp/migrations/0004_alter_siteconfiguration_js_body_and_more.py create mode 100644 src/myapp/migrations/0005_workerconfiguration_workererror.py create mode 100644 src/myapp/migrations/0006_alter_siteconfiguration_js_body_and_more.py create mode 100644 src/myapp/migrations/0007_siteconfiguration_required_2fa.py create mode 100644 src/myapp/migrations/0008_siteconfiguration_include_staff_in_analytics_and_more.py create mode 100644 src/myapp/migrations/0009_remove_required_2fa_field.py create mode 100644 src/myapp/models/worker_configurations.py create mode 100644 src/myapp/models/worker_errors.py create mode 100644 src/organizations/__init__.py create mode 100644 src/organizations/admin.py create mode 100644 src/organizations/apps.py create mode 100644 src/organizations/docs/README.md create mode 100644 src/organizations/docs/invitation_log.md create mode 100644 src/organizations/docs/invitations.md create mode 100644 src/organizations/docs/members.md create mode 100644 src/organizations/docs/organizations.md create mode 100644 src/organizations/forms.py create mode 100644 src/organizations/management/__init__.py create mode 100644 src/organizations/management/commands/__init__.py create mode 100644 src/organizations/management/commands/send_email_invite.py create mode 100644 src/organizations/migrations/0001_initial.py create mode 100644 src/organizations/migrations/0002_invitation.py create mode 100644 src/organizations/migrations/0003_alter_invitation_invite_key.py create mode 100644 src/organizations/migrations/0004_alter_invitation_organization.py create mode 100644 src/organizations/migrations/0005_invitation_email_sent.py create mode 100644 src/organizations/migrations/0006_alter_invitation_user_invitationlog.py rename src/{dataroom => organizations/migrations}/__init__.py (100%) create mode 100644 src/organizations/models.py create mode 100644 src/organizations/services.py create mode 100644 src/organizations/templates/organizations/_breadcrumb.html create mode 100644 src/organizations/templates/organizations/accept_invite.html create mode 100644 src/organizations/templates/organizations/accept_invite_change_password.html create mode 100644 src/organizations/templates/organizations/base.html create mode 100644 src/organizations/templates/organizations/create.html create mode 100644 src/organizations/templates/organizations/decline_invite.html create mode 100644 src/organizations/templates/organizations/decline_invite_success.html create mode 100644 src/organizations/templates/organizations/delete_organization.html create mode 100644 src/organizations/templates/organizations/detail.html create mode 100644 src/organizations/templates/organizations/index.html create mode 100644 src/organizations/templates/organizations/invite.html create mode 100644 src/organizations/templates/organizations/invite_logs.html create mode 100644 src/organizations/templatetags/__init__.py create mode 100644 src/organizations/templatetags/organization_extras.py rename src/{dataroom/migrations => organizations/tests}/__init__.py (100%) create mode 100644 src/organizations/tests/test_forms.py create mode 100644 src/organizations/tests/test_invitation_model.py create mode 100644 src/organizations/tests/views/__init__.py create mode 100644 src/organizations/tests/views/test_members.py create mode 100644 src/organizations/tests/views/test_organizations.py create mode 100644 src/organizations/urls.py create mode 100644 src/organizations/views/__init__.py create mode 100644 src/organizations/views/members.py create mode 100644 src/organizations/views/organizations.py create mode 100644 src/require2fa/migrations/0002_copy_2fa_setting.py create mode 100644 src/static/vendor/bootstrap-icons-1.11.3/font/bootstrap-icons.css create mode 100644 src/static/vendor/bootstrap-icons-1.11.3/font/bootstrap-icons.json create mode 100644 src/static/vendor/bootstrap-icons-1.11.3/font/bootstrap-icons.min.css create mode 100644 src/static/vendor/bootstrap-icons-1.11.3/font/bootstrap-icons.scss create mode 100644 src/static/vendor/bootstrap-icons-1.11.3/font/fonts/bootstrap-icons.woff create mode 100644 src/static/vendor/bootstrap-icons-1.11.3/font/fonts/bootstrap-icons.woff2 create mode 100644 src/static/vendor/bootstrap/bootstrap.bundle.min.js create mode 100644 src/static/vendor/bootstrap/bootstrap.min.css diff --git a/.gitignore b/.gitignore index 61eb16e..5a514c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ venv/ __pycache__/ data/ -logs/ env env.backup @@ -11,7 +10,6 @@ config.mk *.db src/staticfiles/* -src/media/ .idea/** diff --git a/CLAUDE.md b/CLAUDE.md index 820bc51..a1b3a68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,43 +4,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -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. +This is a Django Reference Implementation - a production-ready Django SaaS template with organizations, invitations, and authentication. It follows a pragmatic approach to building multi-tenant applications with minimal dependencies. ## Architecture ### Core Apps Structure - **config/**: Django project configuration (settings, URLs, WSGI/ASGI) -- **myapp/**: Base application with site configuration models and templates -- **dataroom/**: File upload system with customers, endpoints, and audit logging -- **require2fa/**: Two-factor authentication enforcement middleware +- **myapp/**: Base application with site configuration models, templates, and management commands +- **organizations/**: Complete multi-tenant organization system with invitations and user management ### Key Components -- **Authentication**: Uses django-allauth with 2FA support and SSO-ready (Okta) -- **File Management**: Local filesystem storage with UUID-based endpoint privacy -- **Admin Interface**: Django admin for internal team management of customers and endpoints -- **Audit Logging**: Complete tracking of file uploads, deletions, and staff downloads -- **UI Framework**: Tailwind CSS via Play CDN with dark mode support and Heroicons -- **Templates**: Minimal, professional upload interface with responsive design - -## Data Room Features - -### Customer & Endpoint Management -- **Customers**: Internal tracking of companies/projects receiving upload endpoints -- **Data Endpoints**: UUID-based upload URLs that don't expose customer information -- **Multiple Endpoints**: Each customer can have multiple endpoints for different POCs -- **Status Control**: Endpoints can be active, disabled, or archived - -### File Upload System -- **Anonymous Upload**: Customers upload via UUID URL (no authentication required) -- **Security**: Filename sanitization, path traversal prevention, duplicate handling -- **Soft Delete**: Customers can request deletion (immediate with audit trail) -- **File Listing**: Customers can view all files uploaded to their endpoint - -### Staff Features (Django Admin) -- **Customer Management**: Create customers with freeform notes -- **Endpoint Creation**: Generate new upload endpoints with one-click URL copying -- **File Downloads**: Secure download with automatic audit logging -- **Audit Dashboard**: View all file downloads and deletion activity +- **Authentication**: Uses django-allauth with 2FA support +- **Async Processing**: Custom worker pattern using Django management commands with PostgreSQL as task queue +- **Multi-tenancy**: Organization-based tenancy with invitation system +- **Templates**: Bootstrap 5 UI with dark mode support ## Git Workflow for Claude Code @@ -110,7 +87,9 @@ uv run src/manage.py # Key management commands: uv run src/manage.py migrate uv run src/manage.py createsuperuser -uv run src/manage.py test +uv run src/manage.py simple_async_worker +uv run src/manage.py send_email_confirmation +uv run src/manage.py send_email_invite ``` ### Code Quality @@ -137,48 +116,43 @@ uv run vulture src/ --min-confidence 80 # Find unused code (high confidence) uv run vulture src/ --min-confidence 60 # Find unused code (medium confidence) # Type checking -cd src && DJANGO_SETTINGS_MODULE=config.settings uv run mypy dataroom/ myapp/ config/ --ignore-missing-imports --disable-error-code=var-annotated +cd src && DJANGO_SETTINGS_MODULE=config.settings uv run mypy organizations/ myapp/ config/ --ignore-missing-imports --disable-error-code=var-annotated ``` ## Development Workflow ### Local Development -- Uses Docker Compose for PostgreSQL and Mailpit -- Django runs locally or in Docker +- Uses Docker Compose for services (PostgreSQL, Mailpit, S3Proxy) +- Django can run locally or in Docker - Environment variables configured in `env` file (copy from `env.sample`) -- File uploads stored in `src/media/uploads/{endpoint-uuid}/` ### Testing -- Tests located in `*/tests.py` or `*/tests/` directories +- Tests located in `*/tests/` directories - Run with `uv run src/manage.py test` -- Covers models, views, upload/download functionality, and security +- Covers models, views, and forms -### URL Structure -- **Public (No Auth)**: `/upload/{uuid}/` - Customer upload page -- **Admin Only**: `/admin/` - Django admin interface -- **Staff Downloads**: Via Django admin actions (with audit logging) +### Worker System +- Custom async worker pattern using Django management commands +- Workers defined in `*/management/commands/` +- Uses PostgreSQL for task queue (no Redis/Celery required) +- Configure workers in `docker-compose.yml` ## Important Files ### Configuration - `src/config/settings.py`: Main Django settings - `pyproject.toml`: Project metadata and tool configuration (ruff, bandit) -- `docker-compose.yml`: Development services (PostgreSQL, Mailpit) +- `docker-compose.yml`: Development services - `Makefile`: Development automation commands -- `env`: Environment variables (copy from `env.sample`) ### Models -- `dataroom/models.py`: Customer, DataEndpoint, UploadedFile, FileDownload -- `myapp/models/`: Site configuration model -- `require2fa/models.py`: Two-factor configuration model - -### Views & Templates -- `dataroom/views.py`: Upload page, file upload handler, delete handler -- `dataroom/templates/dataroom/`: Upload page, disabled/archived templates -- `dataroom/admin.py`: Complete admin configuration with download actions +- `myapp/models/`: Site configuration and worker models +- `organizations/models.py`: Organization and invitation models -### Tests -- `dataroom/tests.py`: Comprehensive model and view tests +### Templates +- `templates/`: Global templates (base, auth, pages) +- `myapp/templates/`: App-specific templates +- `organizations/templates/`: Organization management templates ## Code Standards @@ -189,17 +163,10 @@ cd src && DJANGO_SETTINGS_MODULE=config.settings uv run mypy dataroom/ myapp/ co ### File Organization - Apps follow Django conventions -- Models in `models.py` or `models/` directory -- Views in `views.py` or `views/` directory +- Models in `models/` directory (may be split into multiple files) +- Views in `views/` directory +- Management commands in `management/commands/` - Templates in `templates/` with app namespacing -- Admin configurations in `admin.py` - -### Security -- **Filename Sanitization**: `sanitize_filename()` prevents path traversal -- **UUID Endpoints**: No customer information exposed in URLs -- **IP Tracking**: All uploads, deletes, and downloads log IP addresses -- **Soft Deletes**: Files marked deleted but retained for audit -- **Staff-Only Downloads**: File downloads only via authenticated admin ## Dependencies @@ -210,63 +177,30 @@ cd src && DJANGO_SETTINGS_MODULE=config.settings uv run mypy dataroom/ myapp/ co - Install with `uv sync` or `uv sync --extra dev` ### Core Dependencies -- Django 5.2.5 +- Django 5.2.3 - Python 3.12 - PostgreSQL 16 -- django-allauth (authentication with MFA and SSO support) -- django-allauth-require2fa (2FA enforcement) -- django-solo (singleton models) -- Tailwind CSS (via Play CDN - no build process required) -- Heroicons (SVG icon library) +- django-allauth (authentication) +- django-bootstrap5 (UI) +- django-storages (S3 support) ### Development Dependencies - ruff (linting/formatting) - pre-commit (hooks) -- mypy + django-stubs (type checking) -- bandit (security scanning) -- radon (complexity analysis) -- vulture (dead code detection) ## Environment Variables Key environment variables (defined in `env` file): - `DEBUG`: Development mode flag - `SECRET_KEY`: Django secret key -- `BASE_URL`: Application base URL (used for upload URL generation) -- `DATABASE_URL`: PostgreSQL connection string -- `EMAIL_URL`: Email backend configuration (console, SMTP, etc.) - -## File Storage - -### Structure -``` -src/media/ - uploads/ - {endpoint-uuid}/ - filename.ext - filename-20250117143022-1.ext # Duplicate with timestamp -``` - -### Handling -- Files stored in MEDIA_ROOT (`src/media/`) -- Organized by endpoint UUID for isolation -- Duplicate filenames auto-renamed with timestamp -- Soft deletes keep files on disk for audit/recovery +- `BASE_URL`: Application base URL +- `DATABASE_URL`: PostgreSQL connection +- `AWS_*`: S3 configuration +- Email settings for django-allauth ## Deployment -- Docker-based deployment ready -- Heroku/Dokku compatible with `Procfile` -- Static files served via WhiteNoise -- File uploads served securely via Django (for staff only) -- Uses environment variables for all configuration -- Database migrations handled via release phase - -## Future Enhancements - -- SSO integration with Okta (django-allauth is already SSO-ready) -- File size limits and validation -- Virus scanning integration -- Automated file expiration/archival -- Email notifications for uploads -- Download links for customers (with expiration) +- Docker-based deployment +- Heroku/Dokku ready with `Procfile` +- Static files served by Django or S3 +- Uses environment variables for configuration diff --git a/Makefile b/Makefile index ae09e49..ebb76d7 100644 --- a/Makefile +++ b/Makefile @@ -59,14 +59,6 @@ snapshot-local-db: ## Create a snapshot of the local database restore-local-db: ## Restore the local database from a snapshot docker compose exec -T postgres pg_restore -U postgres -d django_reference < django_reference.dump -logs/: - mkdir -p logs/ - -.PHONY: runserver -runserver: logs/ ## Run Django development server with logging to logs/server.log - @echo "Starting Django server on http://0.0.0.0:8008 (logs: logs/server.log)" - uv run src/manage.py runserver 0.0.0.0:8008 2>&1 | tee logs/server.log - ########################################################################## # DJANGO-ALLAUTH DEPENDENCY MANAGEMENT ########################################################################## diff --git a/pyproject.toml b/pyproject.toml index 30bbbff..5b8197a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "charset-normalizer==3.4.3", "django-environ==0.12.0", "Django==5.2.5", + "django-bootstrap5==25.2", "django-solo==2.4.0", "gunicorn==23.0.0", "idna==3.10", diff --git a/src/config/settings.py b/src/config/settings.py index 01d4a70..6357d78 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -25,6 +25,14 @@ CSRF_TRUSTED_ORIGINS = [BASE_URL] +# CONFIGURATION for django-storages +AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID") +AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY") +AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") +AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME") +AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL") +AWS_S3_USE_SSL = env("AWS_S3_USE_SSL") + ALLOWED_HOSTS = ["*"] INSTALLED_APPS = [ @@ -35,7 +43,8 @@ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.sites", - "dataroom", + "django_bootstrap5", + "organizations", "myapp", "require2fa", "allauth", @@ -186,4 +195,4 @@ }, } -LOGIN_REDIRECT_URL = "/admin/" +LOGIN_REDIRECT_URL = "/accounts/email/" diff --git a/src/config/urls.py b/src/config/urls.py index 8582e59..1170f52 100644 --- a/src/config/urls.py +++ b/src/config/urls.py @@ -9,6 +9,8 @@ import myapp.views +# import urls from the organizations app + urlpatterns = [ # noqa: RUF005 path( "robots.txt", @@ -19,7 +21,10 @@ path("accounts/", include("allauth.urls")), path("", myapp.views.index, name="home"), path("health-check/", myapp.views.health_check, name="health-check"), - path("", include("dataroom.urls")), + path( + "organizations/", + include(("organizations.urls", "organizations"), namespace="organizations"), + ), # add privacy policy and terms of service URLs here use TemplateView.as_view path("privacy/", TemplateView.as_view(template_name="privacy.html"), name="privacy"), path("terms/", TemplateView.as_view(template_name="terms.html"), name="terms"), diff --git a/src/dataroom/admin.py b/src/dataroom/admin.py deleted file mode 100644 index e812d8a..0000000 --- a/src/dataroom/admin.py +++ /dev/null @@ -1,381 +0,0 @@ -"""Admin configuration for dataroom models.""" - -import io -import os -import re -import zipfile -from datetime import datetime - -from django.conf import settings -from django.contrib import admin -from django.http import FileResponse, HttpRequest, HttpResponse -from django.utils.html import format_html -from django.utils.safestring import mark_safe - -from .models import BulkDownload, Customer, DataEndpoint, FileDownload, UploadedFile - - -class DataEndpointInline(admin.TabularInline): - """Inline admin for data endpoints.""" - - model = DataEndpoint - extra = 0 - fields = ("name", "status", "created_at", "copy_url_button") - readonly_fields = ("created_at", "copy_url_button") - can_delete = False - - def copy_url_button(self, obj: DataEndpoint) -> str: - """Display a button to copy the upload URL.""" - if obj.pk: - url = f"{settings.BASE_URL}/upload/{obj.id}/" - return format_html( - '', - url, - ) - return "-" - - copy_url_button.short_description = "Upload URL" # type: ignore[attr-defined] - - -@admin.register(Customer) -class CustomerAdmin(admin.ModelAdmin): - """Admin for Customer model.""" - - list_display = ("name", "created_by", "created_at", "endpoint_count") - list_filter = ("created_at", "created_by") - search_fields = ("name", "notes") - readonly_fields = ("created_at",) - inlines = [DataEndpointInline] - - fieldsets = ( - (None, {"fields": ("name", "notes")}), - ("Metadata", {"fields": ("created_by", "created_at")}), - ) - - def endpoint_count(self, obj: Customer) -> int: - """Show number of endpoints for this customer.""" - return obj.endpoints.count() - - endpoint_count.short_description = "Endpoints" # type: ignore[attr-defined] - - def save_model(self, request: HttpRequest, obj: Customer, form, change: bool) -> None: # type: ignore[no-untyped-def] - """Set created_by to current user if creating new customer.""" - if not change: # Only set on creation - obj.created_by = request.user - super().save_model(request, obj, form, change) - - -class UploadedFileInline(admin.TabularInline): - """Inline admin for uploaded files.""" - - model = UploadedFile - extra = 0 - fields = ("filename", "file_size_display", "uploaded_at", "is_deleted_display") - readonly_fields = ("filename", "file_size_display", "uploaded_at", "is_deleted_display") - can_delete = False - - def file_size_display(self, obj: UploadedFile) -> str: - """Display file size in human-readable format.""" - size = obj.file_size_bytes - for unit in ["B", "KB", "MB", "GB"]: - if size < 1024.0: - return f"{size:.1f} {unit}" - size /= 1024.0 - return f"{size:.1f} TB" - - file_size_display.short_description = "Size" # type: ignore[attr-defined] - - def is_deleted_display(self, obj: UploadedFile) -> str: - """Display deletion status.""" - if obj.is_deleted: - return format_html('Deleted') - return format_html('Active') - - is_deleted_display.short_description = "Status" # type: ignore[attr-defined] - - -@admin.register(DataEndpoint) -class DataEndpointAdmin(admin.ModelAdmin): - """Admin for DataEndpoint model.""" - - list_display = ("name", "customer", "status", "created_by", "created_at", "file_count", "upload_url_link") - list_filter = ("status", "created_at", "created_by") - search_fields = ("name", "customer__name", "description") - readonly_fields = ("id", "created_at", "upload_url_display") - inlines = [UploadedFileInline] - actions = ["download_endpoint_as_zip"] - - fieldsets = ( - (None, {"fields": ("customer", "name", "description", "status")}), - ("Upload Information", {"fields": ("id", "upload_url_display")}), - ("Metadata", {"fields": ("created_by", "created_at")}), - ) - - def file_count(self, obj: DataEndpoint) -> int: - """Show number of files for this endpoint.""" - return obj.files.filter(deleted_at__isnull=True).count() - - file_count.short_description = "Active Files" # type: ignore[attr-defined] - - def upload_url_display(self, obj: DataEndpoint) -> str: - """Display the full upload URL with copy button.""" - if obj.pk: - url = f"{settings.BASE_URL}/upload/{obj.id}/" - return format_html( - '
{} ' - '
', - url, - url, - url, - ) - return "-" - - upload_url_display.short_description = "Upload URL" # type: ignore[attr-defined] - - def upload_url_link(self, obj: DataEndpoint) -> str: - """Show clickable link in list view.""" - if obj.pk: - url = f"/upload/{obj.id}/" - return format_html('View Upload Page', url) - return "-" - - upload_url_link.short_description = "Upload Page" # type: ignore[attr-defined] - - def save_model(self, request: HttpRequest, obj: DataEndpoint, form, change: bool) -> None: # type: ignore[no-untyped-def] - """Set created_by to current user if creating new endpoint.""" - if not change: # Only set on creation - obj.created_by = request.user - super().save_model(request, obj, form, change) - - @admin.action(description="Download all files as ZIP") - def download_endpoint_as_zip(self, request: HttpRequest, queryset) -> HttpResponse: # type: ignore[no-untyped-def] - """Download all files from selected endpoint as a zip file.""" - if queryset.count() != 1: - self.message_user(request, "Please select exactly one endpoint to download.", level="error") - return HttpResponse() - - endpoint = queryset.first() - - # Get all non-deleted files for this endpoint - files = endpoint.files.filter(deleted_at__isnull=True).order_by("filename") - - # Check if there are any files to download - if not files.exists(): - self.message_user(request, "No files available to download for this endpoint.", level="warning") - return HttpResponse() - - # Get client IP - x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") - if x_forwarded_for: - ip_address = x_forwarded_for.split(",")[0] - else: - ip_address = request.META.get("REMOTE_ADDR") - - # Calculate total size - total_bytes = sum(f.file_size_bytes for f in files) - - # Create zip filename - timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") - customer_name_clean = re.sub(r"[^\w\-]", "_", endpoint.customer.name) - endpoint_name_clean = re.sub(r"[^\w\-]", "_", endpoint.name) - zip_filename = f"{customer_name_clean}-{endpoint_name_clean}-{timestamp}.zip" - - # Create in-memory buffer for zip file - buffer = io.BytesIO() - - # Create zip file - with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: - for uploaded_file in files: - # Construct full file path - file_path = os.path.join(settings.MEDIA_ROOT, uploaded_file.file_path) - - # Check if file exists on disk - if os.path.exists(file_path): - # Add file to zip with original filename - zip_file.write(file_path, uploaded_file.filename) - - # Create individual FileDownload audit record - FileDownload.objects.create( - file=uploaded_file, - downloaded_by=request.user, - ip_address=ip_address, - ) - - # Create BulkDownload audit record - BulkDownload.objects.create( - endpoint=endpoint, - downloaded_by=request.user, - ip_address=ip_address, - file_count=files.count(), - total_bytes=total_bytes, - ) - - # Show success message to user - self.message_user( - request, - f"Downloaded {files.count()} files from {endpoint.name}", - level="success", - ) - - # Get zip content - zip_content = buffer.getvalue() - - # Create response - response = HttpResponse(zip_content, content_type="application/zip") - response["Content-Disposition"] = f'attachment; filename="{zip_filename}"' - response["Content-Length"] = len(zip_content) - - return response - - -@admin.register(UploadedFile) -class UploadedFileAdmin(admin.ModelAdmin): - """Admin for UploadedFile model.""" - - list_display = ( - "filename", - "endpoint", - "file_size_display", - "uploaded_at", - "is_deleted_display", - "download_count", - ) - list_filter = ("uploaded_at", "deleted_at", "endpoint__customer", "endpoint") - search_fields = ("filename", "endpoint__name", "endpoint__customer__name") - readonly_fields = ( - "filename", - "file_path", - "file_size_bytes", - "content_type", - "uploaded_at", - "uploaded_by_ip", - "deleted_at", - "deleted_by_ip", - ) - actions = ["download_file"] - - fieldsets = ( - (None, {"fields": ("endpoint", "filename", "file_size_bytes", "content_type")}), - ("Upload Information", {"fields": ("uploaded_at", "uploaded_by_ip", "file_path")}), - ("Deletion Information", {"fields": ("deleted_at", "deleted_by_ip")}), - ) - - def file_size_display(self, obj: UploadedFile) -> str: - """Display file size in human-readable format.""" - size = obj.file_size_bytes - for unit in ["B", "KB", "MB", "GB"]: - if size < 1024.0: - return f"{size:.1f} {unit}" - size /= 1024.0 - return f"{size:.1f} TB" - - file_size_display.short_description = "Size" # type: ignore[attr-defined] - file_size_display.admin_order_field = "file_size_bytes" # type: ignore[attr-defined] - - def is_deleted_display(self, obj: UploadedFile) -> str: - """Display deletion status with color.""" - if obj.is_deleted: - return format_html('Deleted') - return format_html('Active') - - is_deleted_display.short_description = "Status" # type: ignore[attr-defined] - - def download_count(self, obj: UploadedFile) -> int: - """Show number of times file has been downloaded.""" - return obj.downloads.count() - - download_count.short_description = "Downloads" # type: ignore[attr-defined] - - @admin.action(description="Download selected files") - def download_file(self, request: HttpRequest, queryset) -> HttpResponse: # type: ignore[no-untyped-def] - """Download the selected file and log the download.""" - if queryset.count() != 1: - self.message_user(request, "Please select exactly one file to download.", level="error") - return HttpResponse() - - uploaded_file = queryset.first() - - # Build full file path - file_path = os.path.join(settings.MEDIA_ROOT, uploaded_file.file_path) - - if not os.path.exists(file_path): - self.message_user(request, f"File not found: {uploaded_file.filename}", level="error") - return HttpResponse() - - # Get client IP - x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") - if x_forwarded_for: - ip_address = x_forwarded_for.split(",")[0] - else: - ip_address = request.META.get("REMOTE_ADDR") - - # Log the download - FileDownload.objects.create( - file=uploaded_file, - downloaded_by=request.user, - ip_address=ip_address, - ) - - # Serve the file - response = FileResponse(open(file_path, "rb"), as_attachment=True, filename=uploaded_file.filename) - return response - - -@admin.register(FileDownload) -class FileDownloadAdmin(admin.ModelAdmin): - """Admin for FileDownload model (read-only audit log).""" - - list_display = ("file", "downloaded_by", "downloaded_at", "ip_address") - list_filter = ("downloaded_at", "downloaded_by") - search_fields = ("file__filename", "downloaded_by__email", "ip_address") - readonly_fields = ("file", "downloaded_by", "downloaded_at", "ip_address") - - def has_add_permission(self, request: HttpRequest) -> bool: - """Prevent manual creation of download logs.""" - return False - - def has_delete_permission(self, request: HttpRequest, obj=None) -> bool: # type: ignore[no-untyped-def] - """Prevent deletion of audit logs.""" - return False - - def has_change_permission(self, request: HttpRequest, obj=None) -> bool: # type: ignore[no-untyped-def] - """Make this read-only.""" - return False - - -@admin.register(BulkDownload) -class BulkDownloadAdmin(admin.ModelAdmin): - """Admin for BulkDownload model (read-only audit log).""" - - list_display = ("endpoint", "downloaded_by", "downloaded_at", "file_count", "total_size_display", "ip_address") - list_filter = ("downloaded_at", "downloaded_by", "endpoint__customer") - search_fields = ("endpoint__name", "endpoint__customer__name", "downloaded_by__email", "ip_address") - readonly_fields = ("endpoint", "downloaded_by", "downloaded_at", "file_count", "total_bytes", "ip_address") - - def total_size_display(self, obj: BulkDownload) -> str: - """Display total size in human-readable format.""" - size = obj.total_bytes - for unit in ["B", "KB", "MB", "GB"]: - if size < 1024.0: - return f"{size:.1f} {unit}" - size /= 1024.0 - return f"{size:.1f} TB" - - total_size_display.short_description = "Total Size" # type: ignore[attr-defined] - total_size_display.admin_order_field = "total_bytes" # type: ignore[attr-defined] - - def has_add_permission(self, request: HttpRequest) -> bool: - """Prevent manual creation of bulk download logs.""" - return False - - def has_delete_permission(self, request: HttpRequest, obj=None) -> bool: # type: ignore[no-untyped-def] - """Prevent deletion of audit logs.""" - return False - - def has_change_permission(self, request: HttpRequest, obj=None) -> bool: # type: ignore[no-untyped-def] - """Make this read-only.""" - return False diff --git a/src/dataroom/apps.py b/src/dataroom/apps.py deleted file mode 100644 index 893e202..0000000 --- a/src/dataroom/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class DataroomConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'dataroom' diff --git a/src/dataroom/migrations/0001_initial.py b/src/dataroom/migrations/0001_initial.py deleted file mode 100644 index 8c5d14d..0000000 --- a/src/dataroom/migrations/0001_initial.py +++ /dev/null @@ -1,85 +0,0 @@ -# Generated by Django 5.2.5 on 2025-11-17 20:20 - -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Customer', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='Company or project name', max_length=255)), - ('notes', models.TextField(blank=True, default='', help_text='Freeform notes (contacts, emails, project details, etc.)')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customers_created', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'verbose_name': 'Customer', - 'verbose_name_plural': 'Customers', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='DataEndpoint', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(help_text="e.g., 'Q1 POC Upload', 'Phase 2 Data'", max_length=255)), - ('description', models.TextField(blank=True, default='')), - ('status', models.CharField(choices=[('active', 'Active'), ('disabled', 'Disabled'), ('archived', 'Archived')], default='active', max_length=20)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='endpoints_created', to=settings.AUTH_USER_MODEL)), - ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='endpoints', to='dataroom.customer')), - ], - options={ - 'verbose_name': 'Data Endpoint', - 'verbose_name_plural': 'Data Endpoints', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='UploadedFile', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('filename', models.CharField(max_length=255)), - ('file_path', models.CharField(help_text='Relative path in MEDIA_ROOT', max_length=500)), - ('file_size_bytes', models.BigIntegerField()), - ('content_type', models.CharField(blank=True, default='', max_length=255)), - ('uploaded_at', models.DateTimeField(auto_now_add=True)), - ('uploaded_by_ip', models.GenericIPAddressField(blank=True, null=True)), - ('deleted_at', models.DateTimeField(blank=True, null=True)), - ('deleted_by_ip', models.GenericIPAddressField(blank=True, null=True)), - ('endpoint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='dataroom.dataendpoint')), - ], - options={ - 'verbose_name': 'Uploaded File', - 'verbose_name_plural': 'Uploaded Files', - 'ordering': ['-uploaded_at'], - }, - ), - migrations.CreateModel( - name='FileDownload', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('downloaded_at', models.DateTimeField(auto_now_add=True)), - ('ip_address', models.GenericIPAddressField(blank=True, null=True)), - ('downloaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='file_downloads', to=settings.AUTH_USER_MODEL)), - ('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downloads', to='dataroom.uploadedfile')), - ], - options={ - 'verbose_name': 'File Download', - 'verbose_name_plural': 'File Downloads', - 'ordering': ['-downloaded_at'], - }, - ), - ] diff --git a/src/dataroom/migrations/0002_bulkdownload.py b/src/dataroom/migrations/0002_bulkdownload.py deleted file mode 100644 index 39f135d..0000000 --- a/src/dataroom/migrations/0002_bulkdownload.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.2.5 on 2025-11-18 14:40 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dataroom', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='BulkDownload', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('downloaded_at', models.DateTimeField(auto_now_add=True)), - ('ip_address', models.GenericIPAddressField(blank=True, null=True)), - ('file_count', models.IntegerField(help_text='Number of files included in the zip')), - ('total_bytes', models.BigIntegerField(help_text='Total size of all files in bytes')), - ('downloaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bulk_downloads', to=settings.AUTH_USER_MODEL)), - ('endpoint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_downloads', to='dataroom.dataendpoint')), - ], - options={ - 'verbose_name': 'Bulk Download', - 'verbose_name_plural': 'Bulk Downloads', - 'ordering': ['-downloaded_at'], - }, - ), - ] diff --git a/src/dataroom/models.py b/src/dataroom/models.py deleted file mode 100644 index c2e84c6..0000000 --- a/src/dataroom/models.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Models for the dataroom app.""" - -import uuid - -from django.conf import settings -from django.db import models - - -class Customer(models.Model): - """Customer model for tracking data room customers.""" - - name = models.CharField(max_length=255, help_text="Company or project name") - notes = models.TextField( - blank=True, - default="", - help_text="Freeform notes (contacts, emails, project details, etc.)", - ) - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name="customers_created", - ) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - """Meta options for Customer model.""" - - ordering = ["-created_at"] - verbose_name = "Customer" - verbose_name_plural = "Customers" - - def __str__(self) -> str: - """Return string representation.""" - return self.name - - -class DataEndpoint(models.Model): - """Data endpoint for file uploads - identified by UUID for privacy.""" - - STATUS_ACTIVE = "active" - STATUS_DISABLED = "disabled" - STATUS_ARCHIVED = "archived" - - STATUS_CHOICES = [ - (STATUS_ACTIVE, "Active"), - (STATUS_DISABLED, "Disabled"), - (STATUS_ARCHIVED, "Archived"), - ] - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - customer = models.ForeignKey( - Customer, - on_delete=models.CASCADE, - related_name="endpoints", - ) - name = models.CharField(max_length=255, help_text="e.g., 'Q1 POC Upload', 'Phase 2 Data'") - description = models.TextField(blank=True, default="") - status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default=STATUS_ACTIVE, - ) - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name="endpoints_created", - ) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - """Meta options for DataEndpoint model.""" - - ordering = ["-created_at"] - verbose_name = "Data Endpoint" - verbose_name_plural = "Data Endpoints" - - def __str__(self) -> str: - """Return string representation.""" - return f"{self.customer.name} - {self.name}" - - def get_upload_url(self) -> str: - """Return the upload URL for this endpoint.""" - return f"/upload/{self.id}/" - - -class UploadedFile(models.Model): - """File uploaded to a data endpoint.""" - - endpoint = models.ForeignKey( - DataEndpoint, - on_delete=models.CASCADE, - related_name="files", - ) - filename = models.CharField(max_length=255) - file_path = models.CharField(max_length=500, help_text="Relative path in MEDIA_ROOT") - file_size_bytes = models.BigIntegerField() - content_type = models.CharField(max_length=255, blank=True, default="") - uploaded_at = models.DateTimeField(auto_now_add=True) - uploaded_by_ip = models.GenericIPAddressField(null=True, blank=True) - - # Soft delete fields - deleted_at = models.DateTimeField(null=True, blank=True) - deleted_by_ip = models.GenericIPAddressField(null=True, blank=True) - - class Meta: - """Meta options for UploadedFile model.""" - - ordering = ["-uploaded_at"] - verbose_name = "Uploaded File" - verbose_name_plural = "Uploaded Files" - - def __str__(self) -> str: - """Return string representation.""" - return f"{self.filename} ({self.endpoint.name})" - - @property - def is_deleted(self) -> bool: - """Check if file is soft-deleted.""" - return self.deleted_at is not None - - -class FileDownload(models.Model): - """Audit log for file downloads by internal staff.""" - - file = models.ForeignKey( - UploadedFile, - on_delete=models.CASCADE, - related_name="downloads", - ) - downloaded_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name="file_downloads", - ) - downloaded_at = models.DateTimeField(auto_now_add=True) - ip_address = models.GenericIPAddressField(null=True, blank=True) - - class Meta: - """Meta options for FileDownload model.""" - - ordering = ["-downloaded_at"] - verbose_name = "File Download" - verbose_name_plural = "File Downloads" - - def __str__(self) -> str: - """Return string representation.""" - user_str = self.downloaded_by.email if self.downloaded_by else "Unknown" - return f"{self.file.filename} by {user_str} at {self.downloaded_at}" - - -class BulkDownload(models.Model): - """Audit log for bulk downloads (zip files) of entire endpoints.""" - - endpoint = models.ForeignKey( - DataEndpoint, - on_delete=models.CASCADE, - related_name="bulk_downloads", - ) - downloaded_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name="bulk_downloads", - ) - downloaded_at = models.DateTimeField(auto_now_add=True) - ip_address = models.GenericIPAddressField(null=True, blank=True) - file_count = models.IntegerField(help_text="Number of files included in the zip") - total_bytes = models.BigIntegerField(help_text="Total size of all files in bytes") - - class Meta: - """Meta options for BulkDownload model.""" - - ordering = ["-downloaded_at"] - verbose_name = "Bulk Download" - verbose_name_plural = "Bulk Downloads" - - def __str__(self) -> str: - """Return string representation.""" - user_str = self.downloaded_by.email if self.downloaded_by else "Unknown" - return f"{self.endpoint} ({self.file_count} files) by {user_str} at {self.downloaded_at}" diff --git a/src/dataroom/templates/dataroom/upload_archived.html b/src/dataroom/templates/dataroom/upload_archived.html deleted file mode 100644 index 9672093..0000000 --- a/src/dataroom/templates/dataroom/upload_archived.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - Upload Archived - - - -
- - - -

Upload Archived

-

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

-
- - diff --git a/src/dataroom/templates/dataroom/upload_disabled.html b/src/dataroom/templates/dataroom/upload_disabled.html deleted file mode 100644 index b2df4e4..0000000 --- a/src/dataroom/templates/dataroom/upload_disabled.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - Upload Disabled - - - -
- - - -

Upload Disabled

-

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

-
- - diff --git a/src/dataroom/templates/dataroom/upload_page.html b/src/dataroom/templates/dataroom/upload_page.html deleted file mode 100644 index d30db98..0000000 --- a/src/dataroom/templates/dataroom/upload_page.html +++ /dev/null @@ -1,269 +0,0 @@ - - - - - - Upload Files - - - - - - - - -
-
-

File Upload

-

{{ endpoint.name }}

-
- - {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} - - -
- - -
-
-
-
- - -
-
-

Uploaded Files ({{ files.count }})

- {% if user.is_authenticated and user.is_staff and files %} - - - - - Download All as Zip - - {% endif %} -
-
- {% if files %} - {% for file in files %} -
-
-
{{ file.filename }}
-
- Uploaded {{ file.uploaded_at|date:"F d, Y g:i A" }} • {{ file.file_size_bytes|filesizeformat }} -
-
-
- {% csrf_token %} - -
-
- {% endfor %} - {% else %} -
-

No files uploaded yet

-
- {% endif %} -
-
-
- - - - - - - diff --git a/src/dataroom/tests.py b/src/dataroom/tests.py deleted file mode 100644 index b6af12e..0000000 --- a/src/dataroom/tests.py +++ /dev/null @@ -1,537 +0,0 @@ -"""Tests for dataroom app.""" - -import os -import zipfile -from io import BytesIO - -from django.conf import settings -from django.contrib.auth import get_user_model -from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import Client, TestCase -from django.urls import reverse -from django.utils import timezone - -from require2fa.models import TwoFactorConfig - -from .models import BulkDownload, Customer, DataEndpoint, FileDownload, UploadedFile - -User = get_user_model() - - -class CustomerModelTests(TestCase): - """Tests for Customer model.""" - - def setUp(self) -> None: - """Set up test data.""" - self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") - - def test_customer_creation(self) -> None: - """Test customer can be created.""" - customer = Customer.objects.create( - name="Test Corp", - notes="Test notes", - created_by=self.user, - ) - self.assertEqual(customer.name, "Test Corp") - self.assertEqual(str(customer), "Test Corp") - - def test_customer_endpoints_relationship(self) -> None: - """Test customer can have multiple endpoints.""" - customer = Customer.objects.create(name="Test Corp", created_by=self.user) - endpoint1 = DataEndpoint.objects.create( - customer=customer, - name="Endpoint 1", - created_by=self.user, - ) - endpoint2 = DataEndpoint.objects.create( - customer=customer, - name="Endpoint 2", - created_by=self.user, - ) - self.assertEqual(customer.endpoints.count(), 2) - - -class DataEndpointModelTests(TestCase): - """Tests for DataEndpoint model.""" - - def setUp(self) -> None: - """Set up test data.""" - self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") - self.customer = Customer.objects.create(name="Test Corp", created_by=self.user) - - def test_endpoint_creation(self) -> None: - """Test endpoint can be created.""" - endpoint = DataEndpoint.objects.create( - customer=self.customer, - name="Q1 POC", - description="Test description", - created_by=self.user, - ) - self.assertEqual(endpoint.name, "Q1 POC") - self.assertEqual(endpoint.status, DataEndpoint.STATUS_ACTIVE) - self.assertIsNotNone(endpoint.id) # UUID should be auto-generated - - def test_endpoint_str(self) -> None: - """Test endpoint string representation.""" - endpoint = DataEndpoint.objects.create( - customer=self.customer, - name="Test Endpoint", - created_by=self.user, - ) - expected = f"{self.customer.name} - Test Endpoint" - self.assertEqual(str(endpoint), expected) - - def test_get_upload_url(self) -> None: - """Test get_upload_url method.""" - endpoint = DataEndpoint.objects.create( - customer=self.customer, - name="Test", - created_by=self.user, - ) - expected_url = f"/upload/{endpoint.id}/" - self.assertEqual(endpoint.get_upload_url(), expected_url) - - -class UploadedFileModelTests(TestCase): - """Tests for UploadedFile model.""" - - def setUp(self) -> None: - """Set up test data.""" - self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") - self.customer = Customer.objects.create(name="Test Corp", created_by=self.user) - self.endpoint = DataEndpoint.objects.create( - customer=self.customer, - name="Test Endpoint", - created_by=self.user, - ) - - def test_uploaded_file_creation(self) -> None: - """Test file can be created.""" - uploaded_file = UploadedFile.objects.create( - endpoint=self.endpoint, - filename="test.txt", - file_path="uploads/test/test.txt", - file_size_bytes=1024, - content_type="text/plain", - ) - self.assertEqual(uploaded_file.filename, "test.txt") - self.assertFalse(uploaded_file.is_deleted) - - def test_soft_delete(self) -> None: - """Test soft delete functionality.""" - uploaded_file = UploadedFile.objects.create( - endpoint=self.endpoint, - filename="test.txt", - file_path="uploads/test/test.txt", - file_size_bytes=1024, - ) - self.assertFalse(uploaded_file.is_deleted) - - # Soft delete - uploaded_file.deleted_at = timezone.now() - uploaded_file.save() - self.assertTrue(uploaded_file.is_deleted) - - -class FileDownloadModelTests(TestCase): - """Tests for FileDownload model.""" - - def setUp(self) -> None: - """Set up test data.""" - self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") - self.customer = Customer.objects.create(name="Test Corp", created_by=self.user) - self.endpoint = DataEndpoint.objects.create( - customer=self.customer, - name="Test Endpoint", - created_by=self.user, - ) - self.uploaded_file = UploadedFile.objects.create( - endpoint=self.endpoint, - filename="test.txt", - file_path="uploads/test/test.txt", - file_size_bytes=1024, - ) - - def test_file_download_creation(self) -> None: - """Test download log can be created.""" - download = FileDownload.objects.create( - file=self.uploaded_file, - downloaded_by=self.user, - ip_address="127.0.0.1", - ) - self.assertEqual(download.file, self.uploaded_file) - self.assertEqual(download.downloaded_by, self.user) - - -class UploadViewTests(TestCase): - """Tests for upload views.""" - - def setUp(self) -> None: - """Set up test data.""" - self.client = Client() - self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") - self.customer = Customer.objects.create(name="Test Corp", created_by=self.user) - self.endpoint = DataEndpoint.objects.create( - customer=self.customer, - name="Test Endpoint", - created_by=self.user, - ) - - def test_upload_page_get(self) -> None: - """Test upload page can be accessed.""" - url = reverse("dataroom:upload_page", kwargs={"endpoint_id": self.endpoint.id}) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Test Endpoint") - - def test_upload_page_disabled_endpoint(self) -> None: - """Test disabled endpoint shows correct message.""" - self.endpoint.status = DataEndpoint.STATUS_DISABLED - self.endpoint.save() - - url = reverse("dataroom:upload_page", kwargs={"endpoint_id": self.endpoint.id}) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Upload Disabled") - - def test_upload_page_archived_endpoint(self) -> None: - """Test archived endpoint shows correct message.""" - self.endpoint.status = DataEndpoint.STATUS_ARCHIVED - self.endpoint.save() - - url = reverse("dataroom:upload_page", kwargs={"endpoint_id": self.endpoint.id}) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Upload Archived") - - def test_file_upload(self) -> None: - """Test file can be uploaded via AJAX endpoint.""" - url = reverse("dataroom:ajax_upload", kwargs={"endpoint_id": self.endpoint.id}) - - # Create a simple uploaded file - file_content = b"Test file content" - uploaded_file = SimpleUploadedFile("test.txt", file_content, content_type="text/plain") - - response = self.client.post(url, {"file": uploaded_file}) - - # Should return 201 Created with JSON response - self.assertEqual(response.status_code, 201) - - # Check JSON response structure - response_data = response.json() - self.assertTrue(response_data["success"]) - self.assertIn("file", response_data) - self.assertEqual(response_data["file"]["filename"], "test.txt") - self.assertIn("id", response_data["file"]) - self.assertIn("size", response_data["file"]) - self.assertIn("uploaded_at", response_data["file"]) - - # Check file was created in database - self.assertEqual(UploadedFile.objects.count(), 1) - db_file = UploadedFile.objects.first() - self.assertIsNotNone(db_file) - self.assertEqual(db_file.endpoint, self.endpoint) - self.assertEqual(db_file.filename, "test.txt") - - # Clean up created file - if db_file: - file_path = os.path.join(settings.MEDIA_ROOT, db_file.file_path) - if os.path.exists(file_path): - os.remove(file_path) - - def test_file_delete(self) -> None: - """Test file can be deleted.""" - # Create a file - uploaded_file = UploadedFile.objects.create( - endpoint=self.endpoint, - filename="test.txt", - file_path="uploads/test/test.txt", - file_size_bytes=1024, - ) - - url = reverse("dataroom:delete_file", kwargs={"endpoint_id": self.endpoint.id, "file_id": uploaded_file.id}) - response = self.client.post(url) - - # Should redirect after deletion - self.assertEqual(response.status_code, 302) - - # Check file was soft deleted - uploaded_file.refresh_from_db() - self.assertTrue(uploaded_file.is_deleted) - self.assertIsNotNone(uploaded_file.deleted_at) - - def test_ajax_upload_disabled_endpoint(self) -> None: - """Test AJAX upload to disabled endpoint returns 403.""" - self.endpoint.status = DataEndpoint.STATUS_DISABLED - self.endpoint.save() - - url = reverse("dataroom:ajax_upload", kwargs={"endpoint_id": self.endpoint.id}) - file_content = b"Test file content" - uploaded_file = SimpleUploadedFile("test.txt", file_content, content_type="text/plain") - - response = self.client.post(url, {"file": uploaded_file}) - - # Should return 403 Forbidden - self.assertEqual(response.status_code, 403) - response_data = response.json() - self.assertIn("error", response_data) - self.assertIn("disabled", response_data["error"].lower()) - - def test_ajax_upload_archived_endpoint(self) -> None: - """Test AJAX upload to archived endpoint returns 403.""" - self.endpoint.status = DataEndpoint.STATUS_ARCHIVED - self.endpoint.save() - - url = reverse("dataroom:ajax_upload", kwargs={"endpoint_id": self.endpoint.id}) - file_content = b"Test file content" - uploaded_file = SimpleUploadedFile("test.txt", file_content, content_type="text/plain") - - response = self.client.post(url, {"file": uploaded_file}) - - # Should return 403 Forbidden - self.assertEqual(response.status_code, 403) - response_data = response.json() - self.assertIn("error", response_data) - self.assertIn("archived", response_data["error"].lower()) - - def test_ajax_upload_no_file(self) -> None: - """Test AJAX upload without file returns 400.""" - url = reverse("dataroom:ajax_upload", kwargs={"endpoint_id": self.endpoint.id}) - - response = self.client.post(url, {}) - - # Should return 400 Bad Request - self.assertEqual(response.status_code, 400) - response_data = response.json() - self.assertIn("error", response_data) - self.assertIn("no file", response_data["error"].lower()) - - def test_invalid_endpoint_404(self) -> None: - """Test invalid endpoint returns 404.""" - import uuid - - url = reverse("dataroom:upload_page", kwargs={"endpoint_id": uuid.uuid4()}) - response = self.client.get(url) - self.assertEqual(response.status_code, 404) - - -class BulkDownloadModelTests(TestCase): - """Tests for BulkDownload model.""" - - def setUp(self) -> None: - """Set up test data.""" - self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") - self.customer = Customer.objects.create(name="Test Corp", created_by=self.user) - self.endpoint = DataEndpoint.objects.create( - customer=self.customer, - name="Test Endpoint", - created_by=self.user, - ) - - def test_bulk_download_creation(self) -> None: - """Test bulk download log can be created.""" - bulk_download = BulkDownload.objects.create( - endpoint=self.endpoint, - downloaded_by=self.user, - ip_address="127.0.0.1", - file_count=5, - total_bytes=5120, - ) - self.assertEqual(bulk_download.endpoint, self.endpoint) - self.assertEqual(bulk_download.downloaded_by, self.user) - self.assertEqual(bulk_download.file_count, 5) - self.assertEqual(bulk_download.total_bytes, 5120) - - -class BulkDownloadViewTests(TestCase): - """Tests for bulk download functionality.""" - - def setUp(self) -> None: - """Set up test data.""" - # Create TwoFactorConfig for middleware - TwoFactorConfig.objects.create(required=False) - - self.client = Client() - self.staff_user = User.objects.create_user( - username="staffuser", email="staff@example.com", password="testpass123", is_staff=True - ) - self.regular_user = User.objects.create_user( - username="regularuser", email="regular@example.com", password="testpass123", is_staff=False - ) - self.customer = Customer.objects.create(name="Test Corp", created_by=self.staff_user) - self.endpoint = DataEndpoint.objects.create( - customer=self.customer, - name="Test Endpoint", - created_by=self.staff_user, - ) - - def _create_test_file(self, filename: str, content: bytes) -> UploadedFile: - """Helper method to create a test file.""" - # Create directory if it doesn't exist - endpoint_dir = os.path.join(settings.MEDIA_ROOT, "uploads", str(self.endpoint.id)) - os.makedirs(endpoint_dir, exist_ok=True) - - # Write file to disk - file_path = os.path.join(endpoint_dir, filename) - with open(file_path, "wb") as f: - f.write(content) - - # Create database record - relative_path = os.path.join("uploads", str(self.endpoint.id), filename) - uploaded_file = UploadedFile.objects.create( - endpoint=self.endpoint, - filename=filename, - file_path=relative_path, - file_size_bytes=len(content), - content_type="text/plain", - ) - return uploaded_file - - def tearDown(self) -> None: - """Clean up test files.""" - # Clean up all test files - endpoint_dir = os.path.join(settings.MEDIA_ROOT, "uploads", str(self.endpoint.id)) - if os.path.exists(endpoint_dir): - for file in os.listdir(endpoint_dir): - file_path = os.path.join(endpoint_dir, file) - if os.path.isfile(file_path): - os.remove(file_path) - os.rmdir(endpoint_dir) - - def test_bulk_download_with_staff_user(self) -> None: - """Test bulk download with staff user succeeds.""" - # Create test files - file1 = self._create_test_file("test1.txt", b"Content 1") - file2 = self._create_test_file("test2.txt", b"Content 2") - file3 = self._create_test_file("test3.txt", b"Content 3") - - # Login as staff - self.client.login(username="staffuser", password="testpass123") - - # Download zip - url = reverse("dataroom:download_endpoint_zip", kwargs={"endpoint_id": self.endpoint.id}) - response = self.client.get(url) - - # Check response - self.assertEqual(response.status_code, 200) - self.assertEqual(response["Content-Type"], "application/zip") - self.assertIn("attachment", response["Content-Disposition"]) - - # Verify zip contents - zip_content = BytesIO(b"".join(response.streaming_content) if hasattr(response, "streaming_content") else response.content) - with zipfile.ZipFile(zip_content, "r") as zip_file: - names = zip_file.namelist() - self.assertEqual(len(names), 3) - self.assertIn("test1.txt", names) - self.assertIn("test2.txt", names) - self.assertIn("test3.txt", names) - - # Check file contents - self.assertEqual(zip_file.read("test1.txt"), b"Content 1") - self.assertEqual(zip_file.read("test2.txt"), b"Content 2") - self.assertEqual(zip_file.read("test3.txt"), b"Content 3") - - # Verify audit logs - self.assertEqual(BulkDownload.objects.count(), 1) - bulk_download = BulkDownload.objects.first() - self.assertIsNotNone(bulk_download) - self.assertEqual(bulk_download.endpoint, self.endpoint) - self.assertEqual(bulk_download.downloaded_by, self.staff_user) - self.assertEqual(bulk_download.file_count, 3) - - # Verify individual file download logs - self.assertEqual(FileDownload.objects.count(), 3) - self.assertEqual(FileDownload.objects.filter(file=file1).count(), 1) - self.assertEqual(FileDownload.objects.filter(file=file2).count(), 1) - self.assertEqual(FileDownload.objects.filter(file=file3).count(), 1) - - def test_bulk_download_with_non_staff_user(self) -> None: - """Test bulk download with non-staff user is denied.""" - # Create test file - self._create_test_file("test.txt", b"Content") - - # Login as regular user - self.client.login(username="regularuser", password="testpass123") - - # Try to download zip - url = reverse("dataroom:download_endpoint_zip", kwargs={"endpoint_id": self.endpoint.id}) - response = self.client.get(url) - - # Should be denied - self.assertEqual(response.status_code, 403) - self.assertIn(b"Unauthorized", response.content) - - # No audit logs should be created - self.assertEqual(BulkDownload.objects.count(), 0) - self.assertEqual(FileDownload.objects.count(), 0) - - def test_bulk_download_with_anonymous_user(self) -> None: - """Test bulk download with anonymous user redirects to login.""" - # Create test file - self._create_test_file("test.txt", b"Content") - - # Try to download without logging in - url = reverse("dataroom:download_endpoint_zip", kwargs={"endpoint_id": self.endpoint.id}) - response = self.client.get(url) - - # Should redirect to login - self.assertEqual(response.status_code, 302) - self.assertIn("/accounts/login/", response.url) - - # No audit logs should be created - self.assertEqual(BulkDownload.objects.count(), 0) - self.assertEqual(FileDownload.objects.count(), 0) - - def test_bulk_download_empty_endpoint(self) -> None: - """Test bulk download with no files redirects with message.""" - # Login as staff - self.client.login(username="staffuser", password="testpass123") - - # Try to download zip from empty endpoint - url = reverse("dataroom:download_endpoint_zip", kwargs={"endpoint_id": self.endpoint.id}) - response = self.client.get(url) - - # Should redirect back to upload page - self.assertEqual(response.status_code, 302) - - # No audit logs should be created - self.assertEqual(BulkDownload.objects.count(), 0) - self.assertEqual(FileDownload.objects.count(), 0) - - def test_bulk_download_excludes_deleted_files(self) -> None: - """Test bulk download excludes soft-deleted files.""" - # Create test files - file1 = self._create_test_file("test1.txt", b"Content 1") - file2 = self._create_test_file("test2.txt", b"Content 2") - file3 = self._create_test_file("test3.txt", b"Content 3") - - # Soft delete one file - file2.deleted_at = timezone.now() - file2.save() - - # Login as staff - self.client.login(username="staffuser", password="testpass123") - - # Download zip - url = reverse("dataroom:download_endpoint_zip", kwargs={"endpoint_id": self.endpoint.id}) - response = self.client.get(url) - - # Check response - self.assertEqual(response.status_code, 200) - - # Verify zip contents - should only have 2 files - zip_content = BytesIO(b"".join(response.streaming_content) if hasattr(response, "streaming_content") else response.content) - with zipfile.ZipFile(zip_content, "r") as zip_file: - names = zip_file.namelist() - self.assertEqual(len(names), 2) - self.assertIn("test1.txt", names) - self.assertNotIn("test2.txt", names) # Deleted file should be excluded - self.assertIn("test3.txt", names) - - # Verify audit logs - should only count active files - bulk_download = BulkDownload.objects.first() - self.assertIsNotNone(bulk_download) - self.assertEqual(bulk_download.file_count, 2) - - # Only 2 individual file downloads should be logged - self.assertEqual(FileDownload.objects.count(), 2) diff --git a/src/dataroom/urls.py b/src/dataroom/urls.py deleted file mode 100644 index f85f5cd..0000000 --- a/src/dataroom/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -"""URL configuration for dataroom app.""" - -from django.urls import path - -from . import views - -app_name = "dataroom" - -urlpatterns = [ - path("upload//", views.upload_page, name="upload_page"), - path("upload//ajax/", views.ajax_upload, name="ajax_upload"), - path("upload//delete//", views.delete_file, name="delete_file"), - path("upload//download-zip/", views.download_endpoint_zip, name="download_endpoint_zip"), -] diff --git a/src/dataroom/views.py b/src/dataroom/views.py deleted file mode 100644 index 6f1639b..0000000 --- a/src/dataroom/views.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Views for the dataroom app.""" - -import io -import os -import re -import zipfile -from datetime import datetime -from pathlib import Path - -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.utils import timezone -from django.views.decorators.http import require_http_methods - -from .models import BulkDownload, DataEndpoint, FileDownload, UploadedFile - - -def sanitize_filename(filename: str) -> str: - """Sanitize filename to prevent path traversal and other attacks.""" - # Remove any path components - filename = os.path.basename(filename) - - # Remove any non-alphanumeric characters except dots, hyphens, and underscores - filename = re.sub(r"[^\w\.\-]", "_", filename) - - # Prevent hidden files and relative paths - filename = filename.lstrip(".") - - # Ensure filename is not empty - if not filename: - filename = "unnamed_file" - - # Limit filename length - if len(filename) > 255: - name, ext = os.path.splitext(filename) - filename = name[: 255 - len(ext)] + ext - - return filename - - -def get_client_ip(request: HttpRequest) -> str: - """Extract client IP address from request.""" - x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") - if x_forwarded_for: - return x_forwarded_for.split(",")[0] - return request.META.get("REMOTE_ADDR", "") - - -def get_unique_filepath(endpoint_id: str, filename: str) -> tuple[str, str]: - """Generate unique file path to prevent overwrites.""" - # Sanitize filename - clean_filename = sanitize_filename(filename) - - # Create endpoint-specific directory - endpoint_dir = os.path.join("uploads", str(endpoint_id)) - full_dir = os.path.join(settings.MEDIA_ROOT, endpoint_dir) - - # Create directory if it doesn't exist - Path(full_dir).mkdir(parents=True, exist_ok=True) - - # Check if file exists, add timestamp if needed - name, ext = os.path.splitext(clean_filename) - counter = 1 - test_filename = clean_filename - test_path = os.path.join(full_dir, test_filename) - - while os.path.exists(test_path): - # Add timestamp and counter - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - test_filename = f"{name}-{timestamp}-{counter}{ext}" - test_path = os.path.join(full_dir, test_filename) - counter += 1 - - # Return relative path and final filename - relative_path = os.path.join(endpoint_dir, test_filename) - return relative_path, test_filename - - -@require_http_methods(["GET"]) -def upload_page(request: HttpRequest, endpoint_id: str) -> HttpResponse: - """Display upload page for a specific endpoint.""" - # Get endpoint or 404 - endpoint = get_object_or_404(DataEndpoint, id=endpoint_id) - - # Check if endpoint is active - if endpoint.status == DataEndpoint.STATUS_DISABLED: - return render( - request, - "dataroom/upload_disabled.html", - {"endpoint": endpoint}, - ) - - if endpoint.status == DataEndpoint.STATUS_ARCHIVED: - return render( - request, - "dataroom/upload_archived.html", - {"endpoint": endpoint}, - ) - - # Show upload form and file list - # Get all non-deleted files for this endpoint - files = endpoint.files.filter(deleted_at__isnull=True).order_by("-uploaded_at") - - context = { - "endpoint": endpoint, - "files": files, - "customer_name": endpoint.customer.name, - } - - return render(request, "dataroom/upload_page.html", context) - - -@require_http_methods(["POST"]) -def delete_file(request: HttpRequest, endpoint_id: str, file_id: int) -> HttpResponse: - """Handle file deletion request from customer.""" - # Get endpoint or 404 - endpoint = get_object_or_404(DataEndpoint, id=endpoint_id) - - # Get file or 404 - uploaded_file = get_object_or_404(UploadedFile, id=file_id, endpoint=endpoint) - - # Check if already deleted - if uploaded_file.is_deleted: - messages.warning(request, "File is already deleted.") - return redirect("dataroom:upload_page", endpoint_id=endpoint.id) - - # Soft delete the file - uploaded_file.deleted_at = timezone.now() - uploaded_file.deleted_by_ip = get_client_ip(request) - uploaded_file.save() - - messages.success(request, f"File '{uploaded_file.filename}' has been deleted.") - - return redirect("dataroom:upload_page", endpoint_id=endpoint.id) - - -@require_http_methods(["POST"]) -def ajax_upload(request: HttpRequest, endpoint_id: str) -> JsonResponse: - """Handle AJAX file upload from Uppy.""" - try: - # Get endpoint or 404 - endpoint = get_object_or_404(DataEndpoint, id=endpoint_id) - - # Check if endpoint is active - if endpoint.status == DataEndpoint.STATUS_DISABLED: - return JsonResponse({"error": "This upload endpoint is disabled."}, status=403) - - if endpoint.status == DataEndpoint.STATUS_ARCHIVED: - return JsonResponse({"error": "This upload endpoint is archived."}, status=403) - - # Check if file was uploaded - if "file" not in request.FILES: - return JsonResponse({"error": "No file was uploaded."}, status=400) - - uploaded_file = request.FILES["file"] - - # Validate file - if not uploaded_file.name: - return JsonResponse({"error": "Invalid file."}, status=400) - - # Get unique file path - relative_path, final_filename = get_unique_filepath(str(endpoint.id), uploaded_file.name) - full_path = os.path.join(settings.MEDIA_ROOT, relative_path) - - # Save file to disk - with open(full_path, "wb+") as destination: - for chunk in uploaded_file.chunks(): - destination.write(chunk) - - # Get file size - file_size = os.path.getsize(full_path) - - # Get client IP - client_ip = get_client_ip(request) - - # Create database record - file_record = UploadedFile.objects.create( - endpoint=endpoint, - filename=final_filename, - file_path=relative_path, - file_size_bytes=file_size, - content_type=uploaded_file.content_type or "", - uploaded_by_ip=client_ip, - ) - - # Return success response with file details - return JsonResponse( - { - "success": True, - "file": { - "id": file_record.id, - "filename": file_record.filename, - "size": file_record.file_size_bytes, - "uploaded_at": file_record.uploaded_at.isoformat(), - }, - }, - status=201, - ) - - except Exception as e: - # Clean up file if it was saved - if "full_path" in locals() and os.path.exists(full_path): - os.remove(full_path) - - return JsonResponse({"error": f"Error uploading file: {e!s}"}, status=500) - - -@login_required -@require_http_methods(["GET"]) -def download_endpoint_zip(request: HttpRequest, endpoint_id: str) -> HttpResponse: - """Download all files from an endpoint as a zip file (staff only).""" - # Verify user is staff - if not request.user.is_staff: - return HttpResponse("Unauthorized: Staff access required", status=403) - - # Get endpoint or 404 - endpoint = get_object_or_404(DataEndpoint, id=endpoint_id) - - # Get all non-deleted files for this endpoint - files = endpoint.files.filter(deleted_at__isnull=True).order_by("filename") - - # Check if there are any files to download - if not files.exists(): - messages.warning(request, "No files available to download.") - return redirect("dataroom:upload_page", endpoint_id=endpoint.id) - - # Get client IP - client_ip = get_client_ip(request) - - # Calculate total size - total_bytes = sum(f.file_size_bytes for f in files) - - # Create zip filename - # Format: {customer-name}-{endpoint-name}-YYYY-MM-DD-HHMMSS.zip - timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") - customer_name_clean = re.sub(r"[^\w\-]", "_", endpoint.customer.name) - endpoint_name_clean = re.sub(r"[^\w\-]", "_", endpoint.name) - zip_filename = f"{customer_name_clean}-{endpoint_name_clean}-{timestamp}.zip" - - # Create in-memory buffer for zip file - buffer = io.BytesIO() - - # Create zip file - with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: - for uploaded_file in files: - # Construct full file path - file_path = os.path.join(settings.MEDIA_ROOT, uploaded_file.file_path) - - # Check if file exists on disk - if os.path.exists(file_path): - # Add file to zip with original filename - zip_file.write(file_path, uploaded_file.filename) - - # Create individual FileDownload audit record - FileDownload.objects.create( - file=uploaded_file, - downloaded_by=request.user, - ip_address=client_ip, - ) - - # Create BulkDownload audit record - BulkDownload.objects.create( - endpoint=endpoint, - downloaded_by=request.user, - ip_address=client_ip, - file_count=files.count(), - total_bytes=total_bytes, - ) - - # Get zip content - zip_content = buffer.getvalue() - - # Create response - response = HttpResponse(zip_content, content_type="application/zip") - response["Content-Disposition"] = f'attachment; filename="{zip_filename}"' - response["Content-Length"] = len(zip_content) - - return response diff --git a/src/myapp/admin/__init__.py b/src/myapp/admin/__init__.py index e666dc9..abd70f2 100644 --- a/src/myapp/admin/__init__.py +++ b/src/myapp/admin/__init__.py @@ -1,7 +1,11 @@ """Admin module for the myapp app.""" from .site_configuation import SiteConfigurationAdmin +from .worker_configurations import WorkerConfigurationAdmin +from .worker_errors import WorkerErrorAdmin __all__ = [ "SiteConfigurationAdmin", + "WorkerConfigurationAdmin", + "WorkerErrorAdmin", ] diff --git a/src/myapp/admin/worker_configurations.py b/src/myapp/admin/worker_configurations.py new file mode 100644 index 0000000..4a9c02d --- /dev/null +++ b/src/myapp/admin/worker_configurations.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from myapp.models import WorkerConfiguration + + +@admin.register(WorkerConfiguration) +class WorkerConfigurationAdmin(admin.ModelAdmin): + """Admin interface for WorkerConfiguration.""" + + list_display = ( + "name", + "is_enabled", + "sleep_seconds", + "log_level", + ) + list_editable = ( + "is_enabled", + "sleep_seconds", + "log_level", + ) diff --git a/src/myapp/admin/worker_errors.py b/src/myapp/admin/worker_errors.py new file mode 100644 index 0000000..fef1ef5 --- /dev/null +++ b/src/myapp/admin/worker_errors.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from myapp.models import WorkerError + + +@admin.register(WorkerError) +class WorkerErrorAdmin(admin.ModelAdmin): + """Worker error admin.""" + + list_display = ( + "worker", + "created_at", + "error_status", + ) + list_filter = ("error_status",) + search_fields = ("error",) + readonly_fields = ("worker", "error", "created_at") diff --git a/src/myapp/management/__init__.py b/src/myapp/management/__init__.py new file mode 100644 index 0000000..74b0ab4 --- /dev/null +++ b/src/myapp/management/__init__.py @@ -0,0 +1 @@ +"""Module for the management commands.""" diff --git a/src/myapp/management/commands/__init__.py b/src/myapp/management/commands/__init__.py new file mode 100644 index 0000000..73b9072 --- /dev/null +++ b/src/myapp/management/commands/__init__.py @@ -0,0 +1 @@ +"""Commands module.""" diff --git a/src/myapp/management/commands/_base.py b/src/myapp/management/commands/_base.py new file mode 100644 index 0000000..55d2358 --- /dev/null +++ b/src/myapp/management/commands/_base.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import logging +import signal +import time +import traceback + +from django.core.management import BaseCommand +from django.db import transaction + +from myapp.models import WorkerConfiguration, WorkerError + +LOG_LEVELS = { + logging.DEBUG: "DEBUG", + logging.INFO: "INFO", + logging.WARNING: "WARNING", + logging.ERROR: "ERROR", + logging.CRITICAL: "CRITICAL", +} + + +def get_log_level_name(log_level_number: int) -> str: + """Return the log level name.""" + return LOG_LEVELS.get(log_level_number, "UNKNOWN") + + +class BaseWorkerCommand(BaseCommand): + """Base worker command. This should be subclassed.""" + + abstract = True + + help = "UPDATE ME" + + NAME = "UPDATE ME" + + def __init__(self, *args, **kwargs) -> None: # noqa: ANN003, ANN002 + """Initialize the worker.""" + super().__init__(*args, **kwargs) + + if self.help == "UPDATE ME": + msg = "Please update the help string." + raise NotImplementedError(msg) + + if self.NAME == "UPDATE ME": + msg = "Please update the NAME string." + raise NotImplementedError(msg) + + self.config, created = WorkerConfiguration.objects.get_or_create(name=self.NAME) + + if created: + self.logger.critical("WorkerConfiguration created: %s", self.NAME) + + self.current_log_level = self.logger.getEffectiveLevel() + self.keep_running = True + + def _update_log_level(self) -> None: + """Update the log level if it has changed.""" + self.current_log_level = self.logger.getEffectiveLevel() + + if self.current_log_level != self.config.log_level: + self.logger.setLevel(self.config.log_level) + self.logger.critical( + "Log level changed: %s -> %s", + get_log_level_name(self.current_log_level), + get_log_level_name(self.config.log_level), + ) + + def _log_crawl_error(self, the_exception: Exception | None = None) -> None: + """Log a crawl error.""" + self.logger.error("Crawl Error: %s", the_exception) + error = f"{the_exception!s}\n\n{traceback.format_exc()}" + + WorkerError.objects.create( + error=error, + worker=self.config, + ) + + @property + def logger(self) -> logging.Logger: + return logging.getLogger(f"{self.__class__.__name__}.{self.NAME}") + + def run(self) -> None: + """Run the worker.""" + msg = "Please implement the run method." + raise NotImplementedError(msg) + + def signal_handler(self, the_signal: int, frame) -> None: # noqa: ANN001, ARG002 + self.logger.critical("Received %d. Stopping the worker.", the_signal) + self.keep_running = False + + def handle(self, *args, **options) -> None: # noqa: ANN002, ANN003, ARG002 + self.logger.info("Starting worker...") + + # Set up the signal handler to handle SIGINT and SIGTERM + signal.signal(signal.SIGINT, self.signal_handler) + signal.signal(signal.SIGTERM, self.signal_handler) + + while self.keep_running: + self.config.refresh_from_db() + self._update_log_level() + + if self.config.is_enabled: + with transaction.atomic(): + self.run() + else: + self.logger.debug("Job is disabled.") + + self.logger.debug( + "Sleeping for %d seconds.", + self.config.sleep_seconds, + ) + + for _ in range(self.config.sleep_seconds): + if not self.keep_running: + break + time.sleep(1) diff --git a/src/myapp/management/commands/send_email_confirmation.py b/src/myapp/management/commands/send_email_confirmation.py new file mode 100644 index 0000000..739e0f8 --- /dev/null +++ b/src/myapp/management/commands/send_email_confirmation.py @@ -0,0 +1,22 @@ +from django.core.mail import send_mail + +from myapp.management.commands._base import BaseWorkerCommand + + +class Command(BaseWorkerCommand): + """Simple Async Worker.""" + + help = "Send organization invite email" + NAME = "send_email_invite" + + def run(self) -> None: + """Run the worker.""" + self.logger.debug("I'm here, running things...") + + send_mail( + "Subject here", + "Here is the message.", + "from@example.com", + ["to@example.com"], + fail_silently=False, + ) diff --git a/src/myapp/management/commands/simple_async_worker.py b/src/myapp/management/commands/simple_async_worker.py new file mode 100644 index 0000000..5d82ff8 --- /dev/null +++ b/src/myapp/management/commands/simple_async_worker.py @@ -0,0 +1,12 @@ +from ._base import BaseWorkerCommand + + +class Command(BaseWorkerCommand): + """Simple Async Worker.""" + + help = "Simple Async Worker" + NAME = "simple_async_worker" + + def run(self) -> None: + """Run the worker.""" + self.logger.debug("I'm here, running things...") diff --git a/src/myapp/migrations/0001_initial.py b/src/myapp/migrations/0001_initial.py index 82ed3b4..5e35692 100644 --- a/src/myapp/migrations/0001_initial.py +++ b/src/myapp/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.5 on 2025-11-17 20:20 +# Generated by Django 4.2 on 2023-05-04 20:38 from django.db import migrations, models @@ -14,11 +14,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='SiteConfiguration', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('include_staff_in_analytics', models.BooleanField(default=False, help_text='Include staff in analytics.')), - ('js_analytics', models.TextField(blank=True, default='', help_text='Javascript to be included before the closing body tag. You should include the script tags.')), - ('js_head', models.TextField(blank=True, default='', help_text='Javascript to be included in the head tag. You should include the script tags.')), - ('js_body', models.TextField(blank=True, default='', help_text='Javascript to be included before the closing body tag. You should include the script tags.')), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], options={ 'abstract': False, diff --git a/src/myapp/migrations/0002_siteconfiguration_worker_enabled_and_more.py b/src/myapp/migrations/0002_siteconfiguration_worker_enabled_and_more.py new file mode 100644 index 0000000..da39390 --- /dev/null +++ b/src/myapp/migrations/0002_siteconfiguration_worker_enabled_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2 on 2023-05-04 20:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('myapp', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='siteconfiguration', + name='worker_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='siteconfiguration', + name='worker_sleep_seconds', + field=models.IntegerField(default=5), + ), + migrations.AlterField( + model_name='siteconfiguration', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/src/myapp/migrations/0003_siteconfiguration_js_body_siteconfiguration_js_head.py b/src/myapp/migrations/0003_siteconfiguration_js_body_siteconfiguration_js_head.py new file mode 100644 index 0000000..1717219 --- /dev/null +++ b/src/myapp/migrations/0003_siteconfiguration_js_body_siteconfiguration_js_head.py @@ -0,0 +1,32 @@ +# Generated by Django 5.0.7 on 2024-07-24 15:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("myapp", "0002_siteconfiguration_worker_enabled_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="siteconfiguration", + name="js_body", + field=models.TextField( + blank=True, + help_text="Javascript to be included before the closing body " + "tag. You should include the script tags.", + null=True, + ), + ), + migrations.AddField( + model_name="siteconfiguration", + name="js_head", + field=models.TextField( + blank=True, + help_text="Javascript to be included in the head tag. You " + "should include the script tags.", + null=True, + ), + ), + ] diff --git a/src/myapp/migrations/0004_alter_siteconfiguration_js_body_and_more.py b/src/myapp/migrations/0004_alter_siteconfiguration_js_body_and_more.py new file mode 100644 index 0000000..95c2dc7 --- /dev/null +++ b/src/myapp/migrations/0004_alter_siteconfiguration_js_body_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.7 on 2024-07-24 16:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("myapp", "0003_siteconfiguration_js_body_siteconfiguration_js_head"), + ] + + operations = [ + migrations.AlterField( + model_name="siteconfiguration", + name="js_body", + field=models.TextField( + blank=True, + default="", + help_text="Javascript to be included before the closing body " + "tag. You should include the script tags.", + null=True, + ), + ), + migrations.AlterField( + model_name="siteconfiguration", + name="js_head", + field=models.TextField( + blank=True, + default="", + help_text="Javascript to be included in the head tag. " + "You should include the script tags.", + null=True, + ), + ), + ] diff --git a/src/myapp/migrations/0005_workerconfiguration_workererror.py b/src/myapp/migrations/0005_workerconfiguration_workererror.py new file mode 100644 index 0000000..cb941b2 --- /dev/null +++ b/src/myapp/migrations/0005_workerconfiguration_workererror.py @@ -0,0 +1,86 @@ +# Generated by Django 5.0.7 on 2024-07-29 13:55 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("myapp", "0004_alter_siteconfiguration_js_body_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="WorkerConfiguration", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ("is_enabled", models.BooleanField(default=False)), + ("sleep_seconds", models.IntegerField(default=10)), + ( + "log_level", + models.IntegerField( + choices=[ + (10, "DEBUG"), + (20, "INFO"), + (30, "WARNING"), + (40, "ERROR"), + (50, "CRITICAL"), + ], + default=30, + ), + ), + ( + "custom", + models.JSONField(blank=True, default=dict, null=True), + ), + ("notes", models.TextField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name="WorkerError", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("error", models.TextField()), + ( + "error_status", + models.CharField( + choices=[("O", "Open"), ("C", "Closed")], + default="O", + max_length=1, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "worker", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="worker_errors", + to="myapp.workerconfiguration", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/src/myapp/migrations/0006_alter_siteconfiguration_js_body_and_more.py b/src/myapp/migrations/0006_alter_siteconfiguration_js_body_and_more.py new file mode 100644 index 0000000..399790b --- /dev/null +++ b/src/myapp/migrations/0006_alter_siteconfiguration_js_body_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1 on 2024-08-24 07:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("myapp", "0005_workerconfiguration_workererror"), + ] + + operations = [ + migrations.AlterField( + model_name="siteconfiguration", + name="js_body", + field=models.TextField( + blank=True, + default="", + help_text="Javascript to be included before the closing body tag. You should include the script tags.", + ), + ), + migrations.AlterField( + model_name="siteconfiguration", + name="js_head", + field=models.TextField( + blank=True, + default="", + help_text="Javascript to be included in the head tag. You should include the script tags.", + ), + ), + migrations.AlterField( + model_name="workerconfiguration", + name="notes", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/src/myapp/migrations/0007_siteconfiguration_required_2fa.py b/src/myapp/migrations/0007_siteconfiguration_required_2fa.py new file mode 100644 index 0000000..edbf137 --- /dev/null +++ b/src/myapp/migrations/0007_siteconfiguration_required_2fa.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-02 11:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('myapp', '0006_alter_siteconfiguration_js_body_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='siteconfiguration', + name='required_2fa', + field=models.BooleanField(default=False, help_text='Require 2FA for all users.'), + ), + ] diff --git a/src/myapp/migrations/0008_siteconfiguration_include_staff_in_analytics_and_more.py b/src/myapp/migrations/0008_siteconfiguration_include_staff_in_analytics_and_more.py new file mode 100644 index 0000000..ecc780d --- /dev/null +++ b/src/myapp/migrations/0008_siteconfiguration_include_staff_in_analytics_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-03-24 21:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('myapp', '0007_siteconfiguration_required_2fa'), + ] + + operations = [ + migrations.AddField( + model_name='siteconfiguration', + name='include_staff_in_analytics', + field=models.BooleanField(default=False, help_text='Include staff in analytics.'), + ), + migrations.AddField( + model_name='siteconfiguration', + name='js_analytics', + field=models.TextField(blank=True, default='', help_text='Javascript to be included before the closing body tag. You should include the script tags.'), + ), + ] diff --git a/src/myapp/migrations/0009_remove_required_2fa_field.py b/src/myapp/migrations/0009_remove_required_2fa_field.py new file mode 100644 index 0000000..0dfd4e1 --- /dev/null +++ b/src/myapp/migrations/0009_remove_required_2fa_field.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.4 on 2025-08-20 21:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('myapp', '0008_siteconfiguration_include_staff_in_analytics_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='siteconfiguration', + name='required_2fa', + ), + ] diff --git a/src/myapp/models/__init__.py b/src/myapp/models/__init__.py index 5767e82..315252c 100644 --- a/src/myapp/models/__init__.py +++ b/src/myapp/models/__init__.py @@ -3,10 +3,18 @@ import solo.models from django.db import models +from .worker_configurations import WorkerConfiguration +from .worker_errors import WorkerError + +FIVE_SECONDS = 5 + class SiteConfiguration(solo.models.SingletonModel): """Store the configuration of the site.""" + worker_enabled = models.BooleanField(default=False) + worker_sleep_seconds = models.IntegerField(default=FIVE_SECONDS) + include_staff_in_analytics = models.BooleanField( default=False, help_text="Include staff in analytics.", @@ -34,4 +42,4 @@ def __str__(self) -> str: return "Site Configuration" -__all__ = ["SiteConfiguration"] +__all__ = ["SiteConfiguration", "WorkerConfiguration", "WorkerError"] diff --git a/src/myapp/models/worker_configurations.py b/src/myapp/models/worker_configurations.py new file mode 100644 index 0000000..d5b3afc --- /dev/null +++ b/src/myapp/models/worker_configurations.py @@ -0,0 +1,39 @@ +import uuid + +from django.db import models + + +class WorkerConfiguration(models.Model): + """Store the configuration for the worker.""" + + # these values match the python logging module values + LOG_LEVEL_DEBUG = 10 + LOG_LEVEL_INFO = 20 + LOG_LEVEL_WARNING = 30 + LOG_LEVEL_ERROR = 40 + LOG_LEVEL_CRITICAL = 50 + LOG_LEVEL_CHOICES = [ + (LOG_LEVEL_DEBUG, "DEBUG"), + (LOG_LEVEL_INFO, "INFO"), + (LOG_LEVEL_WARNING, "WARNING"), + (LOG_LEVEL_ERROR, "ERROR"), + (LOG_LEVEL_CRITICAL, "CRITICAL"), + ] + + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4) + name = models.CharField(max_length=255, unique=True) + is_enabled = models.BooleanField(default=False) + sleep_seconds = models.IntegerField(default=10) + + log_level = models.IntegerField( + choices=LOG_LEVEL_CHOICES, + default=LOG_LEVEL_WARNING, + ) + + custom = models.JSONField(default=dict, null=True, blank=True) + + notes = models.TextField(blank=True, default="") + + def __str__(self) -> str: + """Return the worker name.""" + return self.name diff --git a/src/myapp/models/worker_errors.py b/src/myapp/models/worker_errors.py new file mode 100644 index 0000000..bc82e05 --- /dev/null +++ b/src/myapp/models/worker_errors.py @@ -0,0 +1,34 @@ +import uuid + +from django.db import models + + +class WorkerError(models.Model): + """Store the errors encountered by the worker.""" + + ERROR_OPEN = "O" + ERROR_CLOSED = "C" + ERROR_STATUS_CHOICES = ((ERROR_OPEN, "Open"), (ERROR_CLOSED, "Closed")) + + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4) + error = models.TextField() + error_status = models.CharField(max_length=1, choices=ERROR_STATUS_CHOICES, default=ERROR_OPEN) + + worker = models.ForeignKey( + "WorkerConfiguration", + on_delete=models.CASCADE, + related_name="worker_errors", + null=True, + blank=True, + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + """Meta options for the model.""" + + ordering = ["-created_at"] + + def __str__(self) -> str: + """Return the worker name.""" + return self.worker.name diff --git a/src/myapp/templates/_alerts.html b/src/myapp/templates/_alerts.html index 9891ef5..fc1d9c9 100644 --- a/src/myapp/templates/_alerts.html +++ b/src/myapp/templates/_alerts.html @@ -1,10 +1,6 @@ {% for message in messages %} -