errrr
This commit is contained in:
@@ -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.
|
||||
18
app/apps/accounts/migrations/0003_alter_account_email.py
Normal file
18
app/apps/accounts/migrations/0003_alter_account_email.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
16
app/apps/accounts/migrations/0004_delete_account.py
Normal file
16
app/apps/accounts/migrations/0004_delete_account.py
Normal 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",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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", "/"))
|
||||
|
||||
|
||||
3
app/apps/audit/__init__.py
Normal file
3
app/apps/audit/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
default_app_config = "apps.audit.apps.AuditConfig"
|
||||
|
||||
|
||||
91
app/apps/audit/admin.py
Normal file
91
app/apps/audit/admin.py
Normal 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
15
app/apps/audit/apps.py
Normal 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()
|
||||
|
||||
|
||||
60
app/apps/audit/migrations/0001_initial.py
Normal file
60
app/apps/audit/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
19
app/apps/audit/migrations/0002_alter_auditlog_event_type.py
Normal file
19
app/apps/audit/migrations/0002_alter_auditlog_event_type.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
3
app/apps/audit/migrations/__init__.py
Normal file
3
app/apps/audit/migrations/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# empty
|
||||
|
||||
|
||||
112
app/apps/audit/models.py
Normal file
112
app/apps/audit/models.py
Normal 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
52
app/apps/audit/signals.py
Normal 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 {},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
29
app/apps/dashboard/context.py
Normal file
29
app/apps/dashboard/context.py
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
3
app/apps/servers/__init__.py
Normal file
3
app/apps/servers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# empty
|
||||
|
||||
|
||||
29
app/apps/servers/admin.py
Normal file
29
app/apps/servers/admin.py
Normal 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
9
app/apps/servers/apps.py
Normal 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"
|
||||
|
||||
|
||||
45
app/apps/servers/migrations/0001_initial.py
Normal file
45
app/apps/servers/migrations/0001_initial.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
3
app/apps/servers/migrations/__init__.py
Normal file
3
app/apps/servers/migrations/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# empty
|
||||
|
||||
|
||||
43
app/apps/servers/models.py
Normal file
43
app/apps/servers/models.py
Normal 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 "?"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user