"""Base admin classes for models using UUIDv7.""" __package__ = "archivebox.base_models" import json from collections.abc import Mapping from typing import NotRequired, TypedDict from django import forms from django.contrib import admin from django.db import models from django.forms.renderers import BaseRenderer from django.http import HttpRequest, QueryDict from django.utils.safestring import SafeString, mark_safe from django_object_actions import DjangoObjectActions class ConfigOption(TypedDict): plugin: str type: str | list[str] default: object description: str enum: NotRequired[list[object]] pattern: NotRequired[str] minimum: NotRequired[int | float] maximum: NotRequired[int | float] class KeyValueWidget(forms.Widget): """ A widget that renders JSON dict as editable key-value input fields with + and - buttons to add/remove rows. Includes autocomplete for available config keys from the plugin system. """ template_name = "" # We render manually class Media: css = { "all": [], } js = [] def _get_config_options(self) -> dict[str, ConfigOption]: """Get available config options from plugins.""" try: from archivebox.hooks import discover_plugin_configs plugin_configs = discover_plugin_configs() options: dict[str, ConfigOption] = {} for plugin_name, schema in plugin_configs.items(): for key, prop in schema.get("properties", {}).items(): option: ConfigOption = { "plugin": plugin_name, "type": prop.get("type", "string"), "default": prop.get("default", ""), "description": prop.get("description", ""), } for schema_key in ("enum", "pattern", "minimum", "maximum"): if schema_key in prop: option[schema_key] = prop[schema_key] options[key] = option return options except Exception: return {} def _parse_value(self, value: object) -> dict[str, object]: # Parse JSON value to dict if value is None: return {} if isinstance(value, str): try: parsed = json.loads(value) if value else {} except json.JSONDecodeError: return {} return parsed if isinstance(parsed, dict) else {} if isinstance(value, Mapping): return {str(key): item for key, item in value.items()} return {} def render( self, name: str, value: object, attrs: Mapping[str, str] | None = None, renderer: BaseRenderer | None = None, ) -> SafeString: data = self._parse_value(value) widget_id = attrs.get("id", name) if attrs else name config_options = self._get_config_options() # Build datalist options datalist_options = "\n".join( f'' for key, opt in sorted(config_options.items()) ) # Build config metadata as JSON for JS config_meta_json = json.dumps(config_options) html = f'''
{datalist_options}
''' # Render existing key-value pairs for key, val in data.items(): val_str = json.dumps(val) if not isinstance(val, str) else val html += self._render_row(widget_id, key, val_str) # Always add one empty row for new entries html += self._render_row(widget_id, "", "") html += f'''
''' return mark_safe(html) def _render_row(self, widget_id: str, key: str, value: str) -> str: return f'''
''' def _escape(self, s: object) -> str: """Escape HTML special chars in attribute values.""" if not s: return "" return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) def value_from_datadict( self, data: QueryDict | Mapping[str, object], files: object, name: str, ) -> str: value = data.get(name, "{}") return value if isinstance(value, str) else "{}" class ConfigEditorMixin(admin.ModelAdmin): """ Mixin for admin classes with a config JSON field. Provides a key-value editor widget with autocomplete for available config keys. """ def formfield_for_dbfield( self, db_field: models.Field[object, object], request: HttpRequest, **kwargs: object, ) -> forms.Field | None: """Use KeyValueWidget for the config JSON field.""" if db_field.name == "config": kwargs["widget"] = KeyValueWidget() return super().formfield_for_dbfield(db_field, request, **kwargs) class BaseModelAdmin(DjangoObjectActions, admin.ModelAdmin): list_display = ("id", "created_at", "created_by") readonly_fields = ("id", "created_at", "modified_at") show_search_mode_selector = False def get_default_search_mode(self) -> str: # The shared changelist template always asks every admin for a default # search mode, even when the search-mode toggle is hidden. return "meta" def get_form( self, request: HttpRequest, obj: models.Model | None = None, change: bool = False, **kwargs: object, ): form = super().get_form(request, obj, change=change, **kwargs) if "created_by" in form.base_fields: form.base_fields["created_by"].initial = request.user return form