This commit is contained in:
Nick Sweeting
2026-03-23 03:58:32 -07:00
parent 268856bcfb
commit b749b26c5d
286 changed files with 21704 additions and 13480 deletions

View File

@@ -1 +1 @@
__package__ = 'archivebox.base_models'
__package__ = "archivebox.base_models"

View File

@@ -1,6 +1,6 @@
"""Base admin classes for models using UUIDv7."""
__package__ = 'archivebox.base_models'
__package__ = "archivebox.base_models"
import json
from collections.abc import Mapping
@@ -32,11 +32,12 @@ class KeyValueWidget(forms.Widget):
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': []
"all": [],
}
js = []
@@ -44,17 +45,18 @@ class KeyValueWidget(forms.Widget):
"""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():
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', ''),
"plugin": plugin_name,
"type": prop.get("type", "string"),
"default": prop.get("default", ""),
"description": prop.get("description", ""),
}
for schema_key in ('enum', 'pattern', 'minimum', 'maximum'):
for schema_key in ("enum", "pattern", "minimum", "maximum"):
if schema_key in prop:
option[schema_key] = prop[schema_key]
options[key] = option
@@ -85,11 +87,11 @@ class KeyValueWidget(forms.Widget):
) -> SafeString:
data = self._parse_value(value)
widget_id = attrs.get('id', name) if attrs else name
widget_id = attrs.get("id", name) if attrs else name
config_options = self._get_config_options()
# Build datalist options
datalist_options = '\n'.join(
datalist_options = "\n".join(
f'<option value="{self._escape(key)}">{self._escape(opt["description"][:60] or opt["type"])}</option>'
for key, opt in sorted(config_options.items())
)
@@ -111,7 +113,7 @@ class KeyValueWidget(forms.Widget):
html += self._render_row(widget_id, key, val_str)
# Always add one empty row for new entries
html += self._render_row(widget_id, '', '')
html += self._render_row(widget_id, "", "")
html += f'''
</div>
@@ -669,8 +671,8 @@ class KeyValueWidget(forms.Widget):
def _escape(self, s: object) -> str:
"""Escape HTML special chars in attribute values."""
if not s:
return ''
return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
return ""
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def value_from_datadict(
self,
@@ -678,8 +680,8 @@ class KeyValueWidget(forms.Widget):
files: object,
name: str,
) -> str:
value = data.get(name, '{}')
return value if isinstance(value, str) else '{}'
value = data.get(name, "{}")
return value if isinstance(value, str) else "{}"
class ConfigEditorMixin(admin.ModelAdmin):
@@ -696,14 +698,20 @@ class ConfigEditorMixin(admin.ModelAdmin):
**kwargs: object,
) -> forms.Field | None:
"""Use KeyValueWidget for the config JSON field."""
if db_field.name == 'config':
kwargs['widget'] = KeyValueWidget()
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')
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,
@@ -713,6 +721,6 @@ class BaseModelAdmin(DjangoObjectActions, admin.ModelAdmin):
**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
if "created_by" in form.base_fields:
form.base_fields["created_by"].initial = request.user
return form

View File

@@ -1,6 +1,6 @@
"""Base models using UUIDv7 for all id fields."""
__package__ = 'archivebox.base_models'
__package__ = "archivebox.base_models"
from archivebox.uuid_compat import uuid7
from pathlib import Path
@@ -15,22 +15,22 @@ from django.conf import settings
from django_stubs_ext.db.models import TypedModelMeta
def get_or_create_system_user_pk(username='system'):
def get_or_create_system_user_pk(username="system"):
User = get_user_model()
# If there's exactly one superuser, use that for all system operations
if User.objects.filter(is_superuser=True).count() == 1:
return User.objects.filter(is_superuser=True).values_list('pk', flat=True)[0]
return User.objects.filter(is_superuser=True).values_list("pk", flat=True)[0]
# Otherwise get or create the system user
user, _ = User.objects.get_or_create(
username=username,
defaults={'is_staff': True, 'is_superuser': True, 'email': '', 'password': '!'}
defaults={"is_staff": True, "is_superuser": True, "email": "", "password": "!"},
)
return user.pk
class AutoDateTimeField(models.DateTimeField):
"""DateTimeField that automatically updates on save (legacy compatibility)."""
def pre_save(self, model_instance, add):
if add or not getattr(model_instance, self.attname):
value = timezone.now()
@@ -43,13 +43,19 @@ class ModelWithUUID(models.Model):
id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
created_at = models.DateTimeField(default=timezone.now, db_index=True)
modified_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False, db_index=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
default=get_or_create_system_user_pk,
null=False,
db_index=True,
)
class Meta(TypedModelMeta):
abstract = True
def __str__(self) -> str:
return f'[{self.id}] {self.__class__.__name__}'
return f"[{self.id}] {self.__class__.__name__}"
@property
def admin_change_url(self) -> str:
@@ -57,17 +63,17 @@ class ModelWithUUID(models.Model):
@property
def api_url(self) -> str:
return str(reverse_lazy('api-1:get_any', args=[self.id]))
return str(reverse_lazy("api-1:get_any", args=[self.id]))
@property
def api_docs_url(self) -> str:
return f'/api/v1/docs#/{self._meta.app_label.title()}%20Models/api_v1_{self._meta.app_label}_get_{self._meta.db_table}'
return f"/api/v1/docs#/{self._meta.app_label.title()}%20Models/api_v1_{self._meta.app_label}_get_{self._meta.db_table}"
class ModelWithNotes(models.Model):
"""Mixin for models with a notes field."""
notes = models.TextField(blank=True, null=False, default='')
notes = models.TextField(blank=True, null=False, default="")
class Meta(TypedModelMeta):
abstract = True
@@ -75,6 +81,7 @@ class ModelWithNotes(models.Model):
class ModelWithHealthStats(models.Model):
"""Mixin for models with health tracking fields."""
num_uses_failed = models.PositiveIntegerField(default=0)
num_uses_succeeded = models.PositiveIntegerField(default=0)
@@ -88,12 +95,13 @@ class ModelWithHealthStats(models.Model):
def increment_health_stats(self, success: bool):
"""Atomically increment success or failure counter using F() expression."""
field = 'num_uses_succeeded' if success else 'num_uses_failed'
field = "num_uses_succeeded" if success else "num_uses_failed"
type(self).objects.filter(pk=self.pk).update(**{field: F(field) + 1})
class ModelWithConfig(models.Model):
"""Mixin for models with a JSON config field."""
config = models.JSONField(default=dict, null=True, blank=True, editable=True)
class Meta(TypedModelMeta):
@@ -111,7 +119,7 @@ class ModelWithOutputDir(ModelWithUUID):
@property
def output_dir_parent(self) -> str:
return f'{self._meta.model_name}s'
return f"{self._meta.model_name}s"
@property
def output_dir_name(self) -> str:
@@ -119,7 +127,7 @@ class ModelWithOutputDir(ModelWithUUID):
@property
def output_dir_str(self) -> str:
return f'{self.output_dir_parent}/{self.output_dir_name}'
return f"{self.output_dir_parent}/{self.output_dir_name}"
@property
def output_dir(self) -> Path: