admin.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. """Base admin classes for models using UUIDv7."""
  2. __package__ = 'archivebox.base_models'
  3. import json
  4. from django import forms
  5. from django.contrib import admin
  6. from django.utils.html import format_html, mark_safe
  7. from django_object_actions import DjangoObjectActions
  8. class KeyValueWidget(forms.Widget):
  9. """
  10. A widget that renders JSON dict as editable key-value input fields
  11. with + and - buttons to add/remove rows.
  12. Includes autocomplete for available config keys from the plugin system.
  13. """
  14. template_name = None # We render manually
  15. class Media:
  16. css = {
  17. 'all': []
  18. }
  19. js = []
  20. def _get_config_options(self):
  21. """Get available config options from plugins."""
  22. try:
  23. from archivebox.hooks import discover_plugin_configs
  24. plugin_configs = discover_plugin_configs()
  25. options = {}
  26. for plugin_name, schema in plugin_configs.items():
  27. for key, prop in schema.get('properties', {}).items():
  28. options[key] = {
  29. 'plugin': plugin_name,
  30. 'type': prop.get('type', 'string'),
  31. 'default': prop.get('default', ''),
  32. 'description': prop.get('description', ''),
  33. }
  34. return options
  35. except Exception:
  36. return {}
  37. def render(self, name, value, attrs=None, renderer=None):
  38. # Parse JSON value to dict
  39. if value is None:
  40. data = {}
  41. elif isinstance(value, str):
  42. try:
  43. data = json.loads(value) if value else {}
  44. except json.JSONDecodeError:
  45. data = {}
  46. elif isinstance(value, dict):
  47. data = value
  48. else:
  49. data = {}
  50. widget_id = attrs.get('id', name) if attrs else name
  51. config_options = self._get_config_options()
  52. # Build datalist options
  53. datalist_options = '\n'.join(
  54. f'<option value="{self._escape(key)}">{self._escape(opt["description"][:60] or opt["type"])}</option>'
  55. for key, opt in sorted(config_options.items())
  56. )
  57. # Build config metadata as JSON for JS
  58. config_meta_json = json.dumps(config_options)
  59. html = f'''
  60. <div id="{widget_id}_container" class="key-value-editor" style="max-width: 700px;">
  61. <datalist id="{widget_id}_keys">
  62. {datalist_options}
  63. </datalist>
  64. <div id="{widget_id}_rows" class="key-value-rows">
  65. '''
  66. # Render existing key-value pairs
  67. row_idx = 0
  68. for key, val in data.items():
  69. val_str = json.dumps(val) if not isinstance(val, str) else val
  70. html += self._render_row(widget_id, row_idx, key, val_str)
  71. row_idx += 1
  72. # Always add one empty row for new entries
  73. html += self._render_row(widget_id, row_idx, '', '')
  74. html += f'''
  75. </div>
  76. <div style="display: flex; gap: 8px; align-items: center; margin-top: 8px;">
  77. <button type="button" onclick="addKeyValueRow_{widget_id}()"
  78. style="padding: 4px 12px; cursor: pointer; background: #417690; color: white; border: none; border-radius: 4px;">
  79. + Add Row
  80. </button>
  81. <span id="{widget_id}_hint" style="font-size: 11px; color: #666; font-style: italic;"></span>
  82. </div>
  83. <input type="hidden" name="{name}" id="{widget_id}" value="">
  84. <script>
  85. (function() {{
  86. var configMeta_{widget_id} = {config_meta_json};
  87. function showKeyHint_{widget_id}(key) {{
  88. var hint = document.getElementById('{widget_id}_hint');
  89. var meta = configMeta_{widget_id}[key];
  90. if (meta) {{
  91. hint.innerHTML = '<b>' + key + '</b>: ' + (meta.description || meta.type) +
  92. (meta.default !== '' ? ' <span style="color:#888">(default: ' + meta.default + ')</span>' : '');
  93. }} else {{
  94. hint.textContent = key ? 'Custom key: ' + key : '';
  95. }}
  96. }}
  97. function updateHiddenField_{widget_id}() {{
  98. var container = document.getElementById('{widget_id}_rows');
  99. var rows = container.querySelectorAll('.key-value-row');
  100. var result = {{}};
  101. rows.forEach(function(row) {{
  102. var keyInput = row.querySelector('.kv-key');
  103. var valInput = row.querySelector('.kv-value');
  104. if (keyInput && valInput && keyInput.value.trim()) {{
  105. var key = keyInput.value.trim();
  106. var val = valInput.value.trim();
  107. // Try to parse as JSON (for booleans, numbers, etc)
  108. try {{
  109. if (val === 'true') result[key] = true;
  110. else if (val === 'false') result[key] = false;
  111. else if (val === 'null') result[key] = null;
  112. else if (!isNaN(val) && val !== '') result[key] = Number(val);
  113. else if ((val.startsWith('{{') && val.endsWith('}}')) ||
  114. (val.startsWith('[') && val.endsWith(']')) ||
  115. (val.startsWith('"') && val.endsWith('"')))
  116. result[key] = JSON.parse(val);
  117. else result[key] = val;
  118. }} catch(e) {{
  119. result[key] = val;
  120. }}
  121. }}
  122. }});
  123. document.getElementById('{widget_id}').value = JSON.stringify(result);
  124. }}
  125. window.addKeyValueRow_{widget_id} = function() {{
  126. var container = document.getElementById('{widget_id}_rows');
  127. var rows = container.querySelectorAll('.key-value-row');
  128. var newIdx = rows.length;
  129. var newRow = document.createElement('div');
  130. newRow.className = 'key-value-row';
  131. newRow.style.cssText = 'display: flex; gap: 8px; margin-bottom: 6px; align-items: center;';
  132. newRow.innerHTML = '<input type="text" class="kv-key" placeholder="KEY" list="{widget_id}_keys" ' +
  133. 'style="flex: 1; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;" ' +
  134. 'onchange="updateHiddenField_{widget_id}()" oninput="updateHiddenField_{widget_id}(); showKeyHint_{widget_id}(this.value)" onfocus="showKeyHint_{widget_id}(this.value)">' +
  135. '<input type="text" class="kv-value" placeholder="value" ' +
  136. 'style="flex: 2; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;" ' +
  137. 'onchange="updateHiddenField_{widget_id}()" oninput="updateHiddenField_{widget_id}()">' +
  138. '<button type="button" onclick="removeKeyValueRow_{widget_id}(this)" ' +
  139. 'style="padding: 4px 10px; cursor: pointer; background: #ba2121; color: white; border: none; border-radius: 4px; font-weight: bold;">−</button>';
  140. container.appendChild(newRow);
  141. newRow.querySelector('.kv-key').focus();
  142. }};
  143. window.removeKeyValueRow_{widget_id} = function(btn) {{
  144. var row = btn.parentElement;
  145. row.remove();
  146. updateHiddenField_{widget_id}();
  147. }};
  148. window.showKeyHint_{widget_id} = showKeyHint_{widget_id};
  149. window.updateHiddenField_{widget_id} = updateHiddenField_{widget_id};
  150. // Initialize on load
  151. document.addEventListener('DOMContentLoaded', function() {{
  152. updateHiddenField_{widget_id}();
  153. }});
  154. // Also run immediately in case DOM is already ready
  155. if (document.readyState !== 'loading') {{
  156. updateHiddenField_{widget_id}();
  157. }}
  158. // Update on any input change
  159. document.getElementById('{widget_id}_rows').addEventListener('input', updateHiddenField_{widget_id});
  160. }})();
  161. </script>
  162. </div>
  163. '''
  164. return mark_safe(html)
  165. def _render_row(self, widget_id, idx, key, value):
  166. return f'''
  167. <div class="key-value-row" style="display: flex; gap: 8px; margin-bottom: 6px; align-items: center;">
  168. <input type="text" class="kv-key" value="{self._escape(key)}" placeholder="KEY" list="{widget_id}_keys"
  169. style="flex: 1; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;"
  170. onchange="updateHiddenField_{widget_id}()" oninput="updateHiddenField_{widget_id}(); showKeyHint_{widget_id}(this.value)" onfocus="showKeyHint_{widget_id}(this.value)">
  171. <input type="text" class="kv-value" value="{self._escape(value)}" placeholder="value"
  172. style="flex: 2; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;"
  173. onchange="updateHiddenField_{widget_id}()" oninput="updateHiddenField_{widget_id}()">
  174. <button type="button" onclick="removeKeyValueRow_{widget_id}(this)"
  175. style="padding: 4px 10px; cursor: pointer; background: #ba2121; color: white; border: none; border-radius: 4px; font-weight: bold;">−</button>
  176. </div>
  177. '''
  178. def _escape(self, s):
  179. """Escape HTML special chars in attribute values."""
  180. if not s:
  181. return ''
  182. return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
  183. def value_from_datadict(self, data, files, name):
  184. value = data.get(name, '{}')
  185. return value
  186. class ConfigEditorMixin:
  187. """
  188. Mixin for admin classes with a config JSON field.
  189. Provides a key-value editor widget with autocomplete for available config keys.
  190. """
  191. def formfield_for_dbfield(self, db_field, request, **kwargs):
  192. """Use KeyValueWidget for the config JSON field."""
  193. if db_field.name == 'config':
  194. kwargs['widget'] = KeyValueWidget()
  195. return super().formfield_for_dbfield(db_field, request, **kwargs)
  196. class BaseModelAdmin(DjangoObjectActions, admin.ModelAdmin):
  197. list_display = ('id', 'created_at', 'created_by')
  198. readonly_fields = ('id', 'created_at', 'modified_at')
  199. def get_form(self, request, obj=None, **kwargs):
  200. form = super().get_form(request, obj, **kwargs)
  201. if 'created_by' in form.base_fields:
  202. form.base_fields['created_by'].initial = request.user
  203. return form