From 574b3869d4c2ba1d761db98264dbad0cbc60fec3 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 24 Oct 2026 05:29:37 -0400 Subject: [PATCH] Fix security boundaries and upgrade supported Django dependencies --- .github/workflows/ci.yml | 41 +- README.md | 8 +- django_forms_workflows/admin.py | 23 + django_forms_workflows/api_views.py | 2 +- django_forms_workflows/apps.py | 2 +- django_forms_workflows/checks.py | 24 + django_forms_workflows/diff_views.py | 2 + django_forms_workflows/form_builder_views.py | 14 + django_forms_workflows/forms.py | 31 +- django_forms_workflows/models.py | 3 + django_forms_workflows/pdf.py | 53 ++ django_forms_workflows/reporting_views.py | 63 +- django_forms_workflows/security.py | 108 +++ django_forms_workflows/sso_backends.py | 97 +-- django_forms_workflows/sso_views.py | 83 +- .../workflow_builder.html | 4 +- django_forms_workflows/uploads.py | 104 +++ django_forms_workflows/utils.py | 74 +- django_forms_workflows/views.py | 164 ++-- .../workflow_builder_views.py | 23 +- docs/QUICKSTART.md | 4 +- docs/SECURITY_UPGRADE.md | 118 +++ docs/SSO_CONFIGURATION.md | 23 + example_project/README.md | 11 + example_project/example/settings.py | 77 +- package-lock.json | 236 +++--- package.json | 2 +- poetry.lock | 761 +++++++++++------- pyproject.toml | 14 +- ruff.toml | 4 +- scripts/audit_lock.py | 43 + tests/settings.py | 7 + tests/test_security_boundaries.py | 602 ++++++++++++++ tests/test_views.py | 6 +- 34 files changed, 2042 insertions(+), 789 deletions(-) create mode 100644 django_forms_workflows/checks.py create mode 100644 django_forms_workflows/pdf.py create mode 100644 django_forms_workflows/security.py create mode 100644 django_forms_workflows/uploads.py create mode 100644 docs/SECURITY_UPGRADE.md create mode 100644 scripts/audit_lock.py create mode 100644 tests/test_security_boundaries.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebda428..3cc82b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,19 @@ jobs: name: Test on Python ${{ matrix.python-version }} / Django ${{ matrix.django-version }} runs-on: ubuntu-latest + env: + DJANGO_DEBUG: '1' strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12'] - django-version: ['5.1'] + include: + - {python-version: '3.11', django-version: '5.2'} + - {python-version: '3.12', django-version: '5.2'} + - {python-version: '3.13', django-version: '5.2'} + - {python-version: '3.14', django-version: '5.2'} + - {python-version: '3.12', django-version: '6.0'} + - {python-version: '3.13', django-version: '6.1'} + - {python-version: '3.14', django-version: '6.1'} steps: - name: Checkout code @@ -31,8 +39,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install Django==${{ matrix.django-version }}.* - pip install -e .[dev,qr] + pip install -e '.[dev,qr,markdown,pdf,excel]' 'Django==${{ matrix.django-version }}.*' + - name: Install PDF system libraries + run: sudo apt-get update && sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 - name: Run migrations check run: | @@ -111,3 +120,27 @@ jobs: name: dist-packages path: dist/ + + javascript: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + - run: npm ci + - run: npm test + - run: npm audit --audit-level=moderate + + dependencies: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - run: pip install pip-audit poetry + - run: poetry check --lock + - name: Audit all locked Python dependencies, including extras and tooling + run: python scripts/audit_lock.py diff --git a/README.md b/README.md index 1ee81cc..7d5692b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Enterprise-grade, database-driven form builder with multi-stage approval workflows, external data integration, and cross-instance sync** [![License: LGPL v3](https://img.shields.io/badge/License-LGPL_v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0) -[![Python Version](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) +[![Python Version](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/) [![Django Version](https://img.shields.io/badge/django-5.2%2B-green)](https://www.djangoproject.com/) [![Version](https://img.shields.io/badge/version-0.49.0-orange)](https://github.com/opensensor/django-forms-workflows) @@ -189,6 +189,8 @@ Move form definitions between environments from the Django Admin: ## Quick Start +See [security upgrade notes](docs/SECURITY_UPGRADE.md) before upgrading an existing deployment. + ### Installation ```bash @@ -344,8 +346,8 @@ graph TB ## Requirements -- Python 3.10+ -- Django 5.2+ +- Python 3.11+ +- Django 5.2.18+, 6.0.9+, or 6.1.2+ (security patch minimums) - PostgreSQL, MySQL, or SQLite (PostgreSQL recommended for production) - Optional: Celery 5.0+ with Redis/Valkey for background task processing - Optional: `openpyxl` for Excel spreadsheet field support (`pip install django-forms-workflows[excel]`) diff --git a/django_forms_workflows/admin.py b/django_forms_workflows/admin.py index fcddf49..11892e3 100644 --- a/django_forms_workflows/admin.py +++ b/django_forms_workflows/admin.py @@ -1154,6 +1154,15 @@ def export_as_json(self, request, queryset): def sync_import_admin_view(self, request): """Admin page for importing form definitions from a JSON file or raw JSON.""" + from django.core.exceptions import PermissionDenied + + if not self.has_view_permission(request): + raise PermissionDenied + if not ( + self.has_add_permission(request) and self.has_change_permission(request) + ): + raise PermissionDenied + from .sync_api import import_payload context = dict(self.admin_site.each_context(request)) @@ -1219,6 +1228,15 @@ def sync_pull_admin_view(self, request): Step 1 (POST) – fetch available forms from the remote, show checkbox list Step 2 (POST) – import selected forms, show results """ + from django.core.exceptions import PermissionDenied + + if not self.has_view_permission(request): + raise PermissionDenied + if not ( + self.has_add_permission(request) and self.has_change_permission(request) + ): + raise PermissionDenied + from .sync_api import ( build_export_payload, fetch_remote_payload, @@ -1372,6 +1390,11 @@ def sync_push_admin_view(self, request): Step 1 (POST) – show all local forms with checkboxes + diff status Step 2 (POST) – execute push for selected forms, show results """ + from django.core.exceptions import PermissionDenied + + if not self.has_view_permission(request): + raise PermissionDenied + from .sync_api import ( build_export_payload, fetch_remote_payload, diff --git a/django_forms_workflows/api_views.py b/django_forms_workflows/api_views.py index 61b59be..45c5960 100644 --- a/django_forms_workflows/api_views.py +++ b/django_forms_workflows/api_views.py @@ -106,7 +106,7 @@ def wrapper(request, *args, **kwargs): raw = auth.removeprefix("Bearer ").strip() try: token_obj = APIToken.objects.select_related("user").get( - token=raw, is_active=True + token=raw, is_active=True, user__is_active=True ) except (APIToken.DoesNotExist, ValueError, Exception): # noqa: BLE001 return JsonResponse({"error": "Invalid or inactive token."}, status=401) diff --git a/django_forms_workflows/apps.py b/django_forms_workflows/apps.py index 5fcb152..b3b1bf5 100644 --- a/django_forms_workflows/apps.py +++ b/django_forms_workflows/apps.py @@ -17,7 +17,7 @@ def ready(self): Import signal handlers and perform app initialization. """ # Import signals to register handlers - from . import signals # noqa: F401 + from . import checks, signals # noqa: F401 # Load custom callback handlers from FORMS_WORKFLOWS_CALLBACKS setting from .callback_registry import load_from_settings diff --git a/django_forms_workflows/checks.py b/django_forms_workflows/checks.py new file mode 100644 index 0000000..1068545 --- /dev/null +++ b/django_forms_workflows/checks.py @@ -0,0 +1,24 @@ +"""Deployment checks for public submission throttling.""" + +from django.conf import settings +from django.core.checks import Tags, Warning, register + + +@register(Tags.security, deploy=True) +def check_rate_limit_cache(app_configs, **kwargs): + alias = getattr(settings, "FORMS_WORKFLOWS_RATE_LIMIT_CACHE", "default") + backend = settings.CACHES.get(alias, {}).get("BACKEND", "") + if backend not in { + "django.core.cache.backends.redis.RedisCache", + "django.core.cache.backends.memcached.PyMemcacheCache", + "django.core.cache.backends.memcached.PyLibMCCache", + "django_redis.cache.RedisCache", + }: + return [ + Warning( + "Public form rate limiting requires a shared cache with atomic add/incr.", + hint="Configure Redis or Memcached and FORMS_WORKFLOWS_RATE_LIMIT_CACHE; local-memory caches only limit one process.", + id="django_forms_workflows.W001", + ) + ] + return [] diff --git a/django_forms_workflows/diff_views.py b/django_forms_workflows/diff_views.py index af86d0a..0dfd84a 100644 --- a/django_forms_workflows/diff_views.py +++ b/django_forms_workflows/diff_views.py @@ -8,6 +8,7 @@ from django.shortcuts import render from .models import FormDefinition +from .security import builder_permission_required from .sync_api import build_export_payload @@ -429,6 +430,7 @@ def _build_summary(forms_data): return summaries +@builder_permission_required("view_formdefinition") def diff_forms_view(request): """Render a side-by-side JSON diff of selected FormDefinitions.""" pks = request.GET.get("pks", "") diff --git a/django_forms_workflows/form_builder_views.py b/django_forms_workflows/form_builder_views.py index 0328406..b7c3157 100644 --- a/django_forms_workflows/form_builder_views.py +++ b/django_forms_workflows/form_builder_views.py @@ -26,11 +26,13 @@ WorkflowDefinition, WorkflowStage, ) +from .security import builder_permission_required logger = logging.getLogger(__name__) @require_GET +@builder_permission_required("view_formtemplate") def form_builder_templates(request): """ API endpoint to list available form templates. @@ -63,6 +65,7 @@ def form_builder_templates(request): @require_GET +@builder_permission_required("view_formtemplate") def form_builder_load_template(request, template_id): """ API endpoint to load a form template. @@ -83,6 +86,7 @@ def form_builder_load_template(request, template_id): @require_POST +@builder_permission_required("view_formdefinition", "add_formdefinition") def form_builder_clone(request, form_id): """ API endpoint to clone an existing form. @@ -225,6 +229,7 @@ def form_builder_clone(request, form_id): @require_GET +@builder_permission_required("view_formdefinition") def form_builder_view(request, form_id=None): """ Main form builder page. @@ -269,6 +274,7 @@ def form_builder_view(request, form_id=None): @require_GET +@builder_permission_required("view_formdefinition") def form_builder_load(request, form_id): """ API endpoint to load form data as JSON. @@ -353,6 +359,7 @@ def form_builder_load(request, form_id): @require_POST +@builder_permission_required("save_formdefinition") def form_builder_save(request): """ API endpoint to save form data. @@ -604,6 +611,7 @@ def save_form_definition_from_builder_data(data, user, form_definition=None): @require_POST +@builder_permission_required("view_formdefinition") def form_builder_preview(request): """ API endpoint to generate a preview of the form. @@ -764,6 +772,7 @@ def form_builder_preview(request): @require_GET +@builder_permission_required("view_formdefinition", "view_documenttemplate") def document_template_list(request, form_id): """List document templates for a form.""" templates = DocumentTemplate.objects.filter(form_definition_id=form_id).order_by( @@ -789,6 +798,7 @@ def document_template_list(request, form_id): @require_POST +@builder_permission_required("change_formdefinition", "save_documenttemplate") def document_template_save(request, form_id): """Create or update a document template.""" try: @@ -847,6 +857,7 @@ def document_template_save(request, form_id): @require_POST +@builder_permission_required("change_formdefinition", "delete_documenttemplate") def document_template_delete(request, form_id, template_id): """Delete a document template.""" tpl = get_object_or_404( @@ -862,6 +873,7 @@ def document_template_delete(request, form_id, template_id): @require_GET +@builder_permission_required("view_sharedoptionlist") def shared_option_list_api(request): """List all shared option lists (for form builder dropdowns).""" lists = SharedOptionList.objects.filter(is_active=True).order_by("name") @@ -882,6 +894,7 @@ def shared_option_list_api(request): @require_POST +@builder_permission_required("save_sharedoptionlist") def shared_option_list_save(request): """Create or update a shared option list.""" try: @@ -924,6 +937,7 @@ def shared_option_list_save(request): @require_POST +@builder_permission_required("delete_sharedoptionlist") def shared_option_list_delete(request, list_id): """Delete a shared option list.""" ol = get_object_or_404(SharedOptionList, id=list_id) diff --git a/django_forms_workflows/forms.py b/django_forms_workflows/forms.py index f47a111..8896e4d 100644 --- a/django_forms_workflows/forms.py +++ b/django_forms_workflows/forms.py @@ -333,18 +333,6 @@ def __init__( # files). Also used to carry forward draft / resubmit file data. self.stashed_files = stashed_files or {} - # Recover stashed-file metadata carried forward via hidden input from - # a prior validation-failure render (browsers cannot re-send files). - if not self.stashed_files and self.data: - prev = self.data.get("_stashed_files") - if prev: - try: - self.stashed_files = json.loads(prev) - except (json.JSONDecodeError, TypeError): - # Ignore malformed client metadata; uploaded files are rebuilt - # from validated server-side data below. - pass - # Build form fields from definition # Exclude fields scoped to a workflow stage - those are for approvers only for field in ( @@ -354,15 +342,6 @@ def __init__( ): self.add_field(field, initial_data) - # Embed stashed-file metadata as a hidden field so it survives - # validation-failure round-trips (browsers cannot re-send files). - if self.stashed_files: - self.fields["_stashed_files"] = forms.CharField( - widget=forms.HiddenInput(), - required=False, - initial=json.dumps(self.stashed_files), - ) - # Setup form layout with Crispy Forms self.helper = FormHelper() self.helper.form_method = "post" @@ -763,7 +742,7 @@ def add_field(self, field_def, initial_data): # failure, saved with a draft, or carried from a resubmission). existing_file = self._get_existing_file_info(field_def.field_name) if existing_file: - fname = existing_file.get("filename", "file") + fname = html_escape(existing_file.get("filename", "file")) extra_help = f"Previously uploaded: {fname}. Leave blank to keep it." base_help = field_args.get("help_text", "") or "" field_args["help_text"] = ( @@ -789,10 +768,11 @@ def add_field(self, field_def, initial_data): if isinstance(existing_file, list) else existing_file.get("filename", "file") ) - extra = f"Previously uploaded: {names}. Leave blank to keep." + extra = f"Previously uploaded: {html_escape(names)}. Leave blank to keep." help_text = f"{help_text} {extra}" if help_text else extra required = False self.fields[field_def.field_name] = MultipleFileField( + validators=_build_file_validators(field_def), required=required, label=field_def.field_label, help_text=help_text, @@ -931,12 +911,13 @@ def add_field(self, field_def, initial_data): ) elif field_def.field_type == "display_text": - display_html = _render_display_text(initial or field_def.default_value) + display_html = _render_display_text(field_def.default_value) self.fields[field_def.field_name] = forms.CharField( required=False, label=field_def.field_label, help_text=field_def.help_text, - initial=initial or field_def.default_value, + initial=field_def.default_value, + disabled=True, widget=DisplayTextWidget( display_html=display_html, attrs={"data-display-text": "true"}, diff --git a/django_forms_workflows/models.py b/django_forms_workflows/models.py index 2ca773f..1d24959 100644 --- a/django_forms_workflows/models.py +++ b/django_forms_workflows/models.py @@ -881,6 +881,8 @@ def render(self, submission): """Render this template with submission data, returning HTML string.""" import re + from django.utils.html import escape + form_data = submission.form_data or {} # Build merge context with system variables + form data @@ -906,6 +908,7 @@ def render(self, submission): else: context[key] = str(val) if val is not None else "" + context = {key: str(escape(value)) for key, value in context.items()} html = self.html_content # Process conditional blocks: {% if field_name %}...{% endif %} diff --git a/django_forms_workflows/pdf.py b/django_forms_workflows/pdf.py new file mode 100644 index 0000000..60504b0 --- /dev/null +++ b/django_forms_workflows/pdf.py @@ -0,0 +1,53 @@ +"""PDF resources are offline by default, including CSS and SVG subresources.""" + +import base64 +import binascii + +from django.conf import settings + + +def safe_pdf_url_fetcher(url, *args, **kwargs): + """Allow bounded embedded raster images; never open files or network URLs. + + Logos/signatures should be embedded as data:image/png;base64,... (or JPEG). + A deployment needing additional resources can supply a reviewed fetcher via + FORMS_WORKFLOWS_PDF_URL_FETCHER. It must enforce its own allowlist and limits. + """ + limit = getattr(settings, "FORMS_WORKFLOWS_PDF_MAX_RESOURCE_BYTES", 5 * 1024 * 1024) + if len(url) > limit * 4 // 3 + 128: + raise ValueError("PDF resource exceeds size limit") + header, sep, encoded = url.partition(",") + if not sep or header not in ("data:image/png;base64", "data:image/jpeg;base64"): + raise ValueError("PDF resource URL is not permitted") + try: + data = base64.b64decode(encoded, validate=True) + except (ValueError, binascii.Error) as exc: + raise ValueError("Invalid PDF image") from exc + mime = header[5:].split(";")[0] + signature = b"\x89PNG\r\n\x1a\n" if mime == "image/png" else b"\xff\xd8\xff" + if len(data) > limit or not data.startswith(signature): + raise ValueError("Invalid PDF image") + return {"string": data, "mime_type": mime} + + +def pdf_url_fetcher(): + from django.utils.module_loading import import_string + + custom = getattr(settings, "FORMS_WORKFLOWS_PDF_URL_FETCHER", None) + if custom: + factory = import_string(custom) + return factory() + # WeasyPrint 70 replaced dictionary-returning callables with URLFetcher. + try: + from weasyprint.urls import URLFetcher, URLFetcherResponse + except ImportError: + return safe_pdf_url_fetcher + + class OfflineFetcher(URLFetcher): + def fetch(self, url, headers=None): + resource = safe_pdf_url_fetcher(url) + return URLFetcherResponse( + url, resource["string"], {"Content-Type": resource["mime_type"]} + ) + + return OfflineFetcher(allowed_protocols={"data"}, allow_redirects=False) diff --git a/django_forms_workflows/reporting_views.py b/django_forms_workflows/reporting_views.py index 70bc41f..8578f8a 100644 --- a/django_forms_workflows/reporting_views.py +++ b/django_forms_workflows/reporting_views.py @@ -18,17 +18,31 @@ from django.utils import timezone from django.utils.safestring import mark_safe -from .models import ApprovalTask, FormDefinition, FormSubmission +from .models import ApprovalTask, FormDefinition +from .security import visible_submissions + + +def _range_days(request): + try: + return max(1, min(int(request.GET.get("days", 90)), 3660)) + except (TypeError, ValueError): + return 90 + + +def _csv_cell(value): + if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@")): + return "'" + value + return value @login_required def analytics_export_csv(request): """Export analytics summary data as CSV.""" - range_days = int(request.GET.get("days", 90)) + range_days = _range_days(request) cutoff = timezone.now() - timedelta(days=range_days) form_slug = request.GET.get("form", "") - submissions_qs = FormSubmission.objects.exclude(status="draft") + submissions_qs = visible_submissions(request.user).exclude(status="draft") if form_slug: submissions_qs = submissions_qs.filter(form_definition__slug=form_slug) @@ -46,15 +60,18 @@ def analytics_export_csv(request): ).order_by("-created_at"): writer.writerow( [ - sub.created_at.strftime("%Y-%m-%d %H:%M"), - sub.form_definition.name if sub.form_definition else "", - sub.status, - ( - sub.submitter.get_full_name() or sub.submitter.username - if sub.submitter - else "Anonymous" - ), - sub.id, + _csv_cell(value) + for value in [ + sub.created_at.strftime("%Y-%m-%d %H:%M"), + sub.form_definition.name if sub.form_definition else "", + sub.status, + ( + sub.submitter.get_full_name() or sub.submitter.username + if sub.submitter + else "Anonymous" + ), + sub.id, + ] ] ) @@ -66,14 +83,14 @@ def analytics_dashboard(request): """Render the analytics dashboard with submission & approval metrics.""" # ── Time range filter ─────────────────────────────────────────────── - range_days = int(request.GET.get("days", 90)) + range_days = _range_days(request) cutoff = timezone.now() - timedelta(days=range_days) prev_cutoff = cutoff - timedelta(days=range_days) # Previous period form_slug = request.GET.get("form", "") # Base querysets - submissions_qs = FormSubmission.objects.exclude(status="draft") - tasks_qs = ApprovalTask.objects.all() + submissions_qs = visible_submissions(request.user).exclude(status="draft") + tasks_qs = ApprovalTask.objects.filter(submission__in=submissions_qs) if form_slug: submissions_qs = submissions_qs.filter(form_definition__slug=form_slug) @@ -195,7 +212,7 @@ def _fmt_td(td): # ── 7. Bottleneck stages ──────────────────────────────────────────── # Stages with the most pending tasks right now bottleneck_stages = list( - ApprovalTask.objects.filter(status="pending", workflow_stage__isnull=False) + tasks_qs.filter(status="pending", workflow_stage__isnull=False) .values( "workflow_stage__name", "workflow_stage__workflow__form_definition__name" ) @@ -232,13 +249,16 @@ def _fmt_td(td): # ── Available forms for filter dropdown ───────────────────────────── available_forms = list( - FormDefinition.objects.filter(is_active=True) + FormDefinition.objects.filter( + is_active=True, submissions__in=visible_submissions(request.user) + ) + .distinct() .values_list("slug", "name") .order_by("name") ) # ── 10. Overdue tasks ─────────────────────────────────────────────── - overdue_count = ApprovalTask.objects.filter( + overdue_count = tasks_qs.filter( status="pending", due_date__isnull=False, due_date__lt=timezone.now(), @@ -256,7 +276,12 @@ def _chart_json(data): else: clean[k] = v serializable.append(clean) - return mark_safe(json.dumps(serializable)) + return mark_safe( + json.dumps(serializable) + .replace("<", "\\u003C") + .replace(">", "\\u003E") + .replace("&", "\\u0026") + ) context = { "range_days": range_days, diff --git a/django_forms_workflows/security.py b/django_forms_workflows/security.py new file mode 100644 index 0000000..396f16c --- /dev/null +++ b/django_forms_workflows/security.py @@ -0,0 +1,108 @@ +"""Shared authorization and rendering boundaries.""" + +import json +import time +from functools import wraps + +import nh3 +from django.core.exceptions import PermissionDenied +from django.db.models import Q +from django.http import Http404 +from django.utils.html import escape + +from .models import FormSubmission + + +def visible_submissions(user): + """Mirror user_can_view_submission without exposing unrelated records.""" + qs = FormSubmission.objects.all() + if not user.is_authenticated or not user.is_active: + return qs.none() + if user.is_superuser: + return qs + groups = user.groups.all() + return qs.filter( + Q(submitter=user) + | Q(approval_tasks__assigned_to=user) + | Q(approval_tasks__assigned_group__in=groups) + | Q(form_definition__admin_groups__in=groups) + | Q(form_definition__reviewer_groups__in=groups) + ).distinct() + + +def builder_permission_required(*permissions): + """Custom admin endpoints need model permissions in addition to staff status.""" + + def decorate(view): + @wraps(view) + def wrapped(request, *args, **kwargs): + user = request.user + resolved = [] + for permission in permissions: + if permission.startswith("save_"): + model = permission.removeprefix("save_") + try: + data = json.loads(request.body) + except (ValueError, TypeError): + data = {} + key = "workflow_id" if model == "workflowdefinition" else "id" + action = ( + "change" if isinstance(data, dict) and data.get(key) else "add" + ) + if ( + model == "workflowdefinition" + and action == "add" + and isinstance(data, dict) + ): + from .models import WorkflowDefinition + + # Legacy saves without workflow_id update the form's existing track. + if WorkflowDefinition.objects.filter( + form_definition_id=data.get("form_id") + ).exists(): + action = "change" + permission = f"{action}_{model}" + resolved.append(permission) + if not ( + user.is_active + and user.is_staff + and user.has_perms([f"django_forms_workflows.{p}" for p in resolved]) + ): + raise PermissionDenied + return view(request, *args, **kwargs) + + return wrapped + + return decorate + + +def grant_submission_receipt(request, submission): + """A public receipt belongs only to the submitting browser for one hour.""" + now = time.time() + receipts = { + k: v for k, v in request.session.get("dfw_receipts", {}).items() if v > now + } + receipts[str(submission.pk)] = now + 3600 + request.session["dfw_receipts"] = dict(list(receipts.items())[-100:]) + + +def require_submission_receipt(request, submission): + if visible_submissions(request.user).filter(pk=submission.pk).exists(): + return + expiry = request.session.get("dfw_receipts", {}).get(str(submission.pk), 0) + if submission.submitter_id is None and expiry > time.time(): + return + raise Http404("Not found") + + +def render_success_message(template, data): + """Escape answer tokens, then sanitize HTML including URL attributes.""" + import re + + def replace(match): + value = data.get(match.group(1), "") + if isinstance(value, list): + value = ", ".join(str(v) for v in value) + return str(escape(str(value))) + + return nh3.clean(re.sub(r"\{(\w+)\}", replace, template)) diff --git a/django_forms_workflows/sso_backends.py b/django_forms_workflows/sso_backends.py index 24688b6..c2a669b 100644 --- a/django_forms_workflows/sso_backends.py +++ b/django_forms_workflows/sso_backends.py @@ -56,9 +56,8 @@ def get_sso_settings(): create_users: Whether to create new users on SSO login (default: True) update_user_on_login: Whether to update user attributes on login (default: True) sync_groups: Whether to sync SSO groups (default: False) - link_to_existing_user: Whether to link SSO user to existing user by username (default: True) + account_bindings: Administrator-configured provider -> subject -> username mappings sync_ldap_groups_on_sso: Whether to sync LDAP groups when user logs in via SSO (default: True) - username_from_email: Whether to extract username from email (default: True) """ defaults = { "providers": {}, @@ -70,9 +69,8 @@ def get_sso_settings(): "create_users": True, "update_user_on_login": True, "sync_groups": False, - "link_to_existing_user": True, # Link SSO users to existing LDAP users + "account_bindings": {}, "sync_ldap_groups_on_sso": True, # Sync LDAP groups on SSO login - "username_from_email": True, # Extract username from email prefix } user_settings = getattr(settings, "FORMS_WORKFLOWS_SSO", {}) return {**defaults, **user_settings} @@ -342,74 +340,39 @@ def get_saml_config(): } -def link_to_existing_user(backend, details, user=None, *args, **kwargs): - """ - Pipeline function to link SSO login to an existing user by username. +def bound_sso_user(provider, subject): + """Resolve an administrator-configured provider/subject binding only.""" + from django.contrib.auth import get_user_model + from django.core.exceptions import PermissionDenied - This is critical for organizations that use both LDAP and SSO authentication, - where the same user may authenticate via either method. This ensures: - 1. SSO users are linked to their existing LDAP accounts - 2. Group memberships from LDAP are preserved - 3. Users don't end up with duplicate accounts + bindings = get_sso_settings().get("account_bindings", {}) + username = bindings.get(provider, {}).get(str(subject)) if subject else None + if not username: + return None + model = get_user_model() + try: + user = model.objects.get(**{model.USERNAME_FIELD: username}) + except model.DoesNotExist as exc: + raise PermissionDenied("SSO account binding is not available") from exc + if not user.is_active: + raise PermissionDenied("SSO account is disabled") + return user - Add to SOCIAL_AUTH_PIPELINE before 'social_core.pipeline.user.create_user': - 'django_forms_workflows.sso_backends.link_to_existing_user', - Configuration via FORMS_WORKFLOWS_SSO: - link_to_existing_user: True (default) - Enable username matching - username_from_email: True (default) - Extract username from email prefix +def link_to_existing_user(backend, details, user=None, uid=None, *args, **kwargs): + """Keep established social-auth associations; never link by email or username. - Returns: - dict with 'user' key if existing user found, empty dict otherwise + For first-time linking, configure account_bindings[backend.name][uid]. + Social-auth stores the resulting provider/subject association for later logins. """ - if user: - # Already have a user, nothing to do - return {"user": user} - - sso_settings = get_sso_settings() - - if not sso_settings.get("link_to_existing_user", True): - return {} - - # Get email from SSO details - email = details.get("email", "") - if not email: - return {} + from django.core.exceptions import PermissionDenied - # Determine username to look for - if sso_settings.get("username_from_email", True): - # Extract username from email prefix (e.g., "jdoe@example.com" -> "jdoe") - username = email.split("@")[0].lower() - else: - username = details.get("username", "").lower() - - if not username: - return {} - - # Try to find existing user by username (case-insensitive) - try: - existing_user = User.objects.get(username__iexact=username) - logger.info( - f"SSO login linked to existing user '{existing_user.username}' " - f"(email: {email}, backend: {backend.name})" - ) - return {"user": existing_user, "is_new": False} - except User.DoesNotExist: - # Also try by email (case-insensitive) - try: - existing_user = User.objects.get(email__iexact=email) - logger.info( - f"SSO login linked to existing user '{existing_user.username}' " - f"by email (email: {email}, backend: {backend.name})" - ) - return {"user": existing_user, "is_new": False} - except User.DoesNotExist: - logger.debug( - "No existing user found with email '%s' to link SSO login", email - ) - - logger.debug(f"No existing user found for username '{username}' or email '{email}'") - return {} + if user is not None: + if not user.is_active: + raise PermissionDenied("SSO account is disabled") + return {"user": user} + existing = bound_sso_user(backend.name, uid) + return {"user": existing, "is_new": False} if existing is not None else {} def sync_ldap_groups_on_sso(backend, user, response, *args, **kwargs): @@ -837,7 +800,7 @@ def get_attr(attrs, name): # ) SOCIAL_AUTH_PIPELINE_EXTENSION = ( - # Link SSO users to existing LDAP users by username + # Link existing accounts only through explicit provider/subject bindings "django_forms_workflows.sso_backends.link_to_existing_user", # Sync SSO attributes to UserProfile "django_forms_workflows.sso_backends.sync_user_profile", diff --git a/django_forms_workflows/sso_views.py b/django_forms_workflows/sso_views.py index 34cc017..8e20cc4 100644 --- a/django_forms_workflows/sso_views.py +++ b/django_forms_workflows/sso_views.py @@ -112,18 +112,8 @@ def _prepare_django_request(self, request): Returns a dict with the request data in the format expected by OneLogin_Saml2_Auth. Handles reverse proxy scenarios where X-Forwarded-* headers indicate the real protocol. """ - # Check if request is secure (handles reverse proxy via X-Forwarded-Proto) is_secure = request.is_secure() - if not is_secure: - # Check X-Forwarded-Proto header for reverse proxy - forwarded_proto = request.META.get("HTTP_X_FORWARDED_PROTO", "") - is_secure = forwarded_proto.lower() == "https" - - # Get the host without port (proxy may forward with port) - http_host = request.META.get("HTTP_HOST", "") - # Strip port if present to avoid mismatches - if ":" in http_host: - http_host = http_host.split(":")[0] + http_host = request.get_host() return { "https": "on" if is_secure else "off", @@ -141,7 +131,6 @@ def post(self, request): ) try: - from django.contrib.auth import get_user_model from onelogin.saml2.auth import OneLogin_Saml2_Auth saml_config = get_saml_config() @@ -155,9 +144,7 @@ def post(self, request): last_error_reason = auth.get_last_error_reason() logger.error(f"SAML authentication errors: {errors}") logger.error(f"SAML last error reason: {last_error_reason}") - return HttpResponseBadRequest( - f"SAML authentication failed: {errors}. Reason: {last_error_reason}" - ) + return HttpResponseBadRequest("SAML authentication failed.") if not auth.is_authenticated(): return HttpResponseBadRequest("SAML authentication failed") @@ -179,67 +166,13 @@ def post(self, request): if not email: return HttpResponseBadRequest("SAML response missing email/NameID") - # Get first/last name from attributes if available - first_name = "" - for attr_name in ["firstName", "FirstName", "first_name", "givenName"]: - if attr_name in attributes and attributes[attr_name]: - first_name = attributes[attr_name][0] - break - - last_name = "" - for attr_name in ["lastName", "LastName", "last_name", "sn", "surname"]: - if attr_name in attributes and attributes[attr_name]: - last_name = attributes[attr_name][0] - break - - # Find or create user - try to link to existing user first - user_model = get_user_model() - user = None - created = False - - # Extract username from email - username = email.split("@")[0].lower() + # Bind the validated issuer + NameID to an explicitly selected account. + from .sso_backends import bound_sso_user - # Try to find existing user by username (case-insensitive) - # This links SSO users to their existing LDAP accounts - try: - user = user_model.objects.get(username__iexact=username) - logger.info( - f"SAML login linked to existing user '{user.username}' " - f"(email: {email})" - ) - except user_model.DoesNotExist: - # Try by email - try: - user = user_model.objects.get(email__iexact=email) - logger.info( - f"SAML login linked to existing user '{user.username}' " - f"by email (email: {email})" - ) - except user_model.DoesNotExist: - # Create new user - user = user_model.objects.create( - username=username, - email=email, - first_name=first_name, - last_name=last_name, - ) - user.set_unusable_password() - user.save() - created = True - logger.info(f"Created new user from SAML: {user.username}") - - # Update user attributes if they logged in with existing account - if not created and (first_name or last_name): - updated = False - if first_name and not user.first_name: - user.first_name = first_name - updated = True - if last_name and not user.last_name: - user.last_name = last_name - updated = True - if updated: - user.save() + issuer = saml_config["idp"]["entityId"] + user = bound_sso_user(f"saml:{issuer}", name_id) + if user is None: + return HttpResponseBadRequest("SAML identity has no account binding.") # Sync LDAP groups for this user try: diff --git a/django_forms_workflows/templates/admin/django_forms_workflows/workflow_builder.html b/django_forms_workflows/templates/admin/django_forms_workflows/workflow_builder.html index 7c3d90a..acde93c 100644 --- a/django_forms_workflows/templates/admin/django_forms_workflows/workflow_builder.html +++ b/django_forms_workflows/templates/admin/django_forms_workflows/workflow_builder.html @@ -1124,11 +1124,11 @@
Visual Builder Scope
const WORKFLOW_CONFIG = { formId: {{ form_id }}, - currentWorkflowId: {{ workflow_id }}, + currentWorkflowId: {{ workflow_id|default:"null" }}, workflowBuilderUrl: '{% url "admin:workflow_builder" form_id %}', csrfToken: '{{ csrf_token }}', apiUrls: { - load: '{% url "admin:workflow_builder_load" form_id %}?workflow_id={{ workflow_id }}', + load: '{% url "admin:workflow_builder_load" form_id %}{% if workflow_id %}?workflow_id={{ workflow_id }}{% endif %}', save: '{% url "admin:workflow_builder_save" %}', } }; diff --git a/django_forms_workflows/uploads.py b/django_forms_workflows/uploads.py new file mode 100644 index 0000000..94e140f --- /dev/null +++ b/django_forms_workflows/uploads.py @@ -0,0 +1,104 @@ +"""Recover validated uploads from the submitting session, never from POST metadata.""" + +import time +from functools import wraps + +from django import forms +from django.conf import settings +from django.core.exceptions import ValidationError +from django.http import HttpResponseBadRequest + +from .forms import MaxFileSizeValidator, _build_file_validators + +FILE_TYPES = ("file", "multifile", "spreadsheet") + + +def _key(request, form_definition): + return f"{request.user.pk or 'anonymous'}:{form_definition.pk}" + + +def reject_invalid_uploads(view): + @wraps(view) + def wrapped(*args, **kwargs): + try: + return view(*args, **kwargs) + except ValidationError: + return HttpResponseBadRequest( + "Invalid file upload. Check the file type and size." + ) + + return wrapped + + +def prepare_uploads(request, form_definition): + """Validate every new file before writing any of them to storage.""" + from .views import _serialize_single_file + + now = time.time() + entries = { + k: v + for k, v in request.session.get("dfw_uploads", {}).items() + if v["expires"] > now + } + key = _key(request, form_definition) + metadata = dict(entries.get(key, {}).get("files", {})) + fields = { + f.field_name: f + for f in form_definition.fields.filter( + field_type__in=FILE_TYPES, workflow_stage__isnull=True + ) + } + validated = {} + for name in request.FILES: + if name not in fields: + raise ValidationError("Unknown upload field") + field = fields[name] + validators = _build_file_validators(field) + validators.append( + MaxFileSizeValidator(getattr(settings, "FORMS_WORKFLOWS_MAX_UPLOAD_MB", 32)) + ) + cleaner = forms.FileField(validators=validators) + files = request.FILES.getlist(name) + if field.field_type != "multifile" and len(files) != 1: + raise ValidationError("Only one file is allowed") + validated[name] = [cleaner.clean(file) for file in files] + for name, files in validated.items(): + values = [_serialize_single_file(file, name, "pending") for file in files] + if any(value.get("upload_failed") for value in values): + raise ValidationError("Upload storage unavailable") + metadata[name] = values if fields[name].field_type == "multifile" else values[0] + # Keep only fields still present in this form definition. + metadata = {name: value for name, value in metadata.items() if name in fields} + if metadata: + entries[key] = {"expires": now + 3600, "files": metadata} + request.session["dfw_uploads"] = dict(list(entries.items())[-20:]) + return metadata + + +def clear_uploads(request, form_definition): + entries = dict(request.session.get("dfw_uploads", {})) + entries.pop(_key(request, form_definition), None) + request.session["dfw_uploads"] = entries + + +def draft_data(form_definition, raw, previous=None): + """Accept scalar answers only; retain upload metadata from the owned draft.""" + fields = list(form_definition.fields.filter(workflow_stage__isnull=True)) + names = {f.field_name for f in fields if f.field_type not in FILE_TYPES} + for field in fields: + if field.field_type == "matrix" and isinstance(field.choices, dict): + names.update( + f"{field.field_name}__{row}" for row in field.choices.get("rows", []) + ) + result = {} + for name, value in raw.items(): + if name not in names or name == "_stashed_files": + continue + if isinstance(value, (str, int, float, bool)) or value is None: + result[name] = value + elif isinstance(value, list) and all(isinstance(v, str) for v in value): + result[name] = value + for field in fields: + if field.field_type in FILE_TYPES and field.field_name in (previous or {}): + result[field.field_name] = previous[field.field_name] + return result diff --git a/django_forms_workflows/utils.py b/django_forms_workflows/utils.py index c6b9814..3d9aa7e 100644 --- a/django_forms_workflows/utils.py +++ b/django_forms_workflows/utils.py @@ -315,42 +315,54 @@ def sync_ldap_groups(): def _get_client_ip(request): - """Extract the real client IP, respecting X-Forwarded-For.""" - xff = request.META.get("HTTP_X_FORWARDED_FOR") - if xff: - return xff.split(",")[0].strip() - return request.META.get("REMOTE_ADDR", "0.0.0.0") + """Trust forwarding headers only through explicitly configured proxies.""" + import ipaddress - -def check_rate_limit(request, form_slug: str) -> bool: - """Return ``True`` if the request is within the rate limit. - - Uses Django's cache framework (default cache). The limit is read from - ``settings.FORMS_WORKFLOWS_PUBLIC_RATE_LIMIT`` and defaults to - ``"10/hour"``. Format: ``"/"`` where *period* - is one of ``minute``, ``hour``, ``day``. - - The cache key is scoped to IP + form slug so one form's limit doesn't - affect another. - """ from django.conf import settings - from django.core.cache import cache - limit_str = getattr(settings, "FORMS_WORKFLOWS_PUBLIC_RATE_LIMIT", "10/hour") try: - max_requests, period = limit_str.split("/") - max_requests = int(max_requests) - except (ValueError, AttributeError): - max_requests, period = 10, "hour" + peer = ipaddress.ip_address(request.META.get("REMOTE_ADDR", "0.0.0.0")) + trusted = [ + ipaddress.ip_network(cidr) + for cidr in getattr(settings, "FORMS_WORKFLOWS_TRUSTED_PROXIES", []) + ] + if any(peer in network for network in trusted): + hops = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",") + for hop in reversed(hops): + if not any(peer in network for network in trusted): + break + peer = ipaddress.ip_address(hop.strip()) + return str(peer) + except ValueError: + # Invalid chains must not give the caller a new rate-limit identity. + return request.META.get("REMOTE_ADDR", "0.0.0.0") - period_seconds = {"minute": 60, "hour": 3600, "day": 86400}.get(period, 3600) - ip = _get_client_ip(request) - cache_key = f"forms_ratelimit:{ip}:{form_slug}" - current = cache.get(cache_key, 0) +def check_rate_limit(request, form_slug: str) -> bool: + """Atomic fixed-window counter; use a shared Redis/Memcached cache in production.""" + import hashlib + import time - if current >= max_requests: - return False # rate limited + from django.conf import settings + from django.core.cache import caches - cache.set(cache_key, current + 1, timeout=period_seconds) - return True + limit_str = getattr(settings, "FORMS_WORKFLOWS_PUBLIC_RATE_LIMIT", "10/hour") + try: + count, period = limit_str.split("/") + limit = max(1, int(count)) + seconds = {"minute": 60, "hour": 3600, "day": 86400}[period] + except (ValueError, AttributeError, KeyError): + limit, seconds = 10, 3600 + now = int(time.time()) + identity = hashlib.sha256( + f"{_get_client_ip(request)}:{form_slug}".encode() + ).hexdigest() + key = f"dfw_rate:{now // seconds}:{identity}" + cache = caches[getattr(settings, "FORMS_WORKFLOWS_RATE_LIMIT_CACHE", "default")] + try: + if cache.add(key, 1, timeout=seconds - now % seconds + 1): + return True + return cache.incr(key) <= limit + except Exception: + logger.warning("Public submission rate-limit cache unavailable", exc_info=True) + return False diff --git a/django_forms_workflows/views.py b/django_forms_workflows/views.py index fdc5371..3853e9a 100644 --- a/django_forms_workflows/views.py +++ b/django_forms_workflows/views.py @@ -25,6 +25,13 @@ from .forms import DynamicForm from .models import ApprovalTask, AuditLog, FormDefinition, FormField, FormSubmission +from .pdf import pdf_url_fetcher +from .security import ( + grant_submission_receipt, + render_success_message, + require_submission_receipt, +) +from .uploads import clear_uploads, draft_data, prepare_uploads, reject_invalid_uploads from .utils import ( check_rate_limit, user_can_approve, @@ -324,6 +331,7 @@ def form_qr_code(request, slug): return response +@reject_invalid_uploads def form_submit(request, slug): """Submit a form — supports both authenticated and anonymous (public) access.""" form_def = get_object_or_404(FormDefinition, slug=slug, is_active=True) @@ -404,16 +412,10 @@ def form_submit(request, slug): values = request.POST.getlist(k) raw_data[k] = values[0] if len(values) == 1 else values - # Recover previously-stashed file metadata so it persists in draft. - prev_stashed = request.POST.get("_stashed_files") - if prev_stashed: - try: - prev = json.loads(prev_stashed) - if isinstance(prev, dict): - raw_data.update(prev) - except (json.JSONDecodeError, TypeError): - # Ignore malformed client metadata; draft data remains usable. - pass + raw_data = draft_data( + form_def, raw_data, draft.form_data if draft else None + ) + raw_data.update(prepare_uploads(request, form_def)) draft_obj, created = FormSubmission.objects.update_or_create( form_definition=form_def, @@ -426,9 +428,7 @@ def form_submit(request, slug): }, ) - # Persist uploaded files with the draft - if request.FILES: - _save_draft_files(request.FILES, draft_obj, form_def) + clear_uploads(request, form_def) AuditLog.objects.create( action="update" if not created else "create", @@ -445,27 +445,12 @@ def form_submit(request, slug): # Full submission: validate then save. # # ------------------------------------------------------------------ # - # Eagerly persist uploaded files so they survive a validation failure. - stashed_files = ( - _stash_uploaded_files(request.FILES, form_def) if request.FILES else {} - ) - - # Recover previously-stashed file metadata from the hidden input - # (carried forward from a prior validation-failure render). - prev_stashed = request.POST.get("_stashed_files") - if prev_stashed: - try: - prev = json.loads(prev_stashed) - if isinstance(prev, dict): - for k, v in prev.items(): - stashed_files.setdefault(k, v) - except (json.JSONDecodeError, TypeError): - # Ignore malformed client metadata and keep newly stashed uploads. - pass + stashed_files = prepare_uploads(request, form_def) form = DynamicForm( form_definition=form_def, user=user_or_none, + initial_data=draft.form_data if draft else None, data=request.POST, files=request.FILES, stashed_files=stashed_files or None, @@ -505,6 +490,7 @@ def form_submit(request, slug): if form_def.payment_enabled and form_def.payment_provider: submission.status = "pending_payment" submission.save() + clear_uploads(request, form_def) # Bind a short-lived checkout credential to this session so # only this browser can complete the payment for it. from .payments.checkout import grant_checkout_access @@ -526,6 +512,7 @@ def form_submit(request, slug): submission.status = "submitted" submission.submitted_at = timezone.now() submission.save() + clear_uploads(request, form_def) AuditLog.objects.create( action="submit", @@ -562,6 +549,7 @@ def form_submit(request, slug): # 3. Custom success message (rendered on a dedicated page) if form_def.success_message: + grant_submission_receipt(request, submission) return redirect( reverse( "forms_workflows:submission_success", @@ -609,6 +597,7 @@ def form_submit(request, slug): @xframe_options_exempt +@reject_invalid_uploads def form_embed(request, slug): """Render a form in a minimal layout for iframe embedding.""" form_def = get_object_or_404(FormDefinition, slug=slug, is_active=True) @@ -618,7 +607,9 @@ def form_embed(request, slug): is_anonymous = not request.user.is_authenticated - # Authentication gate (same as form_submit but no redirect — just 403) + # Embedding must not weaken the form authentication policy. + if is_anonymous and form_def.requires_login: + return HttpResponseForbidden("Login required.") if not is_anonymous: if not user_can_view_form(request.user, form_def) or not user_can_submit_form( request.user, form_def @@ -653,6 +644,17 @@ def form_embed(request, slug): }, ) + if ( + form_def.one_per_user + and not is_anonymous + and FormSubmission.objects.filter( + form_definition=form_def, submitter=request.user + ) + .exclude(status__in=["draft", "withdrawn"]) + .exists() + ): + return HttpResponseForbidden("You have already submitted this form.") + user_or_none = request.user if not is_anonymous else None theme = request.GET.get("theme", "") # Sanitise accent_color to a valid hex colour to prevent CSS injection @@ -675,9 +677,7 @@ def form_embed(request, slug): }, ) - stashed_files = ( - _stash_uploaded_files(request.FILES, form_def) if request.FILES else {} - ) + stashed_files = prepare_uploads(request, form_def) form = DynamicForm( form_definition=form_def, @@ -710,6 +710,7 @@ def form_embed(request, slug): if form_def.payment_enabled and form_def.payment_provider: submission.status = "pending_payment" submission.save() + clear_uploads(request, form_def) # Bind a short-lived checkout credential to this session so # only this browser can complete the payment for it. from .payments.checkout import grant_checkout_access @@ -725,6 +726,7 @@ def form_embed(request, slug): submission.status = "submitted" submission.submitted_at = timezone.now() submission.save() + clear_uploads(request, form_def) AuditLog.objects.create( action="submit", @@ -739,7 +741,7 @@ def form_embed(request, slug): # Render inline success (no redirect) success_message = "" if form_def.success_message: - success_message = _pipe_answer_tokens( + success_message = render_success_message( form_def.success_message, submission.form_data or {} ) @@ -814,10 +816,11 @@ def _repl(m): def submission_success(request, submission_id): """Custom success page with answer-piped content.""" submission = get_object_or_404(FormSubmission, id=submission_id) + require_submission_receipt(request, submission) form_def = submission.form_definition form_data = submission.form_data or {} - rendered_message = _pipe_answer_tokens(form_def.success_message, form_data) + rendered_message = render_success_message(form_def.success_message, form_data) return render( request, @@ -875,7 +878,7 @@ def form_auto_save(request, slug): status="draft", ) - draft.form_data = data + draft.form_data = draft_data(form_def, data, draft.form_data) draft.submission_ip = get_client_ip(request) draft.user_agent = request.META.get("HTTP_USER_AGENT", "") @@ -2708,7 +2711,9 @@ def submission_pdf(request, submission_id): from weasyprint import HTML base_url = request.build_absolute_uri("/") - pdf_bytes = HTML(string=html_string, base_url=base_url).write_pdf() + pdf_bytes = HTML( + string=html_string, base_url=base_url, url_fetcher=pdf_url_fetcher() + ).write_pdf() except ImportError: return HttpResponse( "PDF generation requires the weasyprint package. " @@ -2845,65 +2850,6 @@ def _parse_spreadsheet(file_obj): return {"headers": [], "rows": [], "error": "openpyxl not installed"} -def _save_draft_files(files, draft_obj, form_def): - """Persist uploaded files from *request.FILES* into the draft's form_data. - - Called during the draft-save path so that file uploads are not lost when a - user clicks "Save Draft" instead of "Submit". - """ - file_field_names = set( - form_def.fields.filter( - field_type__in=("file", "multifile", "spreadsheet") - ).values_list("field_name", flat=True) - ) - for field_name in files: - if field_name not in file_field_names: - continue - file_list = files.getlist(field_name) - if len(file_list) > 1: - # multifile - draft_obj.form_data[field_name] = [ - _serialize_single_file(f, f"{field_name}_{i}", draft_obj.pk) - for i, f in enumerate(file_list) - ] - else: - draft_obj.form_data[field_name] = _serialize_single_file( - file_list[0], field_name, draft_obj.pk - ) - draft_obj.save() - - -def _stash_uploaded_files(files, form_def): - """Save uploaded files to storage eagerly (before validation). - - Returns a dict of ``{field_name: file_info_dict}`` for every file field - found in *files*. The caller should merge this into the form's - ``initial_data`` on a validation-failure re-render so that the template - can display the already-uploaded filename and the file field can be made - optional. - """ - file_field_names = set( - form_def.fields.filter( - field_type__in=("file", "multifile", "spreadsheet") - ).values_list("field_name", flat=True) - ) - stashed = {} - for field_name in files: - if field_name not in file_field_names: - continue - file_list = files.getlist(field_name) - if len(file_list) > 1: - stashed[field_name] = [ - _serialize_single_file(f, f"{field_name}_{i}", "pending") - for i, f in enumerate(file_list) - ] - else: - stashed[field_name] = _serialize_single_file( - file_list[0], field_name, "pending" - ) - return stashed - - def serialize_form_data(data, submission_id=None, existing_file_data=None): """ Convert form data to JSON-serializable format. @@ -2934,7 +2880,7 @@ def serialize_form_data(data, submission_id=None, existing_file_data=None): serialized[key] = str(value) elif isinstance(value, list) and value and hasattr(value[0], "read"): # Multi-file upload: list of file objects - serialized[key] = [ + serialized[key] = existing_file_data.get(key) or [ _serialize_single_file(f, f"{key}_{i}", submission_id) for i, f in enumerate(value) ] @@ -2943,7 +2889,9 @@ def serialize_form_data(data, submission_id=None, existing_file_data=None): if name.lower().endswith((".csv", ".xlsx", ".xls")): serialized[key] = _parse_spreadsheet(value) else: - serialized[key] = _serialize_single_file(value, key, submission_id) + serialized[key] = existing_file_data.get(key) or _serialize_single_file( + value, key, submission_id + ) elif not value and key in existing_file_data: # No new file uploaded — carry forward the previously-stored file. serialized[key] = existing_file_data[key] @@ -2977,7 +2925,9 @@ def save_uploaded_file(file_obj, field_name, submission_id=None): """ try: # Generate a unique path for the file - timestamp = timezone.now().strftime("%Y%m%d_%H%M%S") + import uuid + + timestamp = uuid.uuid4().hex sub_id = submission_id or "temp" # Sanitize filename @@ -3743,13 +3693,9 @@ def _resolve_attachments(attachments): def get_client_ip(request): - """Get client IP address from request""" - x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") - if x_forwarded_for: - ip = x_forwarded_for.split(",")[0] - else: - ip = request.META.get("REMOTE_ADDR") - return ip + from .utils import _get_client_ip + + return _get_client_ip(request) @login_required @@ -4048,7 +3994,9 @@ def bulk_export_submissions_pdf(request): try: base_url = request.build_absolute_uri("/") - pdf_bytes = WeasyPrintHTML(string=html_string, base_url=base_url).write_pdf() + pdf_bytes = WeasyPrintHTML( + string=html_string, base_url=base_url, url_fetcher=pdf_url_fetcher() + ).write_pdf() except Exception as exc: logger.error("WeasyPrint bulk PDF error: %s", exc) return HttpResponse("An error occurred while generating the PDF.", status=500) diff --git a/django_forms_workflows/workflow_builder_views.py b/django_forms_workflows/workflow_builder_views.py index d763ac6..ce2cf9b 100644 --- a/django_forms_workflows/workflow_builder_views.py +++ b/django_forms_workflows/workflow_builder_views.py @@ -25,6 +25,7 @@ WorkflowDefinition, WorkflowStage, ) +from .security import builder_permission_required logger = logging.getLogger(__name__) @@ -451,6 +452,7 @@ def _serialize_notification_rule(rule, stage_node_id=None): @staff_member_required @require_GET +@builder_permission_required("view_formdefinition", "view_workflowdefinition") def workflow_builder_view(request, form_id): """ Main workflow builder page. @@ -458,7 +460,7 @@ def workflow_builder_view(request, form_id): form_definition = get_object_or_404(FormDefinition, id=form_id) workflow_id = request.GET.get("workflow_id") workflow, workflows = _resolve_builder_workflow( - form_definition, workflow_id=workflow_id, create_if_missing=True + form_definition, workflow_id=workflow_id ) workflow_tracks = [ { @@ -468,18 +470,25 @@ def workflow_builder_view(request, form_id): for idx, wf in enumerate(workflows, start=1) ] selected_index = next( - (idx for idx, wf in enumerate(workflows, start=1) if wf.id == workflow.id), None + ( + idx + for idx, wf in enumerate(workflows, start=1) + if workflow and wf.id == workflow.id + ), + None, ) context = { "form_definition": form_definition, "form_id": form_id, - "workflow_id": workflow.id, + "workflow_id": workflow.id if workflow else None, "workflow_count": len(workflows), "workflow_tracks": workflow_tracks, "selected_workflow_label": _workflow_display_name( workflow, index=selected_index - ), + ) + if workflow + else "New workflow", } return render( @@ -489,6 +498,7 @@ def workflow_builder_view(request, form_id): @staff_member_required @require_GET +@builder_permission_required("view_formdefinition", "view_workflowdefinition") def workflow_builder_load(request, form_id): """ API endpoint to load workflow data as JSON. @@ -496,7 +506,7 @@ def workflow_builder_load(request, form_id): form_definition = get_object_or_404(FormDefinition, id=form_id) workflow_id = request.GET.get("workflow_id") workflow, workflows = _resolve_builder_workflow( - form_definition, workflow_id=workflow_id, create_if_missing=True + form_definition, workflow_id=workflow_id ) # Get form fields for condition/action configuration @@ -587,6 +597,7 @@ def workflow_builder_load(request, form_id): @staff_member_required @require_POST +@builder_permission_required("change_formdefinition", "save_workflowdefinition") def workflow_builder_save(request): """ API endpoint to save workflow data. @@ -624,7 +635,7 @@ def workflow_builder_save(request): { "success": True, "message": "Workflow saved successfully", - "workflow_id": workflow.id, + "workflow_id": workflow.id if workflow else None, } ) diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index cacd679..035e884 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -6,8 +6,8 @@ Get Django Forms Workflows up and running in 10 minutes! ## Prerequisites -- Python 3.10 or higher -- Django 5.2 or higher +- Python 3.11 or higher +- Django 5.2.18+, 6.0.9+, or 6.1.2+ - PostgreSQL 14+ (recommended) or MySQL 8.0+ ## Installation diff --git a/docs/SECURITY_UPGRADE.md b/docs/SECURITY_UPGRADE.md new file mode 100644 index 0000000..f2281a5 --- /dev/null +++ b/docs/SECURITY_UPGRADE.md @@ -0,0 +1,118 @@ +# Security upgrade notes + +This update fixes the security audit's account linking, submission receipt, +upload ownership, PDF resource fetching, embedded-form authentication, builder +permissions, analytics visibility, inactive API user, and public rate-limit issues. + +## Runtime and dependencies + +Use Python 3.11 or newer. Python 3.10 has reached end of life, and the patched +Pillow 13 PDF dependency requires Python 3.11. Django security minimums are +5.2.18, 6.0.9, and 6.1.2. The package rejects earlier vulnerable 6.x patch releases. +The lock resolves 5.2 LTS on Python 3.11–3.12 and 6.1 on Python 3.13+; deployments +remaining on LTS should explicitly constrain `Django>=5.2.18,<5.3`. + +Regenerate deployed environments from the updated dependencies/lock, including +optional PDF dependencies. Vitest and the development dependency jaraco.context +are updated too. Run `python scripts/audit_lock.py` with pip-audit installed to +check every locked version, including alternative Python-version resolutions. +No database schema migration is required by these fixes. + +## Identity and permissions + +Configure explicit SSO provider/subject bindings using [SSO_CONFIGURATION.md](SSO_CONFIGURATION.md). +Review existing social-auth associations before upgrading: removing automatic +linking does not undo an association previously created by unsafe matching. +SAML accounts require explicit bindings, and disabled accounts cannot authenticate +through either bindings or personal API tokens. + +Custom builder endpoints now require staff status **and** model permissions. +Grant `view_formdefinition` for viewing forms, `add_formdefinition` for creation +and cloning, and `change_formdefinition` for updates. A form definition is the +builder's aggregate: its fields are managed through that form's permission. +Workflow saves also require `change_formdefinition` and add/change permission on +`workflowdefinition`. Document-template and shared-list endpoints require the +corresponding model permissions; document changes also require change access to +the form. Sync import/pull require form add/change permissions. Review-only staff +accounts can no longer mutate these resources just by knowing the endpoint URL. + +Analytics now uses submission visibility: submitters see their own records, +assigned approvers and form admin/reviewer groups see permitted records, and +superusers see all records. Restricted metadata is excluded from chart data, +form filters, approval statistics, and CSV exports. + +## Public forms, receipts, and uploads + +Embedding honors `requires_login` and `one_per_user`. Public forms must explicitly +set `requires_login=False`. An anonymous success receipt is available only to the +submitting session for one hour. Other users and anonymous visitors receive a +non-disclosing 404 for private receipts. HTML answer tokens are escaped and the +resulting success message is sanitized, including URL attributes. + +Upload recovery is stored in the session for one hour, scoped to user and form. +Browser-supplied `_stashed_files` is ignored, so a page opened before the upgrade +may require the user to select the file again. Draft/autosave requests cannot +supply storage metadata. Existing files in an owned draft remain available. +New uploads are validated before storage, including draft uploads; the default +maximum is 32 MiB per file, configurable via `FORMS_WORKFLOWS_MAX_UPLOAD_MB`. +Form-specific extension/size limits also apply. File names used in storage gain +random identifiers; original display names are retained and HTML-escaped. + +Previously stored data is not automatically trusted merely because the application +has been patched. Review suspicious pre-upgrade file metadata and revoke exposed +storage URLs where appropriate. Serve uploads through private storage or protected +media delivery; never expose the entire media directory on the application origin. + +## PDF templates + +Template answer values are HTML-escaped. Both individual and bulk PDF rendering +block HTTP(S), local files, SVG data URLs, and other resource URLs by default. +Inline base64 PNG/JPEG images are supported up to 5 MiB via +`FORMS_WORKFLOWS_PDF_MAX_RESOURCE_BYTES`. Embed logos as bounded raster data URLs; +remote stylesheets/fonts and file-based images no longer load automatically. + +A deployment that needs more resources may set `FORMS_WORKFLOWS_PDF_URL_FETCHER` +to a dotted path to a **zero-argument factory** returning a fetcher compatible +with its installed WeasyPrint version. This is a trusted extension point: enforce +strict destination allowlists, validate redirect targets and resolved addresses, +and bound response size and time. Never configure WeasyPrint's unrestricted +fetcher for templates containing user data. + +## Rate limiting and example deployment + +Use a shared Redis or Memcached cache with atomic `add`/`incr` operations: + +```python +CACHES = {'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': 'redis://127.0.0.1:6379/1', +}} +FORMS_WORKFLOWS_RATE_LIMIT_CACHE = 'default' +FORMS_WORKFLOWS_PUBLIC_RATE_LIMIT = '10/hour' +# Only proxies you control. Leave empty for direct connections. +FORMS_WORKFLOWS_TRUSTED_PROXIES = ['10.0.0.10/32'] +``` + +Install the Redis client with `pip install 'django-forms-workflows[cache]'`. +Without trusted proxies, only `REMOTE_ADDR` is used. With trusted proxies, the +forwarded chain is walked from the right to the first untrusted address. Configure +the proxy to append/replace the actual client address. Cache failures reject public +submissions. Limits use fixed windows, and deployment checks flag caches that do +not provide shared atomic counters. + +The example defaults to production security settings. Set `DJANGO_SECRET_KEY` to +a private random secret, `DJANGO_ALLOWED_HOSTS` to your hostnames, and +`DJANGO_CACHE_URL` to your Redis endpoint. It enables HTTPS redirect, secure cookies, +and one-year HSTS for that host. After confirming that all subdomains require +HTTPS, set `DJANGO_HSTS_INCLUDE_SUBDOMAINS=1`; set `DJANGO_HSTS_PRELOAD=1` only if +that domain is intended for browser preloading. Django reports W005/W021 until +these explicit opt-ins are set. Configure proxy HTTPS detection in settings only +when the proxy strips untrusted forwarding headers. Do not expose seeded demo +accounts. For local development only, set `DJANGO_DEBUG=1`. + +Run `python manage.py check --deploy` with actual production settings before +rollout. Django 6.1's example uses `MAILERS`; older supported branches retain +`EMAIL_BACKEND`. Production uses SMTP: configure `DJANGO_EMAIL_HOST`, +`DJANGO_EMAIL_PORT`, `DJANGO_EMAIL_USER`, `DJANGO_EMAIL_PASSWORD`, and +`DJANGO_DEFAULT_FROM_EMAIL`. STARTTLS is enabled unless `DJANGO_EMAIL_USE_TLS=0`. +Set `FORMS_WORKFLOWS_BASE_URL` to the public HTTPS URL for notification links. diff --git a/docs/SSO_CONFIGURATION.md b/docs/SSO_CONFIGURATION.md index 7b51524..b6e9dc4 100644 --- a/docs/SSO_CONFIGURATION.md +++ b/docs/SSO_CONFIGURATION.md @@ -88,6 +88,29 @@ FORMS_WORKFLOWS_SSO = { } ``` +## Explicit account bindings + +Existing accounts are never matched by email prefix, email address, or an incoming +username. Existing python-social-auth provider/subject associations continue to work. +To link an external identity to an existing Django/LDAP account, configure its exact +provider identifier and stable subject returned by the provider: + +```python +FORMS_WORKFLOWS_SSO['account_bindings'] = { + 'google-oauth2': {'provider-subject-123': 'jdoe'}, + 'saml:https://idp.example.com/entity': {'exact-signed-NameID': 'jdoe'}, +} +``` + +For OAuth, keep `django_forms_workflows.sso_backends.link_to_existing_user` after +`social_core.pipeline.social_auth.social_user` and before `create_user` in the +pipeline. Never add `associate_by_email` to work around the binding requirement. +The provider name must identify a fixed trusted issuer/tenant in your backend +configuration; do not reuse it across untrusted tenants. SAML always requires an +explicit issuer/NameID binding and no longer creates accounts automatically. +Disabled accounts are rejected. Legacy `link_to_existing_user` and +`username_from_email` flags do not enable automatic matching. + ## Google OAuth2 Configuration ### 1. Create OAuth2 Credentials diff --git a/example_project/README.md b/example_project/README.md index fd4e8d3..40d9642 100644 --- a/example_project/README.md +++ b/example_project/README.md @@ -17,6 +17,17 @@ Instead of starting from a blank admin, it can seed a realistic showcase with: ## Quick start +Opt into local development before running the commands below: + +```bash +export DJANGO_DEBUG=1 +``` + +Without that opt-in, settings require `DJANGO_SECRET_KEY`, enforce HTTPS and secure +cookies, and use Redis (`DJANGO_CACHE_URL`). See [security upgrade notes](../docs/SECURITY_UPGRADE.md) +for production configuration. Seeded demo accounts must never be deployed publicly. + + 1. **Install dependencies** ```bash diff --git a/example_project/example/settings.py b/example_project/example/settings.py index 34d2638..541922d 100644 --- a/example_project/example/settings.py +++ b/example_project/example/settings.py @@ -4,18 +4,44 @@ This is a minimal example showing how to configure Django Form Workflows. """ +import os from pathlib import Path +import django +from django.core.exceptions import ImproperlyConfigured + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-example-key-change-in-production" - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = ["localhost", "127.0.0.1"] +# Explicitly opt into local development. Production fails closed without a secret. +DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1" +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "") +if not SECRET_KEY: + if not DEBUG: + raise ImproperlyConfigured( + "Set DJANGO_SECRET_KEY, or DJANGO_DEBUG=1 for local development." + ) + SECRET_KEY = "django-insecure-example-development-only" +ALLOWED_HOSTS = [ + host.strip() + for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + if host.strip() +] +SECURE_SSL_REDIRECT = not DEBUG +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG +SECURE_HSTS_SECONDS = 0 if DEBUG else 31536000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = ( + os.environ.get("DJANGO_HSTS_INCLUDE_SUBDOMAINS", "0") == "1" +) +SECURE_HSTS_PRELOAD = os.environ.get("DJANGO_HSTS_PRELOAD", "0") == "1" +if not DEBUG: + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": os.environ.get("DJANGO_CACHE_URL", "redis://127.0.0.1:6379/1"), + } + } # Application definition @@ -148,9 +174,40 @@ # EMAIL_HOST_USER = 'your-email@example.com' # EMAIL_HOST_PASSWORD = 'your-password' # DEFAULT_FROM_EMAIL = 'noreply@example.com' -# Development-friendly email backend and base URL for absolute links in emails -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" -FORMS_WORKFLOWS_BASE_URL = "http://localhost:8000" +# Console delivery is development-only; production uses explicit SMTP options. +_email_backend = ( + "django.core.mail.backends.console.EmailBackend" + if DEBUG + else "django.core.mail.backends.smtp.EmailBackend" +) +_smtp_options = { + "host": os.environ.get("DJANGO_EMAIL_HOST", "localhost"), + "port": int(os.environ.get("DJANGO_EMAIL_PORT", "587")), + "username": os.environ.get("DJANGO_EMAIL_USER", ""), + "password": os.environ.get("DJANGO_EMAIL_PASSWORD", ""), + "use_tls": os.environ.get("DJANGO_EMAIL_USE_TLS", "1") == "1", + "timeout": 10, +} +if django.VERSION >= (6, 1): + MAILERS = { + "default": { + "BACKEND": _email_backend, + "OPTIONS": {} if DEBUG else _smtp_options, + } + } +else: + EMAIL_BACKEND = _email_backend + EMAIL_HOST = _smtp_options["host"] + EMAIL_PORT = _smtp_options["port"] + EMAIL_HOST_USER = _smtp_options["username"] + EMAIL_HOST_PASSWORD = _smtp_options["password"] + EMAIL_USE_TLS = _smtp_options["use_tls"] + EMAIL_TIMEOUT = _smtp_options["timeout"] +DEFAULT_FROM_EMAIL = os.environ.get("DJANGO_DEFAULT_FROM_EMAIL", "webmaster@localhost") +FORMS_WORKFLOWS_BASE_URL = os.environ.get( + "FORMS_WORKFLOWS_BASE_URL", + "http://localhost:8000" if DEBUG else "https://localhost", +) # Login/Logout URLs diff --git a/package-lock.json b/package-lock.json index d4ca39b..a5cb5de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "name": "django-forms-workflows-js", "devDependencies": { "jsdom": "^25.0.0", - "vitest": "^4.1.11" + "vitest": "^4.1.12" } }, "node_modules/@asamuzakjp/css-color": { @@ -147,9 +147,9 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.154.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", - "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "dev": true, "license": "MIT", "funding": { @@ -157,9 +157,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", - "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "cpu": [ "arm" ], @@ -174,9 +174,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", - "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -191,9 +191,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", - "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -208,9 +208,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", - "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -225,9 +225,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", - "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -242,9 +242,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", - "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -259,9 +259,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", - "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -279,9 +279,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", - "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -299,9 +299,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", - "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -319,9 +319,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", - "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -339,9 +339,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", - "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -359,9 +359,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", - "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -379,9 +379,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", - "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -396,9 +396,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", - "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -413,9 +413,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", - "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -469,16 +469,16 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -487,13 +487,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -514,9 +514,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -527,13 +527,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -541,14 +541,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -557,9 +557,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -567,13 +567,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -623,9 +623,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -1542,13 +1542,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", - "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.154.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1558,21 +1558,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.14", - "@rolldown/binding-android-arm64": "1.2.14", - "@rolldown/binding-darwin-arm64": "1.2.14", - "@rolldown/binding-darwin-x64": "1.2.14", - "@rolldown/binding-freebsd-x64": "1.2.14", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", - "@rolldown/binding-linux-arm64-gnu": "1.2.14", - "@rolldown/binding-linux-arm64-musl": "1.2.14", - "@rolldown/binding-linux-ppc64-gnu": "1.2.14", - "@rolldown/binding-linux-s390x-gnu": "1.2.14", - "@rolldown/binding-linux-x64-gnu": "1.2.14", - "@rolldown/binding-linux-x64-musl": "1.2.14", - "@rolldown/binding-openharmony-arm64": "1.2.14", - "@rolldown/binding-win32-arm64-msvc": "1.2.14", - "@rolldown/binding-win32-x64-msvc": "1.2.14" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/rrweb-cssom": { @@ -1675,9 +1675,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -1809,19 +1809,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -1849,12 +1849,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 4d3448a..c0b9198 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "test:watch": "vitest" }, "devDependencies": { - "vitest": "^4.1.11", + "vitest": "^4.1.12", "jsdom": "^25.0.0" } } diff --git a/poetry.lock b/poetry.lock index 2b06eea..65c1f7a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "alabaster" @@ -40,11 +40,62 @@ files = [ {file = "asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e"}, ] +[package.extras] +tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] + +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "(extra == \"cache\" or extra == \"all\") and python_full_version < \"3.11.3\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "azure-core" +version = "1.41.0" +description = "Microsoft Azure Core Library for Python" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mssql\" or extra == \"all\"" +files = [ + {file = "azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d"}, + {file = "azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a"}, +] + [package.dependencies] -typing_extensions = {version = ">=4", markers = "python_version < \"3.11\""} +requests = ">=2.21.0" +typing-extensions = ">=4.6.0" [package.extras] -tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] +aio = ["aiohttp (>=3.0)"] +tracing = ["opentelemetry-api (>=1.26,<2.0)"] + +[[package]] +name = "azure-identity" +version = "1.26.0" +description = "Microsoft Azure Identity Library for Python" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"mssql\" or extra == \"all\"" +files = [ + {file = "azure_identity-1.26.0-py3-none-any.whl", hash = "sha256:3c60d9682b3ac01507388d474cb5a5ffbe54152a96b6cdcaa7013da9315cf186"}, + {file = "azure_identity-1.26.0.tar.gz", hash = "sha256:61b4538b87df10d2d6d44b77cd5ea28a8929fd4929d58b45bf3f230f7daba02e"}, +] + +[package.dependencies] +azure-core = ">=1.31.0" +cryptography = ">=2.5" +msal = ">=1.39.0" +msal-extensions = ">=1.2.0" +typing-extensions = ">=4.0.0" [[package]] name = "babel" @@ -69,7 +120,7 @@ description = "Backport of CPython tarfile module" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" +markers = "python_version == \"3.11\"" files = [ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, @@ -135,8 +186,6 @@ packaging = ">=22.0" pathspec = ">=1.0.0" platformdirs = ">=2" pytokens = ">=0.4.0,<0.5.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -151,7 +200,7 @@ description = "Python bindings for the Brotli compression library" optional = true python-versions = "*" groups = ["main"] -markers = "(extra == \"all\" or extra == \"pdf\") and platform_python_implementation == \"CPython\"" +markers = "(extra == \"pdf\" or extra == \"all\") and platform_python_implementation == \"CPython\"" files = [ {file = "brotli-1.2.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:09b560859a04d112d206639c7e444161ee2f413098aa6de11d43daf3ce883d24"}, {file = "brotli-1.2.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:66f171af80744c06f6b4e5b01fe6e40c70cc4e17570888746a281422fed83fc0"}, @@ -302,9 +351,10 @@ files = [ name = "brotlicffi" version = "1.2.1.0" description = "Python CFFI bindings to the Brotli library" -optional = false +optional = true python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] +markers = "(extra == \"pdf\" or extra == \"all\") and platform_python_implementation != \"CPython\"" files = [ {file = "brotlicffi-1.2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fea6f5450a6bd73ec222e92282d4db25030943946190697c1f2cd41087c59bc6"}, {file = "brotlicffi-1.2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b77704eb33e345e00d0a2854abf7966b86e1709a1f89bcc143b6c8437256d7c7"}, @@ -326,12 +376,11 @@ files = [ {file = "brotlicffi-1.2.1.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:52b99120543ffa3b0667e922c6a7dc6b6e01350f7fd38f21aaca511bb9f8b4f2"}, {file = "brotlicffi-1.2.1.0.tar.gz", hash = "sha256:5d0b89e959f643aab4c58c15c06e1e87b6f6605e5e88c3c1c28f5ce1c58c4e5f"}, ] -markers = {main = "(extra == \"all\" or extra == \"pdf\") and platform_python_implementation != \"CPython\""} [package.dependencies] cffi = [ - {version = ">=1.0.0", markers = "python_version < \"3.13\""}, {version = ">=1.17.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.0.0", markers = "python_version < \"3.13\""}, ] [[package]] @@ -496,7 +545,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {main = "(extra == \"all\" or extra == \"sso\" or extra == \"pdf\") and platform_python_implementation != \"PyPy\" or extra == \"all\" or extra == \"pdf\""} +markers = {main = "(extra == \"sso\" or extra == \"all\" or extra == \"mssql\" or extra == \"pdf\") and platform_python_implementation != \"PyPy\" or extra == \"pdf\" or extra == \"all\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -702,7 +751,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "(extra == \"dev\" or extra == \"docs\") and sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "(extra == \"docs\" or extra == \"dev\") and sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "coverage" @@ -807,9 +856,6 @@ files = [ ] markers = {main = "extra == \"dev\""} -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] @@ -887,11 +933,10 @@ files = [ {file = "cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9"}, {file = "cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9"}, ] -markers = {main = "extra == \"all\" or extra == \"sso\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\""} +markers = {main = "extra == \"sso\" or extra == \"all\" or extra == \"mssql\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\""} [package.dependencies] cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} -typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} [package.extras] ssh = ["bcrypt (>=3.1.5)"] @@ -903,7 +948,7 @@ description = "CSS selectors for Python ElementTree" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" +markers = "extra == \"pdf\" or extra == \"all\"" files = [ {file = "cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563"}, {file = "cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb"}, @@ -924,7 +969,7 @@ description = "XML bomb protection for Python stdlib modules" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" groups = ["main"] -markers = "extra == \"all\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"all\"" files = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, @@ -932,14 +977,15 @@ files = [ [[package]] name = "django" -version = "5.2.17" +version = "5.2.18" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" groups = ["main", "dev"] +markers = "python_version < \"3.13\"" files = [ - {file = "django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db"}, - {file = "django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f"}, + {file = "django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c"}, + {file = "django-5.2.18.tar.gz", hash = "sha256:461c5dd06d2ea16bd5ca37d3f46e4def1d6b0fe7588c6f4e2119517bb0af8b2d"}, ] [package.dependencies] @@ -951,6 +997,28 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django" +version = "6.1.2" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.12" +groups = ["main", "dev"] +markers = "python_version >= \"3.13\"" +files = [ + {file = "django-6.1.2-py3-none-any.whl", hash = "sha256:141efee6ec64d1db6db90683bf734c550102450f444fb099063b0be1bd27d991"}, + {file = "django-6.1.2.tar.gz", hash = "sha256:a1e92451ccb8b514e91bbb3b6d186d20b4030558f116b5d9de6535455ff210b7"}, +] + +[package.dependencies] +asgiref = ">=3.9.1" +sqlparse = ">=0.5.0" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=23.1.0)"] +bcrypt = ["bcrypt (>=4.1.1)"] + [[package]] name = "django-auth-ldap" version = "5.3.0" @@ -958,7 +1026,7 @@ description = "Django LDAP authentication backend" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"ldap\"" +markers = "extra == \"ldap\" or extra == \"all\"" files = [ {file = "django_auth_ldap-5.3.0-py3-none-any.whl", hash = "sha256:aa880415983149b072f876d976ef8ec755a438090e176817998263a6ed9e1038"}, {file = "django_auth_ldap-5.3.0.tar.gz", hash = "sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19"}, @@ -975,7 +1043,7 @@ description = "Provides a country field for Django models." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"picklists\"" +markers = "extra == \"picklists\" or extra == \"all\"" files = [ {file = "django_countries-8.2.0-py3-none-any.whl", hash = "sha256:2b2617bec7c15dc735bdec38ae89f0058e38fddfffdb19a7f6b75ef1e3d5380f"}, {file = "django_countries-8.2.0.tar.gz", hash = "sha256:6df3883180599052c7dfa9a8be0601792441cfb248935dc229ad1ac92e9e39e3"}, @@ -1010,7 +1078,7 @@ description = "Country-specific Django helpers" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"picklists\"" +markers = "extra == \"picklists\" or extra == \"all\"" files = [ {file = "django_localflavor-5.0-py3-none-any.whl", hash = "sha256:ee32749420cc4a93b18667939fe7fa0da36d71024a0d9578b40809a71489cee7"}, {file = "django_localflavor-5.0.tar.gz", hash = "sha256:9ddcce5dac6a956d076f3134071c64d4c76e1c7063e8aa7730414a2a1c5ded59"}, @@ -1054,7 +1122,6 @@ files = [ [package.dependencies] django = "*" django-stubs-ext = ">=5.2.7" -tomli = {version = "*", markers = "python_full_version < \"3.11.0\""} types-pyyaml = "*" typing-extensions = ">=4.11.0" @@ -1099,31 +1166,12 @@ description = "An implementation of lxml.xmlfile for the standard library" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"excel\"" +markers = "extra == \"excel\" or extra == \"all\"" files = [ {file = "et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa"}, {file = "et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54"}, ] -[[package]] -name = "exceptiongroup" -version = "1.3.0" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, -] -markers = {main = "extra == \"dev\" and python_version == \"3.10\"", dev = "python_version == \"3.10\""} - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "flake8" version = "7.3.0" @@ -1148,7 +1196,7 @@ description = "Tools to manipulate font files" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" +markers = "extra == \"pdf\" or extra == \"all\"" files = [ {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"}, {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"}, @@ -1227,7 +1275,7 @@ description = "Google API client core library" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9"}, {file = "google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7"}, @@ -1237,10 +1285,10 @@ files = [ google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.56.2,<2.0.0" proto-plus = [ - {version = ">=1.22.3,<2.0.0"}, {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, ] -protobuf = ">=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" requests = ">=2.18.0,<3.0.0" [package.extras] @@ -1256,15 +1304,15 @@ description = "Google API Client Library for Python" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "google_api_python_client-2.188.0-py3-none-any.whl", hash = "sha256:3cad1b68f9d48b82b93d77927e8370a6f43f33d97848242601f14a93a1c70ef5"}, {file = "google_api_python_client-2.188.0.tar.gz", hash = "sha256:5c469db6614f071009e3e5bb8b6aeeccae3beb3647fa9c6cd97f0d551edde0b6"}, ] [package.dependencies] -google-api-core = ">=1.31.5,<2.0 || >=2.3.dev0,!=2.3.0,<3.0.0" -google-auth = ">=1.32.0,!=2.24.0,!=2.25.0,<3.0.0" +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0" +google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" google-auth-httplib2 = ">=0.2.0,<1.0.0" httplib2 = ">=0.19.0,<1.0.0" uritemplate = ">=3.0.1,<5" @@ -1276,7 +1324,7 @@ description = "Google Authentication Library" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "google_auth-2.47.0-py3-none-any.whl", hash = "sha256:c516d68336bfde7cf0da26aab674a36fedcf04b37ac4edd59c597178760c3498"}, {file = "google_auth-2.47.0.tar.gz", hash = "sha256:833229070a9dfee1a353ae9877dcd2dec069a8281a4e72e72f77d4a70ff945da"}, @@ -1304,7 +1352,7 @@ description = "Google Authentication Library: httplib2 transport" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776"}, {file = "google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b"}, @@ -1321,14 +1369,14 @@ description = "Common protobufs used in Google APIs" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, ] [package.dependencies] -protobuf = ">=3.20.2,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [package.extras] grpc = ["grpcio (>=1.44.0,<2.0.0)"] @@ -1340,7 +1388,7 @@ description = "A comprehensive HTTP client library." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816"}, {file = "httplib2-0.32.0.tar.gz", hash = "sha256:48a0ef30a42db65d8f3399045e1d09ab0ba66e3b9efc360d07f80ea55d286025"}, @@ -1404,7 +1452,7 @@ description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version == \"3.11\"" files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, @@ -1442,7 +1490,7 @@ description = "An ISO 8601 date/time/duration parser and formatter" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"saml\" or extra == \"all\"" files = [ {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, @@ -1486,23 +1534,26 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-ena [[package]] name = "jaraco-context" -version = "6.0.1" +version = "6.1.2" description = "Useful decorators and context managers" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] -markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ - {file = "jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4"}, - {file = "jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3"}, + {file = "jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535"}, + {file = "jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"}, ] [package.dependencies] "backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} [package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] [[package]] name = "jaraco-functools" @@ -1638,7 +1689,7 @@ description = "Powerful and Pythonic XML processing library combining libxml2/li optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"saml\" or extra == \"all\"" files = [ {file = "lxml-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:41dcc4c7b10484257cbd6c37b83ddb26df2b0e5aff5ac00d095689015af868ec"}, {file = "lxml-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a31286dbb5e74c8e9a5344465b77ab4c5bd511a253b355b5ca2fae7e579fafec"}, @@ -1789,7 +1840,7 @@ description = "Python implementation of John Gruber's Markdown." optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"markdown\"" +markers = "extra == \"markdown\" or extra == \"all\"" files = [ {file = "markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea"}, {file = "markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f"}, @@ -1960,23 +2011,168 @@ files = [ {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, ] +[[package]] +name = "msal" +version = "1.39.0" +description = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"mssql\" or extra == \"all\"" +files = [ + {file = "msal-1.39.0-py3-none-any.whl", hash = "sha256:2d2577886906cd7293850dffa2da29119966c213bfc6ec0cecf8bf7621e1ca77"}, + {file = "msal-1.39.0.tar.gz", hash = "sha256:6ab7de335e6d7f5717e2c7e1dbf86e4dda2f6acf3c56773b78dc53ebc6395b5f"}, +] + +[package.dependencies] +cryptography = ">=2.5,<51" +PyJWT = {version = ">=1.0.0,<3", extras = ["crypto"]} +requests = ">=2.0.0,<3" + +[package.extras] +broker = ["pymsalruntime (>=0.20,<0.21) ; python_version >= \"3.9\" and platform_system == \"Darwin\"", "pymsalruntime (>=0.20,<0.21) ; python_version >= \"3.9\" and platform_system == \"Linux\"", "pymsalruntime (>=0.20,<0.21) ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +description = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"mssql\" or extra == \"all\"" +files = [ + {file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"}, + {file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"}, +] + +[package.dependencies] +msal = ">=1.29,<2" + +[package.extras] +portalocker = ["portalocker (>=1.4,<4)"] + [[package]] name = "mssql-django" -version = "1.6" +version = "2.0.0" description = "Django backend for Microsoft SQL Server" optional = true -python-versions = "*" +python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"mssql\"" +markers = "extra == \"mssql\" or extra == \"all\"" files = [ - {file = "mssql_django-1.6-py3-none-any.whl", hash = "sha256:1cfaee804de5b4a1fb1f5f11e9aa3dfc063103d0046a528e09c1066cb938da4b"}, - {file = "mssql_django-1.6.tar.gz", hash = "sha256:fc62791df0b4d01c62c36360e6da3c5f3d0f5e4dcba8793e7c90aa2d18d26afe"}, + {file = "mssql_django-2.0.0-py3-none-any.whl", hash = "sha256:a582b8b958032e95adba0b9af2dc67aa6ddae7562e3955672334ce7e5809ebf5"}, + {file = "mssql_django-2.0.0.tar.gz", hash = "sha256:c4eef774120d16ae291533e9100bb5be6cc35aef1713d7e79a7fe50b328bce64"}, ] [package.dependencies] -django = ">=3.2,<5.3" +django = ">=5.2,<6.2" +mssql-python = ">=1.15.0" pyodbc = ">=3.0" -pytz = "*" +tzdata = "*" + +[package.extras] +test = ["unittest-xml-reporting (>=3.2.0)"] + +[[package]] +name = "mssql-python" +version = "1.16.0" +description = "A Python library for interacting with Microsoft SQL Server" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mssql\" or extra == \"all\"" +files = [ + {file = "mssql_python-1.16.0-cp310-cp310-macosx_15_0_universal2.whl", hash = "sha256:643a09f8d8230a5676caef55b8a11cc6841b2412973a7de45e389185726a875a"}, + {file = "mssql_python-1.16.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:69c08909da3470051ff14a72b0278841318f46c0db80257bd6205c838c6d445f"}, + {file = "mssql_python-1.16.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:24908f3208a66950a96e0bb6c26d17970a13425b89045d033f8ccf0a0a5279d0"}, + {file = "mssql_python-1.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2ea07bc5d270327256003f5b1123b5d674e1fceefd87078d86ba4041cfa0b68b"}, + {file = "mssql_python-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1dd472531949f4dcc95775abc068c09b9624bda5745e4a60c4702baa844dbbe"}, + {file = "mssql_python-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:bf9cd36d41b19da499c5f8fb3264940bf4b42cd0e69d4c8e89897bfbc04d0cf2"}, + {file = "mssql_python-1.16.0-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:dd865dafc1ef3d075a1ae533721f14b8748745f8054db1d00a6c57349ba2b854"}, + {file = "mssql_python-1.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7c950dd78c2d83d572607814aafde69711f62424107614f09cb201ef804f29e2"}, + {file = "mssql_python-1.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:27a0650e92f7cc0b8a38f0384d2f9c7836276bec58907a8ab449893c2de34eb1"}, + {file = "mssql_python-1.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8e05f92488e69d0909b25cc15a1f550fc8b709001b29d37a2ce28f531e391613"}, + {file = "mssql_python-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d1d0c3d2f2591b774525c9395e3324fb6ca139047457e4da0ec5bfbc0d80e61c"}, + {file = "mssql_python-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:088077ea1ac6e1a02e2344d0c3a29bbad1c0dd5043046a3a0fd954631653cc86"}, + {file = "mssql_python-1.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:7fcdebb64838edf6a8e70fd2f0c341947848c904b63f0eb3132a02645ca939ac"}, + {file = "mssql_python-1.16.0-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:c78de1a285e69b382acb650f624c94ebbdd1df488924ebf97c0652cf28e18378"}, + {file = "mssql_python-1.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:bfabcaead8b5328761832b52c0930fc1e49a315ac1fb74268056be955ceb9200"}, + {file = "mssql_python-1.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5f705edb44bcf552c04cb229d1d433b2c67b1797d9e8d08ec0db4598c1784baf"}, + {file = "mssql_python-1.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:05aab12b99e51997e3ea4f70822fc2fcbc627990b4d0401f7c36b0eed9e16fca"}, + {file = "mssql_python-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87ac541db168ed66f1852c904d5a90d694fe86174d87ec097348869cbaa5cc6a"}, + {file = "mssql_python-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:cac56009aeea9ca6edfa9b37cacb05e4ce8b44feddba90483387ed46daff14fa"}, + {file = "mssql_python-1.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:d2d8d22e32bb8fd5b4d0013adab067d62a98413aeb59019bcd1601d14a7f7771"}, + {file = "mssql_python-1.16.0-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:214c91e5c19d2dc0c01f01d3ae0ad9b0394b138a8f45dcc703da0505cec34d98"}, + {file = "mssql_python-1.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:353f18425658779e2d713911d3e4bd9599b8735f94d7a5a5ef61224d8303d2a7"}, + {file = "mssql_python-1.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:22529e80dec460cb145980b5faf7531a8858dee623b4250c111b2e48c1fd75c9"}, + {file = "mssql_python-1.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:632d1e6b8d449922ee6bffcd64f747952715c7017c9b2119d7ae7d0a8d21c751"}, + {file = "mssql_python-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:306a6f8fe1b176d22e01f745c4748d2a30eeed1eaf5bb75bbcd4bba1811b1ca4"}, + {file = "mssql_python-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:c87d854e55c2afecc2a01464f43b68173429652ab08bb6153fb356ddbb738abd"}, + {file = "mssql_python-1.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:40d58f05e9bd65b3edccd126584a34b7d62358c31ae886421168c2957acd9c50"}, + {file = "mssql_python-1.16.0-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:fe556d426a0104c5079fbf8aba3f4ef8e0f57d7e8690e8c6b4b46b27278fa2ce"}, + {file = "mssql_python-1.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:14186264e85628df4e7ffa8deb54c1715291beb60747f1de9ef714b1794d4042"}, + {file = "mssql_python-1.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:1a5ed6da1f9bd76483c841249135159d37d126ff6d00fbfa724eeef2ee951aba"}, + {file = "mssql_python-1.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a86bca67dddd3af3c57d84731dda24001dcd1eb176d6dfa46aaf119043bbefc4"}, + {file = "mssql_python-1.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79cdc0edb974c55ba0f3f12824baac3e53a3ab44c67a60c023c2c84b114d0ead"}, + {file = "mssql_python-1.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:7cdbd510b6b30538665ce717ac7bedf298b43742034ce5a50ddeef45ef7db7b1"}, + {file = "mssql_python-1.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:f6e67704873289d701ece7b8cb0da6e38e3cd0d1b0edef5bc49dba5576141228"}, + {file = "mssql_python-1.16.0-cp315-cp315-macosx_15_0_universal2.whl", hash = "sha256:9354a5ea5eea367c1f87fa12fa90ffbf727c30bcecdbc1f7ce6f910088fa2fa1"}, + {file = "mssql_python-1.16.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:6fd96baae8ee512ab2cc42d2d2035867fd030214825524eaf5270e29c3c9096c"}, + {file = "mssql_python-1.16.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:bd60b84eb8882dd083724ff9efe7f043918676bfe0a7848b4c1af8eec8d5f25e"}, + {file = "mssql_python-1.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:00e3abe71f570172b0e3251cbdb98fb21fd077b5352a4f1176de3f29417049a2"}, + {file = "mssql_python-1.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dc0bd13b8d6220ad844e51f4c70c8118b5058cfe923568661f71981b341bc3e7"}, + {file = "mssql_python-1.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:7518308c3d24a04f062b5e74d3f6396158ca3e38a513824c2bf219ece3e27fda"}, + {file = "mssql_python-1.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:6ede87f8c570ee38b8aa9730ba4e2a6db237d7ddbf52e1fb905b331ba58eb7c5"}, +] + +[package.dependencies] +azure-identity = ">=1.12.0" +mssql-python-odbc = "18.6.2.2" +mssql-python-rs = "0.3.0" + +[package.extras] +pyarrow = ["pyarrow (>=14.0.0)"] + +[[package]] +name = "mssql-python-odbc" +version = "18.6.2.2" +description = "Internal implementation package for mssql-python: Microsoft ODBC Driver 18 for SQL Server binaries. Not intended for direct use." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mssql\" or extra == \"all\"" +files = [ + {file = "mssql_python_odbc-18.6.2.2-py3-none-macosx_15_0_universal2.whl", hash = "sha256:dc7315c3269866213b60a51c7bf2f6358093503f8226ce3be8e45226d152a6e5"}, + {file = "mssql_python_odbc-18.6.2.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4571228f62f97ffbf427b88f5bb85380dcc1bb6e086375daa52d83226e0f62c6"}, + {file = "mssql_python_odbc-18.6.2.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f5ed985f6d9854345e3e65dbc38e0b574ff13d8e04e6a58492d37bb19e714553"}, + {file = "mssql_python_odbc-18.6.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0509da0fb839ffbb23d2ad4efefb2aa82e60a1c8e101c2f8a39b51cc19a9da31"}, + {file = "mssql_python_odbc-18.6.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2fe1663f1d45dad8e92bd98d27297a9bda915d312fe7a2694b31cbe06f10aee4"}, + {file = "mssql_python_odbc-18.6.2.2-py3-none-win_amd64.whl", hash = "sha256:dd4926c85ae3eee4bf630758b9500581951acf3481f7a15d6d28ee529763fe99"}, + {file = "mssql_python_odbc-18.6.2.2-py3-none-win_arm64.whl", hash = "sha256:716d53a8d4c5089de24e3a673d6e96f57303ee02f2cddf65cd0782f8cd906861"}, +] + +[[package]] +name = "mssql-python-rs" +version = "0.3.0" +description = "Rust-based native runtime (TDS core and ODBC driver) consumed by the mssql-python driver." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mssql\" or extra == \"all\"" +files = [ + {file = "mssql_python_rs-0.3.0-cp310-abi3-macosx_15_0_universal2.whl", hash = "sha256:2b054fdbafbc87341a3c5a37f6a8223d28433f68710507426bd20c8406719b58"}, + {file = "mssql_python_rs-0.3.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:18964708ca26f283f0d819ef4ab0d6ddefd8f99c98429e5ac51a9fe3b156fc80"}, + {file = "mssql_python_rs-0.3.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:57fb9802984f449be14315078942d93c3726d21f6e3148cf05497f959bb87f14"}, + {file = "mssql_python_rs-0.3.0-cp310-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:efede00ab7dce89c6336680f2bc54c6fe565761dd7093697e3ef9b0ac78040db"}, + {file = "mssql_python_rs-0.3.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e8a73283799d57b28d26d41412d1576e574cfa84ba2c941ef58e69b884b08ab6"}, + {file = "mssql_python_rs-0.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fffb09624143cdb128445ce25472037d5caddb7886420d5814fafd0a29c9d031"}, + {file = "mssql_python_rs-0.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:083e8f8c9f76edad075929ab6514fa07829aed3f39de04f2be5df170486c1a33"}, + {file = "mssql_python_rs-0.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:d04b8c1c4860129113664f1b695928cf84d7f9bb90c92739c1c12c6ec84a65d6"}, + {file = "mssql_python_rs-0.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:1e1d8096eb7c19ce7cb25c58ffbf3348f0bec01b9a7f3538aabf6add964fc306"}, +] + +[package.extras] +dev = ["maturin (>=1.0,<2.0)", "pytest (>=7.0)", "pytest-asyncio (>=0.21.0)", "python-dotenv (>=1.0.0)"] [[package]] name = "mypy" @@ -2029,7 +2225,6 @@ files = [ [package.dependencies] mypy_extensions = ">=1.0.0" pathspec = ">=0.9.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing_extensions = ">=4.6.0" [package.extras] @@ -2058,7 +2253,7 @@ description = "Python interface to MySQL" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"mysql\"" +markers = "extra == \"mysql\" or extra == \"all\"" files = [ {file = "mysqlclient-2.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:2e3c11f7625029d7276ca506f8960a7fd3c5a0a0122c9e7404e6a8fe961b3d22"}, {file = "mysqlclient-2.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687"}, @@ -2072,38 +2267,39 @@ files = [ [[package]] name = "nh3" -version = "0.3.2" +version = "0.3.7" description = "Python binding to Ammonia HTML sanitizer Rust crate" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ - {file = "nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d"}, - {file = "nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130"}, - {file = "nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b"}, - {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5"}, - {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31"}, - {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99"}, - {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868"}, - {file = "nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93"}, - {file = "nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13"}, - {file = "nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80"}, - {file = "nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87"}, - {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a"}, - {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131"}, - {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0"}, - {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6"}, - {file = "nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b"}, - {file = "nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe"}, - {file = "nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104"}, - {file = "nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376"}, + {file = "nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba"}, + {file = "nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b"}, + {file = "nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32"}, + {file = "nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa"}, + {file = "nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac"}, + {file = "nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102"}, + {file = "nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a"}, + {file = "nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946"}, + {file = "nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d"}, + {file = "nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877"}, + {file = "nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5"}, + {file = "nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479"}, + {file = "nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506"}, + {file = "nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086"}, + {file = "nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563"}, + {file = "nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174"}, + {file = "nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42"}, + {file = "nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8"}, + {file = "nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493"}, + {file = "nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd"}, + {file = "nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac"}, + {file = "nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62"}, + {file = "nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af"}, + {file = "nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59"}, + {file = "nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc"}, + {file = "nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a"}, + {file = "nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848"}, ] [[package]] @@ -2113,7 +2309,7 @@ description = "A generic, spec-compliant, thorough implementation of the OAuth r optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"all\"" files = [ {file = "oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd"}, {file = "oauthlib-4.0.0.tar.gz", hash = "sha256:efb274799819440f95b4ab3b818869f1ce9ae26c5beacba0201d1a1b76b54f86"}, @@ -2131,7 +2327,7 @@ description = "A Python library to read/write Excel 2010 xlsx/xlsm files" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"excel\"" +markers = "extra == \"excel\" or extra == \"all\"" files = [ {file = "openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"}, {file = "openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050"}, @@ -2171,100 +2367,96 @@ re2 = ["google-re2 (>=1.1)"] [[package]] name = "pillow" -version = "12.3.0" +version = "13.0.0" description = "Python Imaging Library (fork)" optional = true -python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" -files = [ - {file = "pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a"}, - {file = "pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7"}, - {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f"}, - {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec"}, - {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468"}, - {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed"}, - {file = "pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1"}, - {file = "pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb"}, - {file = "pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f"}, - {file = "pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756"}, - {file = "pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6"}, - {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd"}, - {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd"}, - {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c"}, - {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5"}, - {file = "pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b"}, - {file = "pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a"}, - {file = "pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26"}, - {file = "pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965"}, - {file = "pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"}, - {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9"}, - {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91"}, - {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c"}, - {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df"}, - {file = "pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f"}, - {file = "pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09"}, - {file = "pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510"}, - {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89"}, - {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace"}, - {file = "pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec"}, - {file = "pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66"}, - {file = "pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35"}, - {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65"}, - {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3"}, - {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a"}, - {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e"}, - {file = "pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f"}, - {file = "pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8"}, - {file = "pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b"}, - {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330"}, - {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217"}, - {file = "pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930"}, - {file = "pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8"}, - {file = "pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0"}, - {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321"}, - {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b"}, - {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198"}, - {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130"}, - {file = "pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a"}, - {file = "pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d"}, - {file = "pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838"}, - {file = "pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e"}, - {file = "pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17"}, - {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385"}, - {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c"}, - {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d"}, - {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931"}, - {file = "pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7"}, - {file = "pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c"}, - {file = "pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45"}, - {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139"}, - {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402"}, - {file = "pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c"}, - {file = "pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f"}, - {file = "pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701"}, - {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace"}, - {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4"}, - {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39"}, - {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71"}, - {file = "pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827"}, - {file = "pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5"}, - {file = "pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658"}, - {file = "pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf"}, - {file = "pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64"}, - {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e"}, - {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777"}, - {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1"}, - {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9"}, - {file = "pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8"}, - {file = "pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418"}, - {file = "pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59"}, - {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468"}, - {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94"}, - {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e"}, - {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3"}, - {file = "pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a"}, - {file = "pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce"}, +python-versions = ">=3.11" +groups = ["main"] +markers = "extra == \"pdf\" or extra == \"all\"" +files = [ + {file = "pillow-13.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb"}, + {file = "pillow-13.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37290fbef0c73e2d51d594eb78fded7dc7170d1c3ad0a890269e6fd8c8c4e061"}, + {file = "pillow-13.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c88c10dd432f3b39ccdafc9ed81d0c21b8fd634502574da76d1bfa4fe2c76d0"}, + {file = "pillow-13.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a625df9a2a5597fa5e5266e01aefc82604bb85d291e3547981e863560d75da6c"}, + {file = "pillow-13.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:10b5019883c98f1dd819ca48ffc6f33a91cee04948fe637c605f4345c7cafd6f"}, + {file = "pillow-13.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f391dc92a015c478745192fe4f2e120c2af2aba18d9fe0616341cbf537599806"}, + {file = "pillow-13.0.0-cp311-cp311-win32.whl", hash = "sha256:6a0920cb55df5a4d3f6862964908078c0bd59332bdd1bcd20d14e8c70ef27cc5"}, + {file = "pillow-13.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:d7710eb676e9bb68965cb045be0d333b4c0d7f9dfbaa7fbf121385b1588683d3"}, + {file = "pillow-13.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:12fbb851cf323cc19aac3eea1df5ad17b57e91e9fe3cfd06100dba7bb8208c13"}, + {file = "pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79"}, + {file = "pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52"}, + {file = "pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d"}, + {file = "pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca"}, + {file = "pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620"}, + {file = "pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40"}, + {file = "pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27"}, + {file = "pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b"}, + {file = "pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51"}, + {file = "pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00"}, + {file = "pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033"}, + {file = "pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599"}, + {file = "pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe"}, + {file = "pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313"}, + {file = "pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369"}, + {file = "pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582"}, + {file = "pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81"}, + {file = "pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502"}, + {file = "pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8"}, + {file = "pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42"}, + {file = "pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f"}, + {file = "pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c"}, + {file = "pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76"}, + {file = "pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981"}, + {file = "pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4"}, + {file = "pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d"}, + {file = "pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6"}, + {file = "pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d"}, + {file = "pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c"}, + {file = "pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0"}, + {file = "pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055"}, + {file = "pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a"}, + {file = "pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4"}, + {file = "pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa"}, + {file = "pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f"}, + {file = "pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1"}, + {file = "pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683"}, + {file = "pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058"}, + {file = "pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e"}, + {file = "pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15"}, + {file = "pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0"}, + {file = "pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692"}, + {file = "pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521"}, + {file = "pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc"}, + {file = "pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac"}, + {file = "pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d"}, + {file = "pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782"}, + {file = "pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071"}, + {file = "pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf"}, + {file = "pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3"}, + {file = "pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932"}, + {file = "pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81"}, + {file = "pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6"}, + {file = "pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0"}, + {file = "pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f"}, + {file = "pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88"}, + {file = "pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb"}, + {file = "pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe"}, + {file = "pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22"}, + {file = "pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630"}, + {file = "pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2"}, + {file = "pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43"}, + {file = "pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8"}, + {file = "pillow-13.0.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:405be6058e206a86fedce2f715ecc2343f94072d7925251632dffa2c05f70dca"}, + {file = "pillow-13.0.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:b84be41387fc00db28c13c02e30d42b02d8d8f6a4a741a1aee6012ca7f1f6039"}, + {file = "pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a192360d512305fe92d3053e4f7627bbad1f7708f1f4ad38cccd9336acabe9ad"}, + {file = "pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83b37abd87b590acd8f47ca8a61f70893322b11834d36e07c2298dacad4d14d2"}, + {file = "pillow-13.0.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:73ee25cf8cf8b75d5a5aeed7cb6588f5e5dbfbb0fde51707b630bbb23897645c"}, + {file = "pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463"}, + {file = "pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e"}, + {file = "pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63"}, + {file = "pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61"}, + {file = "pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a"}, + {file = "pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510"}, ] [package.extras] @@ -2272,7 +2464,7 @@ docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybut fpx = ["olefile"] mic = ["olefile"] test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] -tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"] +tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "psutil ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] [[package]] @@ -2331,7 +2523,7 @@ description = "Beautiful, Pythonic protocol buffers" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82"}, {file = "proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4"}, @@ -2350,7 +2542,7 @@ description = "" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b"}, {file = "protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c"}, @@ -2371,7 +2563,7 @@ description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"postgresql\"" +markers = "extra == \"postgresql\" or extra == \"all\"" files = [ {file = "psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c"}, {file = "psycopg2_binary-2.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2"}, @@ -2449,7 +2641,7 @@ description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"ldap\" or extra == \"gmail\"" +markers = "extra == \"ldap\" or extra == \"all\" or extra == \"gmail\"" files = [ {file = "pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b"}, {file = "pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81"}, @@ -2462,7 +2654,7 @@ description = "A collection of ASN.1-based protocols modules" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"ldap\" or extra == \"gmail\"" +markers = "extra == \"ldap\" or extra == \"all\" or extra == \"gmail\"" files = [ {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, @@ -2494,7 +2686,7 @@ files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] -markers = {main = "(extra == \"all\" or extra == \"sso\" or extra == \"pdf\") and (platform_python_implementation != \"PyPy\" or extra == \"all\" or extra == \"pdf\") and implementation_name != \"PyPy\"", dev = "implementation_name != \"PyPy\""} +markers = {main = "(extra == \"sso\" or extra == \"all\" or extra == \"mssql\" or extra == \"pdf\") and (platform_python_implementation != \"PyPy\" or extra == \"pdf\" or extra == \"all\") and implementation_name != \"PyPy\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""} [[package]] name = "pydyf" @@ -2503,7 +2695,7 @@ description = "A low-level PDF generator." optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" +markers = "extra == \"pdf\" or extra == \"all\"" files = [ {file = "pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc"}, {file = "pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095"}, @@ -2536,7 +2728,7 @@ files = [ {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, ] -markers = {main = "extra == \"dev\" or extra == \"docs\""} +markers = {main = "extra == \"docs\" or extra == \"dev\""} [package.extras] windows-terminal = ["colorama (>=0.4.6)"] @@ -2548,7 +2740,7 @@ description = "JSON Web Token implementation in Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"all\" or extra == \"mssql\"" files = [ {file = "pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8"}, {file = "pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4"}, @@ -2556,7 +2748,6 @@ files = [ [package.dependencies] cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} -typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] crypto = ["cryptography (>=3.4.0)"] @@ -2568,7 +2759,7 @@ description = "DB API module for ODBC" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"mssql\"" +markers = "extra == \"mssql\" or extra == \"all\"" files = [ {file = "pyodbc-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6682cdec78f1302d0c559422c8e00991668e039ed63dece8bf99ef62173376a5"}, {file = "pyodbc-5.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9cd3f0a9796b3e1170a9fa168c7e7ca81879142f30e20f46663b882db139b7d2"}, @@ -2643,7 +2834,7 @@ description = "pyparsing - Classes and methods to define and execute parsing gra optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, @@ -2659,7 +2850,7 @@ description = "Pure Python module to hyphenate text" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" +markers = "extra == \"pdf\" or extra == \"all\"" files = [ {file = "pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd"}, {file = "pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3"}, @@ -2684,12 +2875,10 @@ markers = {main = "extra == \"dev\""} [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] @@ -2769,7 +2958,7 @@ description = "Python modules for implementing LDAP clients" optional = true python-versions = ">=3.6" groups = ["main"] -markers = "extra == \"all\" or extra == \"ldap\"" +markers = "extra == \"ldap\" or extra == \"all\"" files = [ {file = "python_ldap-3.4.5.tar.gz", hash = "sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a"}, ] @@ -2797,7 +2986,7 @@ description = "Python module to handle standardized numbers and codes" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"all\" or extra == \"picklists\"" +markers = "extra == \"picklists\" or extra == \"all\"" files = [ {file = "python_stdnum-2.2-py3-none-any.whl", hash = "sha256:bdf98fd117a0ca152e4047aa8ad254bae63853d4e915ddd4e0effb33ba0e9260"}, {file = "python_stdnum-2.2.tar.gz", hash = "sha256:e95fcfa858a703d4a40130cb3eaac133c60d8808a7f3c98efeedac968c2479b9"}, @@ -2813,7 +3002,7 @@ description = "OpenID support for modern servers and consumers." optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"all\"" files = [ {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"}, {file = "python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b"}, @@ -2833,7 +3022,7 @@ description = "Saml Python Toolkit. Add SAML support to your Python software usi optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"saml\" or extra == \"all\"" files = [ {file = "python3-saml-1.16.0.tar.gz", hash = "sha256:97c9669aecabc283c6e5fb4eb264f446b6e006f5267d01c9734f9d8bffdac133"}, {file = "python3_saml-1.16.0-py2-none-any.whl", hash = "sha256:c49097863c278ff669a337a96c46dc1f25d16307b4bb2679d2d1733cc4f5176a"}, @@ -2842,7 +3031,7 @@ files = [ [package.dependencies] isodate = ">=0.6.1" -lxml = ">=4.6.5,!=4.7.0" +lxml = ">=4.6.5,<4.7.0 || >4.7.0" xmlsec = ">=1.3.9" [package.extras] @@ -2903,19 +3092,6 @@ files = [ [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] -[[package]] -name = "pytz" -version = "2025.2" -description = "World timezone definitions, modern and historical" -optional = true -python-versions = "*" -groups = ["main"] -markers = "extra == \"all\" or extra == \"mssql\"" -files = [ - {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, - {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, -] - [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -2949,6 +3125,30 @@ Pygments = ">=2.5.1" [package.extras] md = ["cmarkgfm (>=0.8.0)"] +[[package]] +name = "redis" +version = "8.1.0" +description = "Python client for Redis database and key-value store" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"cache\" or extra == \"all\"" +files = [ + {file = "redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb"}, + {file = "redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + +[package.extras] +circuit-breaker = ["pybreaker (>=1.4.0)"] +hiredis = ["hiredis (>=3.2.0)"] +jwt = ["pyjwt (>=2.13.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] +otel = ["opentelemetry-api (>=1.39.1)", "opentelemetry-exporter-otlp-proto-http (>=1.39.1)", "opentelemetry-sdk (>=1.39.1)"] +xxhash = ["xxhash (>=3.6.0,<3.7.0)"] + [[package]] name = "requests" version = "2.34.2" @@ -2978,7 +3178,7 @@ description = "OAuthlib authentication support for Requests." optional = true python-versions = ">=3.4" groups = ["main"] -markers = "extra == \"all\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"all\"" files = [ {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, @@ -3047,7 +3247,7 @@ description = "Pure-Python RSA implementation" optional = true python-versions = "<4,>=3.6" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, @@ -3110,7 +3310,7 @@ description = "QR Code and Micro QR Code generator for Python" optional = true python-versions = ">=3.5" groups = ["main"] -markers = "extra == \"all\" or extra == \"qr\"" +markers = "extra == \"qr\" or extra == \"all\"" files = [ {file = "segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7"}, {file = "segno-1.6.6.tar.gz", hash = "sha256:e60933afc4b52137d323a4434c8340e0ce1e58cec71439e46680d4db188f11b3"}, @@ -3148,7 +3348,7 @@ description = "Python Social Authentication, Django integration." optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"all\"" files = [ {file = "social_auth_app_django-6.0.1-py3-none-any.whl", hash = "sha256:652ec21105ef58a34ffd223a02c338bc03e5f56d357ee69c33283afd08442065"}, {file = "social_auth_app_django-6.0.1.tar.gz", hash = "sha256:43002de0530e2ad13ef067f54aba7185a05fc8527310006c5328dc0b5e7a4cfd"}, @@ -3169,7 +3369,7 @@ description = "Python social authentication made simple." optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"all\"" files = [ {file = "social_auth_core-5.1.1-py3-none-any.whl", hash = "sha256:fa320931bcfdb0d65f9d01c46f9fbf0c273b72184712ccb9ecdef8de71c75a24"}, {file = "social_auth_core-5.1.1.tar.gz", hash = "sha256:a1763474d8bb7300effc7a934bbae74a8eb24d9c968e0d50fcddc8a38cc13a39"}, @@ -3222,7 +3422,6 @@ sphinxcontrib-htmlhelp = ">=2.0.6" sphinxcontrib-jsmath = ">=1.0.1" sphinxcontrib-qthelp = ">=1.0.6" sphinxcontrib-serializinghtml = ">=1.1.9" -tomli = {version = ">=2", markers = "python_version < \"3.11\""} [package.extras] docs = ["sphinxcontrib-websupport"] @@ -3415,7 +3614,7 @@ description = "A tiny CSS parser" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" +markers = "extra == \"pdf\" or extra == \"all\"" files = [ {file = "tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661"}, {file = "tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957"}, @@ -3435,7 +3634,7 @@ description = "HTML parser based on the WHATWG HTML specification" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" +markers = "extra == \"pdf\" or extra == \"all\"" files = [ {file = "tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a"}, {file = "tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67"}, @@ -3448,59 +3647,6 @@ webencodings = ">=0.5.1" doc = ["sphinx", "sphinx_rtd_theme"] test = ["pytest", "ruff"] -[[package]] -name = "tomli" -version = "2.3.0" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, -] -markers = {main = "(extra == \"dev\" or extra == \"docs\") and python_version == \"3.10\"", dev = "python_version == \"3.10\""} - [[package]] name = "twine" version = "6.2.0" @@ -3519,7 +3665,7 @@ keyring = {version = ">=21.2.0", markers = "platform_machine != \"ppc64le\" and packaging = ">=24.0" readme-renderer = ">=35.0" requests = ">=2.20" -requests-toolbelt = ">=0.8.0,!=0.9.0" +requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" rfc3986 = ">=1.4.0" rich = ">=12.0.0" urllib3 = ">=1.26.0" @@ -3550,7 +3696,7 @@ files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {main = "extra == \"all\" or extra == \"picklists\" or python_version == \"3.10\""} +markers = {main = "extra == \"mssql\" or extra == \"all\" or extra == \"picklists\""} [[package]] name = "tzdata" @@ -3572,7 +3718,7 @@ description = "Implementation of RFC 6570 URI Templates" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"gmail\"" +markers = "extra == \"gmail\" or extra == \"all\"" files = [ {file = "uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686"}, {file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"}, @@ -3627,7 +3773,7 @@ description = "The Awesome Document Factory" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" +markers = "extra == \"pdf\" or extra == \"all\"" files = [ {file = "weasyprint-70.0-py3-none-any.whl", hash = "sha256:5043e55e38d2a2af2b2b871e869697b1f65dad5f8b4a3677961d04ceacf9c5fe"}, {file = "weasyprint-70.0.tar.gz", hash = "sha256:c263abf0e86c747b12af678b67f85f4abbfb97d18a20503031e7ba94e4b8cf8c"}, @@ -3654,7 +3800,7 @@ description = "Character encoding aliases for legacy web content" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" +markers = "extra == \"pdf\" or extra == \"all\"" files = [ {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, @@ -3667,7 +3813,7 @@ description = "Python bindings for the XML Security Library" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"saml\" or extra == \"sso\"" +markers = "extra == \"sso\" or extra == \"saml\" or extra == \"all\"" files = [ {file = "xmlsec-1.3.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d43d4f68ac6b11f6e1e69bcb389495f54da77bf1168b4de08f4a7785e47bbb"}, {file = "xmlsec-1.3.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea2d65749c4c6a35a3ba138debda2f910713a9f4f06b4647510c184c284d7c62"}, @@ -3736,7 +3882,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version == \"3.11\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, @@ -3757,7 +3903,7 @@ description = "Zopfli module for python" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"all\" or extra == \"pdf\"" +markers = "extra == \"pdf\" or extra == \"all\"" files = [ {file = "zopfli-0.4.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:4238d4d746d1095e29c9125490985e0c12ffd3654f54a24af551e2391e936d54"}, {file = "zopfli-0.4.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fdfb7ce9f5de37a5b2f75dd2642fd7717956ef2a72e0387302a36d382440db07"}, @@ -3777,7 +3923,8 @@ files = [ test = ["pytest"] [extras] -all = ["django-auth-ldap", "django-countries", "django-localflavor", "google-api-python-client", "google-auth", "markdown", "mssql-django", "mysqlclient", "openpyxl", "psycopg2-binary", "pyodbc", "python-ldap", "python3-saml", "segno", "social-auth-app-django", "social-auth-core", "weasyprint"] +all = ["django-auth-ldap", "django-countries", "django-localflavor", "google-api-python-client", "google-auth", "markdown", "mssql-django", "mysqlclient", "openpyxl", "pillow", "psycopg2-binary", "pyodbc", "python-ldap", "python3-saml", "redis", "segno", "social-auth-app-django", "social-auth-core", "weasyprint"] +cache = ["redis"] dev = ["pytest", "pytest-cov", "pytest-django", "ruff"] docs = ["sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme"] excel = ["openpyxl"] @@ -3786,7 +3933,7 @@ ldap = ["django-auth-ldap", "python-ldap"] markdown = ["markdown"] mssql = ["mssql-django", "pyodbc"] mysql = ["mysqlclient"] -pdf = ["weasyprint"] +pdf = ["pillow", "weasyprint"] picklists = ["django-countries", "django-localflavor"] postgresql = ["psycopg2-binary"] qr = ["segno"] @@ -3795,5 +3942,5 @@ sso = ["python3-saml", "social-auth-app-django", "social-auth-core"] [metadata] lock-version = "2.1" -python-versions = ">=3.10,<4.0" -content-hash = "2500feb414712a99f7dedcf1156c3c2ecf8a258bda080dd8e22e07daf489e64a" +python-versions = ">=3.11,<4.0" +content-hash = "05fbc0050dea7da3a8bceff0b71bcced3fb34c78f1937a008bc981279353fb25" diff --git a/pyproject.toml b/pyproject.toml index 6e9517b..40f1628 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,14 +24,16 @@ include = [ ] [tool.poetry.dependencies] -python = ">=3.10,<4.0" -Django = ">=5.1,<7.0" +python = ">=3.11,<4.0" +Django = ">=5.2.18,<7.0,!=6.0.0,!=6.0.1,!=6.0.2,!=6.0.3,!=6.0.4,!=6.0.5,!=6.0.6,!=6.0.7,!=6.0.8,!=6.1.0,!=6.1.1" django-crispy-forms = ">=2.0" crispy-bootstrap5 = ">=2.0" django-nested-admin = ">=4.0" celery = ">=5.3" python-decouple = ">=3.8" requests = ">=2.31" +nh3 = ">=0.3.2" +redis = { version = ">=5.0", optional = true } # Optional SSO dependencies social-auth-app-django = { version = ">=6.0", optional = true } @@ -54,6 +56,7 @@ google-api-python-client = { version = ">=2.100", optional = true } # Optional PDF generation weasyprint = { version = ">=60.0", optional = true } +pillow = { version = ">=13.0.0", optional = true } # Optional Excel export openpyxl = { version = ">=3.1", optional = true } @@ -80,6 +83,7 @@ pytest-cov = { version = ">=4.1", optional = true } ruff = { version = ">=0.4", optional = true } [tool.poetry.extras] +cache = ["redis"] ldap = ["django-auth-ldap", "python-ldap"] sso = ["social-auth-app-django", "social-auth-core", "python3-saml"] saml = ["python3-saml"] @@ -87,19 +91,20 @@ mssql = ["mssql-django", "pyodbc"] postgresql = ["psycopg2-binary"] mysql = ["mysqlclient"] gmail = ["google-auth", "google-api-python-client"] -pdf = ["weasyprint"] +pdf = ["weasyprint", "pillow"] excel = ["openpyxl"] qr = ["segno"] markdown = ["markdown"] picklists = ["django-countries", "django-localflavor"] docs = ["sphinx", "sphinx-rtd-theme", "sphinx-autodoc-typehints"] all = [ + "redis", "django-auth-ldap", "python-ldap", "social-auth-app-django", "social-auth-core", "python3-saml", "mssql-django", "pyodbc", "psycopg2-binary", "mysqlclient", "google-auth", "google-api-python-client", - "weasyprint", + "weasyprint", "pillow", "openpyxl", "segno", "markdown", @@ -117,6 +122,7 @@ flake8 = ">=6.0" mypy = ">=1.5" django-stubs = ">=4.2" twine = "^6.2.0" +jaraco-context = ">=6.1.0" ruff = "^0.14.3" [build-system] diff --git a/ruff.toml b/ruff.toml index 8c97eca..c483e6b 100644 --- a/ruff.toml +++ b/ruff.toml @@ -16,8 +16,8 @@ exclude = [ # Set line length to match Black's default line-length = 88 -# Target Python 3.10+ -target-version = "py310" +# Target Python 3.11+ +target-version = "py311" [lint] # Enable recommended rules diff --git a/scripts/audit_lock.py b/scripts/audit_lock.py new file mode 100644 index 0000000..d43586f --- /dev/null +++ b/scripts/audit_lock.py @@ -0,0 +1,43 @@ +"""Audit every locked version, including alternate Python-version resolutions.""" + +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path + +with Path("poetry.lock").open("rb") as source: + packages = tomllib.load(source)["package"] + +# pip-audit rejects duplicate names, so audit alternate versions in separate batches. +batches = [] +for package in packages: + name, version = package["name"], package["version"] + for batch in batches: + if name not in batch: + batch[name] = version + break + else: + batches.append({name: version}) + +failed = False +with tempfile.TemporaryDirectory() as directory: + for number, batch in enumerate(batches): + path = Path(directory) / f"requirements-{number}.txt" + path.write_text( + "".join(f"{name}=={version}\n" for name, version in batch.items()) + ) + result = subprocess.run( + [ + sys.executable, + "-m", + "pip_audit", + "-r", + str(path), + "--no-deps", + "--disable-pip", + ], + check=False, + ) + failed |= result.returncode != 0 +sys.exit(int(failed)) diff --git a/tests/settings.py b/tests/settings.py index 41791ab..ed990e5 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -2,6 +2,8 @@ Test settings for django-forms-workflows test suite. """ +import django + SECRET_KEY = "test-secret-key-not-for-production" DEBUG = True ALLOWED_HOSTS = ["*"] @@ -69,3 +71,8 @@ "enabled": False, }, } + +# Exercise the modern mail configuration while retaining the Django 5.2 test matrix. + +if django.VERSION >= (6, 1): + MAILERS = {"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}} diff --git a/tests/test_security_boundaries.py b/tests/test_security_boundaries.py new file mode 100644 index 0000000..fe3f70d --- /dev/null +++ b/tests/test_security_boundaries.py @@ -0,0 +1,602 @@ +"""Regression coverage for the security audit's authorization and input boundaries.""" + +import base64 +import json +import sys +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest +from django.contrib.auth.models import Permission +from django.core.cache import cache +from django.core.exceptions import PermissionDenied +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import RequestFactory +from django.urls import reverse + +from django_forms_workflows.models import ( + APIToken, + DocumentTemplate, + FormField, + FormSubmission, +) +from django_forms_workflows.pdf import safe_pdf_url_fetcher +from django_forms_workflows.security import ( + grant_submission_receipt, + render_success_message, +) +from django_forms_workflows.sso_backends import bound_sso_user, link_to_existing_user +from django_forms_workflows.utils import _get_client_ip, check_rate_limit + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def isolated_uploads(settings, tmp_path): + settings.MEDIA_ROOT = tmp_path + cache.clear() + + +@pytest.fixture +def auth_client(client, user): + client.force_login(user) + return client + + +def test_sso_does_not_link_email_prefix_or_full_email(superuser): + backend = SimpleNamespace(name="google-oauth2") + for email in [f"{superuser.username}@attacker.example", superuser.email]: + assert link_to_existing_user(backend, {"email": email}, uid="unbound") == {} + + +def test_sso_binding_requires_matching_provider_and_subject(settings, user): + settings.FORMS_WORKFLOWS_SSO = { + "account_bindings": {"google-oauth2": {"subject-123": user.username}} + } + backend = SimpleNamespace(name="google-oauth2") + assert link_to_existing_user(backend, {}, uid="subject-123")["user"] == user + assert link_to_existing_user(backend, {}, uid="different") == {} + assert bound_sso_user("different-provider", "subject-123") is None + user.is_active = False + user.save() + with pytest.raises(PermissionDenied): + link_to_existing_user(backend, {}, uid="subject-123") + with pytest.raises(PermissionDenied): + link_to_existing_user(backend, {}, user=user) + + +@pytest.mark.parametrize("bound", [False, True]) +def test_saml_uses_issuer_subject_binding(monkeypatch, settings, user, bound): + from django_forms_workflows.sso_views import SAMLACSView + + fake_auth = Mock() + fake_auth.get_errors.return_value = [] + fake_auth.is_authenticated.return_value = True + fake_auth.get_attributes.return_value = {"email": [user.email]} + fake_auth.get_nameid.return_value = "stable-subject" + monkeypatch.setitem( + sys.modules, + "onelogin.saml2.auth", + SimpleNamespace(OneLogin_Saml2_Auth=Mock(return_value=fake_auth)), + ) + settings.FORMS_WORKFLOWS_SSO = ( + {"account_bindings": {"saml:trusted-issuer": {"stable-subject": user.username}}} + if bound + else {} + ) + request = RequestFactory().post("/sso/saml/acs/") + with ( + patch("django_forms_workflows.sso_views.is_saml_available", return_value=True), + patch( + "django_forms_workflows.sso_views.get_saml_config", + return_value={"idp": {"entityId": "trusted-issuer"}}, + ), + patch("django_forms_workflows.sso_views.login") as login, + patch("django_forms_workflows.sso_backends.sync_user_ldap_groups"), + patch("django_forms_workflows.sso_backends.sync_user_ldap_attributes"), + ): + response = SAMLACSView.as_view()(request) + assert response.status_code == (302 if bound else 400) + assert login.called == bound + if bound: + assert login.call_args.args[1] == user + + +def test_success_receipt_rejects_anonymous_and_other_users( + client, user, approver_user, form_definition +): + form_definition.success_message = "Secret: {answer}" + form_definition.save() + sub = FormSubmission.objects.create( + form_definition=form_definition, submitter=user, form_data={"answer": "private"} + ) + url = reverse("forms_workflows:submission_success", args=[sub.pk]) + assert client.get(url).status_code == 404 + client.force_login(approver_user) + assert client.get(url).status_code == 404 + client.force_login(user) + assert b"private" in client.get(url).content + + +def test_public_receipt_is_session_bound_and_expires(client, form_definition): + sub = FormSubmission.objects.create(form_definition=form_definition, form_data={}) + url = reverse("forms_workflows:submission_success", args=[sub.pk]) + request = SimpleNamespace(session=client.session) + grant_submission_receipt(request, sub) + request.session.save() + assert client.get(url).status_code == 200 + from django.test import Client + + assert Client().get(url).status_code == 404 + session = client.session + session["dfw_receipts"] = {str(sub.pk): 0} + session.save() + assert client.get(url).status_code == 404 + + +def test_success_message_preserves_markup_but_escapes_answers(): + message = render_success_message( + '

Hello {name}

Continue', + {"name": '', "link": "javascript:alert(1)"}, + ) + assert "

Hello <img" in message + assert "alert(1)")} + if draft: + data["save_draft"] = "1" + response = auth_client.post( + reverse("forms_workflows:form_submit", args=[upload_form.slug]), data + ) + assert response.status_code == 400 + assert not list(settings.MEDIA_ROOT.rglob("*")) + + +@pytest.mark.parametrize("autosave", [False, True]) +def test_drafts_cannot_store_client_file_paths( + auth_client, upload_form, user, autosave +): + fake = {"filename": "secret.pdf", "path": "uploads/victim/secret.pdf"} + if autosave: + response = auth_client.post( + reverse("forms_workflows:form_auto_save", args=[upload_form.slug]), + json.dumps({"attachment": fake, "name": fake, "unknown": fake}), + content_type="application/json", + ) + assert response.status_code == 200 + else: + response = auth_client.post( + reverse("forms_workflows:form_submit", args=[upload_form.slug]), + {"save_draft": "1", "_stashed_files": json.dumps({"attachment": fake})}, + ) + assert response.status_code == 302 + sub = FormSubmission.objects.get(form_definition=upload_form, submitter=user) + assert "path" not in json.dumps(sub.form_data) + + +def test_inactive_user_token_denied(client, user): + token = APIToken.objects.create(user=user, name="Before deactivation") + user.is_active = False + user.save() + assert ( + client.get( + "/api/forms/", HTTP_AUTHORIZATION=f"Bearer {token.token}" + ).status_code + == 401 + ) + + +@pytest.mark.parametrize("endpoint", ["analytics_dashboard", "analytics_export_csv"]) +def test_analytics_scoped_to_visible_submissions( + client, user, approver_user, form_definition, endpoint +): + sub = FormSubmission.objects.create( + form_definition=form_definition, + submitter=user, + form_data={}, + status="submitted", + ) + client.force_login(approver_user) + response = client.get(reverse(f"forms_workflows:{endpoint}")) + assert response.status_code == 200 + assert form_definition.name.encode() not in response.content + client.force_login(user) + response = client.get(reverse(f"forms_workflows:{endpoint}")) + assert response.status_code == 200 + assert form_definition.name.encode() in response.content + assert sub.pk + + +@pytest.mark.parametrize( + "endpoint,method,kwargs", + [ + ("form_builder_api_save", "post", {}), + ("form_builder_api_load", "get", {"form_id": 1}), + ("form_builder_api_shared_list_save", "post", {}), + ("form_builder_api_doc_template_save", "post", {"form_id": 1}), + ("workflow_builder_save", "post", {}), + ], +) +def test_staff_without_model_permissions_denied( + client, staff_user, endpoint, method, kwargs +): + client.force_login(staff_user) + url = reverse(f"admin:{endpoint}", kwargs=kwargs) + response = ( + getattr(client, method)( + url, + data=json.dumps({"name": "Injected", "slug": "injected", "fields": []}), + content_type="application/json", + ) + if method == "post" + else client.get(url) + ) + assert response.status_code == 403 + + +def test_builder_with_explicit_model_permission_can_create(client, staff_user): + staff_user.user_permissions.add( + Permission.objects.get( + content_type__app_label="django_forms_workflows", + codename="add_formdefinition", + ) + ) + client.force_login(staff_user) + response = client.post( + reverse("admin:form_builder_api_save"), + json.dumps({"name": "Authorized", "slug": "authorized", "fields": []}), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json()["success"] + + +def test_untrusted_forwarding_header_cannot_change_rate_identity(settings): + settings.FORMS_WORKFLOWS_PUBLIC_RATE_LIMIT = "1/hour" + factory = RequestFactory() + for ip in ["198.51.100.1", "198.51.100.2"]: + request = factory.post("/", REMOTE_ADDR="192.0.2.1", HTTP_X_FORWARDED_FOR=ip) + assert _get_client_ip(request) == "192.0.2.1" + assert check_rate_limit(request, "form") == (ip == "198.51.100.1") + + +def test_trusted_proxy_chain_walks_from_right(settings): + settings.FORMS_WORKFLOWS_TRUSTED_PROXIES = ["10.0.0.0/8"] + request = RequestFactory().get( + "/", REMOTE_ADDR="10.0.0.1", HTTP_X_FORWARDED_FOR="1.2.3.4, 192.0.2.7, 10.0.0.2" + ) + assert _get_client_ip(request) == "192.0.2.7" + + +def test_rate_limit_cache_failure_is_closed(): + with patch( + "django.core.cache.backends.locmem.LocMemCache.add", + side_effect=RuntimeError("unavailable"), + ): + assert not check_rate_limit(RequestFactory().post("/"), "form") + + +@pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1/private", + "https://example.com/image", + "file:///etc/passwd", + "data:image/svg+xml;base64,PHN2Zz4=", + "ftp://example.com/file", + ], +) +def test_pdf_disallows_external_and_active_resources(url): + with pytest.raises(ValueError): + safe_pdf_url_fetcher(url) + + +def test_pdf_allows_bounded_inline_png(settings): + data = b"\x89PNG\r\n\x1a\nlocal-test" + url = "data:image/png;base64," + base64.b64encode(data).decode() + assert safe_pdf_url_fetcher(url)["string"] == data + settings.FORMS_WORKFLOWS_PDF_MAX_RESOURCE_BYTES = 8 + with pytest.raises(ValueError): + safe_pdf_url_fetcher(url) + + +def test_document_template_escapes_answer_markup(form_definition, user): + template = DocumentTemplate( + form_definition=form_definition, name="PDF", html_content="

{answer}

" + ) + sub = FormSubmission( + form_definition=form_definition, + submitter=user, + form_data={"answer": ''}, + ) + assert "'}, + ) + client.force_login(user) + with patch( + "urllib.request.OpenerDirector.open", + side_effect=AssertionError("No network permitted"), + ) as fetch: + response = client.get(reverse("forms_workflows:submission_pdf", args=[sub.pk])) + assert response.status_code == 200 + assert response["Content-Type"] == "application/pdf" + fetch.assert_not_called() + + +def test_readonly_workflow_builder_does_not_create_workflow( + client, staff_user, form_definition +): + from django_forms_workflows.models import WorkflowDefinition + + staff_user.user_permissions.add( + *Permission.objects.filter( + content_type__app_label="django_forms_workflows", + codename__in=["view_formdefinition", "view_workflowdefinition"], + ) + ) + client.force_login(staff_user) + for name in ["workflow_builder", "workflow_builder_load"]: + response = client.get(reverse(f"admin:{name}", args=[form_definition.pk])) + assert response.status_code == 200 + assert not WorkflowDefinition.objects.filter( + form_definition=form_definition + ).exists() + + +def test_workflow_save_without_id_requires_change_permission( + client, staff_user, workflow, form_definition +): + staff_user.user_permissions.add( + *Permission.objects.filter( + content_type__app_label="django_forms_workflows", + codename__in=["change_formdefinition", "add_workflowdefinition"], + ) + ) + client.force_login(staff_user) + response = client.post( + reverse("admin:workflow_builder_save"), + json.dumps( + { + "form_id": form_definition.pk, + "workflow": {"nodes": [], "connections": []}, + } + ), + content_type="application/json", + ) + assert response.status_code == 403 + workflow.refresh_from_db() + assert workflow.requires_approval + + +def test_rate_limit_concurrent_requests_do_not_lose_increments(settings): + from concurrent.futures import ThreadPoolExecutor + + settings.FORMS_WORKFLOWS_PUBLIC_RATE_LIMIT = "5/hour" + request = RequestFactory().post("/", REMOTE_ADDR="192.0.2.1") + with ThreadPoolExecutor(max_workers=10) as pool: + accepted = list( + pool.map(lambda _: check_rate_limit(request, "concurrent"), range(30)) + ) + assert sum(accepted) == 5 + + +def test_analytics_chart_escapes_script_end_tag(client, user, form_definition): + payload = "" + form_definition.name = payload + form_definition.save() + FormSubmission.objects.create( + form_definition=form_definition, + submitter=user, + form_data={}, + status="submitted", + ) + client.force_login(user) + response = client.get(reverse("forms_workflows:analytics_dashboard")) + assert payload.encode() not in response.content + assert b"\\u003C/script\\u003E" in response.content + + +def test_analytics_csv_neutralizes_formula(client, user, form_definition): + form_definition.name = '=HYPERLINK("https://example.com")' + form_definition.save() + FormSubmission.objects.create( + form_definition=form_definition, + submitter=user, + form_data={}, + status="submitted", + ) + client.force_login(user) + response = client.get(reverse("forms_workflows:analytics_export_csv")) + import csv + import io + + rows = list(csv.reader(io.StringIO(response.content.decode()))) + assert rows[1][1].startswith("'=HYPERLINK") + + +def test_autosave_preserves_previously_validated_files(auth_client, user, upload_form): + url = reverse("forms_workflows:form_submit", args=[upload_form.slug]) + response = auth_client.post( + url, {"save_draft": "1", "attachment": SimpleUploadedFile("keep.pdf", b"pdf")} + ) + assert response.status_code == 302 + sub = FormSubmission.objects.get(form_definition=upload_form, submitter=user) + original = sub.form_data["attachment"] + response = auth_client.post( + reverse("forms_workflows:form_auto_save", args=[upload_form.slug]), + json.dumps( + { + "name": "updated", + "attachment": {"filename": "forged", "path": "other/file"}, + } + ), + content_type="application/json", + ) + assert response.status_code == 200 + sub.refresh_from_db() + assert sub.form_data["attachment"] == original + with patch("django_forms_workflows.views.create_approval_tasks"): + response = auth_client.post(url, {"name": "Ready"}) + assert response.status_code == 302 + sub.refresh_from_db() + assert sub.form_data["attachment"] == original + assert sub.status == "submitted" + + +def test_upload_recovery_expires(auth_client, user, upload_form): + url = reverse("forms_workflows:form_submit", args=[upload_form.slug]) + auth_client.post(url, {"attachment": SimpleUploadedFile("ok.pdf", b"pdf")}) + session = auth_client.session + entries = session["dfw_uploads"] + for entry in entries.values(): + entry["expires"] = 0 + session["dfw_uploads"] = entries + session.save() + response = auth_client.post(url, {"name": "Expired"}) + assert "attachment" in response.context["form"].errors diff --git a/tests/test_views.py b/tests/test_views.py index 2295424..00edf69 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1867,8 +1867,8 @@ def test_nonexistent_submission_returns_404(self, auth_client, db): resp = auth_client.get(url) assert resp.status_code == 404 - def test_anonymous_access_allowed(self, client, form_with_fields, user): - """Success page has no login_required; anonymous users can view it.""" + def test_anonymous_access_denied(self, client, form_with_fields, user): + """A submission ID alone must not grant receipt access.""" form_with_fields.success_message = "Done!" form_with_fields.save() sub = FormSubmission.objects.create( @@ -1879,7 +1879,7 @@ def test_anonymous_access_allowed(self, client, form_with_fields, user): ) url = reverse("forms_workflows:submission_success", args=[sub.id]) resp = client.get(url) - assert resp.status_code == 200 + assert resp.status_code == 404 # ── success routing in form_submit ─────────────────────────────────────────