This commit is contained in:
2025-11-11 15:51:54 +00:00
parent 99ae905cb0
commit 3e0c5d2ecc
114 changed files with 9278 additions and 929 deletions

View File

@@ -1,37 +1,3 @@
from django.contrib import admin
from unfold.admin import ModelAdmin
from unfold.sections import TableSection, TemplateSection
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from .models import Account
def __str__(self):
return f"{self.firstname} {self.lastname}"
# # Register your models here.
# admin.site.register(Account)
# Table for related records
class CustomTableSection(TableSection):
verbose_name = _("Keywarden Users") # Displays custom table title
height = 300 # Force the table height. Ideal for large amount of records
# related_name = "related_name_set" # Related model field name
fields = ["id", "firstname", "lastname", "joined_date"] # Fields from related model
# # Custom field
# def custom_field(self, instance):
# return instance.pk
# # Simple template with custom content
# class CardSection(TemplateSection):
# template_name = "keywarden/some_template.html"
@admin.register(Account)
class SomeAdmin(ModelAdmin):
list_sections = [
#CardSection,
CustomTableSection,
]
#
# No custom models registered in accounts app. The legacy Account model has been removed.

View File

@@ -0,0 +1,18 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_rename_accounts_account"),
]
operations = [
migrations.AlterField(
model_name="account",
name="email",
field=models.EmailField(max_length=254, unique=True),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_alter_account_email"),
]
operations = [
migrations.DeleteModel(
name="Account",
),
]

View File

@@ -1,7 +1,3 @@
from django.db import models
class Account(models.Model):
firstname = models.CharField(max_length=255)
lastname = models.CharField(max_length=255)
email = models.CharField(max_length=255)
joined_date = models.DateField(null=True)
#
# Legacy Account model has been removed. This app now contains URLs/views only.

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Sign in • Keywarden{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Sign in</h1>
<form method="post" class="space-y-4">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'accounts:profile' %}">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-gray-700">Username</label>
<input type="text" name="username" autocomplete="username" required class="block w-full rounded-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600">
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-gray-700">Password</label>
<input type="password" name="password" autocomplete="current-password" required class="block w-full rounded-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600">
</div>
{% if form.errors %}
<p class="text-sm text-red-600">Please check your username and password.</p>
{% endif %}
<div class="pt-2">
<button type="submit" class="inline-flex w-full items-center justify-center rounded-md bg-purple-600 px-4 py-2.5 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">
Sign in
</button>
</div>
</form>
<div class="mt-6 border-t border-gray-200 pt-6">
<p class="text-sm text-gray-600">
Or, if configured, use
<a href="/oidc/authenticate/" class="font-medium text-purple-700 hover:text-purple-800">OIDC login</a>.
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Profile • Keywarden{% endblock %}
{% block content %}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1>
<dl class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Username</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.username }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Email</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.email }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">First name</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.first_name|default:"—" }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Last name</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.last_name|default:"—" }}</dd>
</div>
</dl>
</div>
</div>
<div>
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h2 class="mb-4 text-base font-semibold tracking-tight text-gray-900">Single Sign-On</h2>
{% if auth_mode == "hybrid" %}
<div class="mt-6 border-t border-gray-200 pt-6">
<p class="text-sm text-gray-600">
Optional: Link your account with your identity provider for single sign-on.
<a href="/oidc/authenticate/" class="font-medium text-purple-700 hover:text-purple-800">Link with SSO</a>
</p>
</div>
{% elif auth_mode == "oidc" %}
<p class="text-sm text-gray-600">OIDC is required. Sign-in is managed by your identity provider.</p>
{% else %}
<p class="text-sm text-gray-600">OIDC is disabled. You are using native authentication.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [
path("login/", views.login_view, name="login"),
path("logout/", views.logout_view, name="logout"),
path("profile/", views.profile, name="profile"),
]

View File

@@ -0,0 +1,29 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.conf import settings
from django.shortcuts import redirect
from django.contrib.auth import views as auth_views
from django.contrib.auth import logout
@login_required(login_url="/accounts/login/")
def profile(request):
context = {
"user": request.user,
"auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"),
}
return render(request, "accounts/profile.html", context)
def login_view(request):
auth_mode = getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid")
if auth_mode == "oidc":
return redirect("/oidc/authenticate/")
# native or hybrid -> render Django's built-in login view
return auth_views.LoginView.as_view(template_name="accounts/login.html")(request)
def logout_view(request):
logout(request)
return redirect(getattr(settings, "LOGOUT_REDIRECT_URL", "/"))

View File

@@ -0,0 +1,3 @@
default_app_config = "apps.audit.apps.AuditConfig"

91
app/apps/audit/admin.py Normal file
View File

@@ -0,0 +1,91 @@
from django.contrib import admin
from unfold.admin import ModelAdmin
from unfold.decorators import action # type: ignore
from .models import AuditEventType, AuditLog
@admin.register(AuditEventType)
class AuditEventTypeAdmin(ModelAdmin):
list_display = ("key", "title", "default_severity", "created_at")
search_fields = ("key", "title", "description")
list_filter = ("default_severity",)
ordering = ("key",)
compressed_fields = True
@admin.register(AuditLog)
class AuditLogAdmin(ModelAdmin):
date_hierarchy = "created_at"
list_display = (
"created_at",
"severity",
"event_type",
"actor",
"object_repr",
"source",
"ip_address",
)
list_filter = (
"severity",
"source",
"event_type",
("actor", admin.RelatedOnlyFieldListFilter),
"created_at",
)
search_fields = (
"message",
"object_repr",
"ip_address",
"user_agent",
"request_id",
"metadata",
"actor__username",
"actor__email",
)
readonly_fields = (
"created_at",
"actor",
"event_type",
"message",
"severity",
"source",
"target_content_type",
"target_object_id",
"object_repr",
"ip_address",
"user_agent",
"request_id",
"metadata",
)
compressed_fields = True
list_per_page = 50
fieldsets = (
(
None,
{
"fields": (
"created_at",
"event_type",
"severity",
"message",
"source",
)
},
),
(
"Actor",
{"fields": ("actor", "ip_address", "user_agent", "request_id")},
),
(
"Target",
{"fields": ("target_content_type", "target_object_id", "object_repr")},
),
(
"Metadata",
{"fields": ("metadata",)},
),
)

15
app/apps/audit/apps.py Normal file
View File

@@ -0,0 +1,15 @@
from django.apps import AppConfig
class AuditConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.audit"
label = "audit"
verbose_name = "Audit"
def ready(self) -> None:
# Import signal handlers
from . import signals # noqa: F401
return super().ready()

View File

@@ -0,0 +1,60 @@
# Generated by Django 5.1 on 2025-11-11 15:25
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AuditEventType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.SlugField(help_text='Stable machine key, e.g., user_login', max_length=64, unique=True)),
('title', models.CharField(help_text='Human-readable title', max_length=128)),
('description', models.TextField(blank=True)),
('default_severity', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('critical', 'Critical')], db_index=True, default='info', max_length=16)),
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
],
options={
'verbose_name': 'Audit event type',
'verbose_name_plural': 'Audit event types',
'ordering': ['key'],
},
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, editable=False)),
('message', models.TextField(help_text='Summary describing the action in human terms, snapshot at time of event')),
('severity', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('critical', 'Critical')], db_index=True, default='info', max_length=16)),
('source', models.CharField(choices=[('ui', 'UI'), ('api', 'API'), ('system', 'System')], db_index=True, default='ui', max_length=16)),
('target_object_id', models.CharField(blank=True, max_length=64, null=True)),
('object_repr', models.CharField(blank=True, help_text='String representation of the target object at event time', max_length=255)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('request_id', models.CharField(blank=True, help_text='Correlation id, if available', max_length=64)),
('metadata', models.JSONField(blank=True, default=dict)),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL)),
('event_type', models.ForeignKey(help_text='Type of the event', on_delete=django.db.models.deletion.PROTECT, related_name='audit_logs', to='audit.auditeventtype')),
('target_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_target', to='contenttypes.contenttype')),
],
options={
'verbose_name': 'Audit log',
'verbose_name_plural': 'Audit logs',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['created_at'], name='audit_audit_created_2c1626_idx'), models.Index(fields=['severity'], name='audit_audit_severit_f33e16_idx'), models.Index(fields=['source'], name='audit_audit_source_5d49a7_idx'), models.Index(fields=['actor', 'created_at'], name='audit_audit_actor_i_ec0f72_idx'), models.Index(fields=['target_content_type', 'target_object_id'], name='audit_audit_target__f79146_idx')],
},
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1 on 2025-11-11 15:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audit', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='auditlog',
name='event_type',
field=models.ForeignKey(help_text='Type of event', on_delete=django.db.models.deletion.PROTECT, related_name='audit_logs', to='audit.auditeventtype'),
),
]

View File

@@ -0,0 +1,3 @@
# empty

112
app/apps/audit/models.py Normal file
View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
class AuditEventType(models.Model):
"""
Catalog of audit event types (e.g., user_login, secret_updated).
Useful for consistent naming, severity, and descriptions.
"""
class Severity(models.TextChoices):
INFO = "info", "Info"
WARNING = "warning", "Warning"
ERROR = "error", "Error"
CRITICAL = "critical", "Critical"
key = models.SlugField(max_length=64, unique=True, help_text="Stable machine key, e.g., user_login")
title = models.CharField(max_length=128, help_text="Human-readable title")
description = models.TextField(blank=True)
default_severity = models.CharField(
max_length=16, choices=Severity.choices, default=Severity.INFO, db_index=True
)
created_at = models.DateTimeField(default=timezone.now, editable=False)
class Meta:
verbose_name = "Audit event type"
verbose_name_plural = "Audit event types"
ordering = ["key"]
def __str__(self) -> str:
return f"{self.key} ({self.default_severity})"
class AuditLog(models.Model):
"""
An immutable audit record of something that happened in the system.
"""
class Source(models.TextChoices):
UI = "ui", "UI"
API = "api", "API"
SYSTEM = "system", "System"
class Severity(models.TextChoices):
INFO = "info", "Info"
WARNING = "warning", "Warning"
ERROR = "error", "Error"
CRITICAL = "critical", "Critical"
created_at = models.DateTimeField(default=timezone.now, db_index=True, editable=False)
# Who did it
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="audit_logs",
)
# What happened
event_type = models.ForeignKey(
AuditEventType, on_delete=models.PROTECT, related_name="audit_logs", help_text="Type of event"
)
message = models.TextField(
help_text="Summary describing the action in human terms, snapshot at time of event"
)
severity = models.CharField(
max_length=16, choices=Severity.choices, default=Severity.INFO, db_index=True
)
source = models.CharField(max_length=16, choices=Source.choices, default=Source.UI, db_index=True)
# Which object it touched (optional)
target_content_type = models.ForeignKey(
ContentType, null=True, blank=True, on_delete=models.SET_NULL, related_name="audit_target"
)
target_object_id = models.CharField(max_length=64, null=True, blank=True)
target = GenericForeignKey("target_content_type", "target_object_id")
object_repr = models.CharField(
max_length=255,
blank=True,
help_text="String representation of the target object at event time",
)
# Request context
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
request_id = models.CharField(max_length=64, blank=True, help_text="Correlation id, if available")
# Arbitrary extra data
metadata = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name = "Audit log"
verbose_name_plural = "Audit logs"
indexes = [
models.Index(fields=["created_at"]),
models.Index(fields=["severity"]),
models.Index(fields=["source"]),
models.Index(fields=["actor", "created_at"]),
models.Index(fields=["target_content_type", "target_object_id"]),
]
ordering = ["-created_at"]
def __str__(self) -> str:
actor = getattr(self.actor, "username", "system")
return f"[{self.created_at:%Y-%m-%d %H:%M:%S}] {actor}: {self.message}"

52
app/apps/audit/signals.py Normal file
View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.dispatch import receiver
from django.utils import timezone
from .models import AuditEventType, AuditLog
User = get_user_model()
def _get_or_create_event(key: str, title: str, severity: str = AuditEventType.Severity.INFO) -> AuditEventType:
event, _ = AuditEventType.objects.get_or_create(
key=key,
defaults={"title": title, "default_severity": severity},
)
return event
@receiver(user_logged_in)
def on_user_logged_in(sender, request, user: User, **kwargs):
event = _get_or_create_event("user_login", "User logged in", AuditEventType.Severity.INFO)
AuditLog.objects.create(
created_at=timezone.now(),
actor=user,
event_type=event,
message=f"User {user} logged in",
severity=event.default_severity,
source=AuditLog.Source.UI,
ip_address=(request.META.get("REMOTE_ADDR") if request else None),
user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
metadata={"path": request.path} if request else {},
)
@receiver(user_logged_out)
def on_user_logged_out(sender, request, user: User, **kwargs):
event = _get_or_create_event("user_logout", "User logged out", AuditEventType.Severity.INFO)
AuditLog.objects.create(
created_at=timezone.now(),
actor=user,
event_type=event,
message=f"User {user} logged out",
severity=event.default_severity,
source=AuditLog.Source.UI,
ip_address=(request.META.get("REMOTE_ADDR") if request else None),
user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
metadata={"path": request.path} if request else {},
)

View File

@@ -1,6 +1,4 @@
# apps/dashboard/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
from django.contrib.auth.models import User, Group
@@ -9,28 +7,22 @@ from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationFo
from unfold.admin import ModelAdmin
# Unregister the default Group admin and register with Unfold
# Unregister default and re-register User/Group with Unfold admin
try:
admin.site.unregister(Group)
admin.site.unregister(User)
except admin.sites.NotRegistered:
pass
@admin.register(User)
class UserAdmin(BaseUserAdmin, ModelAdmin):
# Forms loaded from `unfold.forms`
form = UserChangeForm
add_form = UserCreationForm
change_password_form = AdminPasswordChangeForm
# Set to False, to enable filter as "sidebar"
list_filter_sheet = True
# Display fields in changeform in compressed mode
compressed_fields = True # Default: False
# Warn before leaving unsaved changes in changeform
warn_unsaved_form = True # Default: False
compressed_fields = True
warn_unsaved_form = True
@admin.register(Group)
@@ -38,16 +30,4 @@ class GroupAdmin(BaseGroupAdmin, ModelAdmin):
pass
# # Custom dashboard view
# def custom_dashboard(request):
# context = {
# "user_count": get_user_model().objects.count(),
# "group_count": auth_models.Group.objects.count(),
# }
# return render(request, "unfold/dashboard.html", context)
# # Add the URL to admin
# admin.site.get_urls = (
# lambda self: [path("", custom_dashboard, name="index")] + self.get_urls()
# ).__get__(admin.site)
# No index override; use Unfold dashboard sections

View File

@@ -0,0 +1,29 @@
from django.conf import settings
from django.contrib.auth import get_user_model, models as auth_models
def dashboard_status(request):
user_count = get_user_model().objects.count()
group_count = auth_models.Group.objects.count()
auth_mode = getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid")
has_superuser = get_user_model().objects.filter(is_superuser=True).exists()
oidc_configured = bool(
getattr(settings, "OIDC_RP_CLIENT_ID", None)
and getattr(settings, "OIDC_RP_CLIENT_SECRET", None)
and getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", None)
and getattr(settings, "OIDC_OP_TOKEN_ENDPOINT", None)
and getattr(settings, "OIDC_OP_USER_ENDPOINT", None)
and getattr(settings, "OIDC_OP_JWKS_ENDPOINT", None)
)
return {
"dashboard_status": {
"auth_mode": auth_mode,
"user_count": user_count,
"group_count": group_count,
"has_superuser": has_superuser,
"oidc_configured": oidc_configured,
}
}

View File

@@ -0,0 +1,3 @@
# empty

29
app/apps/servers/admin.py Normal file
View File

@@ -0,0 +1,29 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import Server
@admin.register(Server)
class ServerAdmin(admin.ModelAdmin):
list_display = ("avatar", "display_name", "hostname", "ipv4", "ipv6", "created_at")
list_display_links = ("display_name",)
search_fields = ("display_name", "hostname", "ipv4", "ipv6")
list_filter = ("created_at",)
readonly_fields = ("created_at", "updated_at")
fields = ("display_name", "hostname", "ipv4", "ipv6", "image", "created_at", "updated_at")
def avatar(self, obj: Server):
if obj.image_url:
return format_html(
'<img src="{}" alt="{}" style="width:28px;height:28px;border-radius:6px;object-fit:cover;" />',
obj.image_url,
obj.display_name,
)
initial = obj.initial
return format_html(
'<div style="width:28px;height:28px;border-radius:6px;background:#7C3AED;color:white;display:flex;align-items:center;justify-content:center;font-weight:600;">{}</div>',
initial,
)
avatar.short_description = ""

9
app/apps/servers/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class ServersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.servers"
verbose_name = "Servers"

View File

@@ -0,0 +1,45 @@
from django.db import migrations, models
import django.core.validators
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Server",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("display_name", models.CharField(max_length=128)),
(
"hostname",
models.CharField(
blank=True,
max_length=253,
null=True,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Enter a valid hostname.",
regex="^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*\\.?$",
)
],
),
),
("ipv4", models.GenericIPAddressField(blank=True, null=True, protocol="IPv4", unique=True)),
("ipv6", models.GenericIPAddressField(blank=True, null=True, protocol="IPv6", unique=True)),
("image", models.ImageField(blank=True, null=True, upload_to="servers/")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Server",
"verbose_name_plural": "Servers",
"ordering": ["display_name", "hostname", "ipv4", "ipv6"],
},
),
]

View File

@@ -0,0 +1,3 @@
# empty

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from django.core.validators import RegexValidator
from django.db import models
from django.utils.text import slugify
hostname_validator = RegexValidator(
regex=r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*\.?$",
message="Enter a valid hostname.",
)
class Server(models.Model):
display_name = models.CharField(max_length=128)
hostname = models.CharField(max_length=253, null=True, blank=True, unique=True, validators=[hostname_validator])
ipv4 = models.GenericIPAddressField(null=True, blank=True, protocol="IPv4", unique=True)
ipv6 = models.GenericIPAddressField(null=True, blank=True, protocol="IPv6", unique=True)
image = models.ImageField(upload_to="servers/", null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["display_name", "hostname", "ipv4", "ipv6"]
verbose_name = "Server"
verbose_name_plural = "Servers"
def __str__(self) -> str:
primary = self.hostname or self.ipv4 or self.ipv6 or "unassigned"
return f"{self.display_name} ({primary})"
@property
def image_url(self) -> str | None:
try:
return self.image.url if self.image else None
except Exception:
return None
@property
def initial(self) -> str:
return (self.display_name or "?").strip()[:1].upper() or "?"