Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion users/admin.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,93 @@
from datetime import datetime, timedelta

from django.contrib import admin
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin, UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from django.contrib.gis import admin
from django.db.models import Exists, OuterRef
from django.utils import timezone
from django.utils.formats import localize
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from social_django.models import UserSocialAuth

from traffic_control.admin.operational_area import GroupOperationalAreaInline
from traffic_control.admin.responsible_entity import GroupResponsibleEntityInline

from .models import User


class AuthenticationTypeFilter(admin.SimpleListFilter):
"""
Filter users by their authentication type (Local, Azure AD, Both, or None).
"""

title = _("authentication type")
parameter_name = "auth_type"

def lookups(self, request, model_admin):
"""
Define filter options.

Args:
request: The current request object.
model_admin: The model admin instance.

Returns:
list: List of tuples (value, label) for filter options.
"""
return [
("local", _("Local")),
("oidc", _("Azure AD")),
("both", _("Both")),
("none", _("None")),
]

def queryset(self, request, queryset):
"""
Filter queryset based on selected authentication type.

Args:
request: The current request object.
queryset: The queryset to filter.

Returns:
QuerySet: Filtered queryset based on authentication type.
"""
value = self.value()
if not value:
return queryset

# Annotate queryset with has_oidc field
has_social_auth = Exists(UserSocialAuth.objects.filter(user=OuterRef("pk"), provider="tunnistamo"))
queryset = queryset.annotate(has_oidc=has_social_auth)

if value == "local":
# Password only: has usable password AND no social auth
return queryset.filter(has_oidc=False).exclude(password__startswith="!")

if value == "oidc":
# Azure AD only: has social auth AND no usable password
return queryset.filter(has_oidc=True, password__startswith="!")

if value == "both":
# Both: has social auth AND has usable password
return queryset.filter(has_oidc=True).exclude(password__startswith="!")

if value == "none":
# Neither: no social auth AND no usable password
return queryset.filter(has_oidc=False, password__startswith="!")

return queryset


class UserAdmin(BaseUserAdmin):
fieldsets = BaseUserAdmin.fieldsets + (
(
_("Authentication Type"),
{
"fields": ("auth_type_display",),
},
),
(_("Additional user information"), {"fields": ("additional_information",)}),
(
_("Operational area"),
Expand All @@ -36,12 +108,14 @@ class UserAdmin(BaseUserAdmin):
},
),
)
readonly_fields = ("auth_type_display",)
filter_horizontal = BaseUserAdmin.filter_horizontal + (
"operational_areas",
"responsible_entities",
)
list_display = (
"username",
"auth_type_display",
"email",
"first_name",
"last_name",
Expand All @@ -50,6 +124,36 @@ class UserAdmin(BaseUserAdmin):
"is_staff",
"is_superuser",
)
list_filter = BaseUserAdmin.list_filter + (AuthenticationTypeFilter,)

def get_queryset(self, request):
"""
Optimize queryset by prefetching social_auth relationships.

Args:
request: The current request object.

Returns:
QuerySet: Optimized queryset with prefetched social_auth.
"""
queryset = super().get_queryset(request)
return queryset.prefetch_related("social_auth")

@admin.display(description=_("Authentication Type"))
def auth_type_display(self, obj: User) -> str:
"""
Display authentication type with color coding.

Args:
obj (User): The user instance.

Returns:
str: HTML formatted authentication type with color.
"""
text, color = obj.get_auth_type()
if color:
return format_html('<span style="color: {};">{}</span>', color, text)
return text

@admin.display(description=_("last login"), ordering="last_login")
def last_login_highlighted(self, obj: User):
Expand Down
42 changes: 41 additions & 1 deletion users/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import uuid
from typing import Optional, TYPE_CHECKING
from typing import Optional, Tuple, TYPE_CHECKING

from auditlog.registry import auditlog
from django.contrib.auth.models import Group
Expand Down Expand Up @@ -90,6 +90,46 @@ def has_responsible_entity_permission(self, responsible_entity: Optional["Respon
.exists()
)

def is_oidc_user(self) -> bool:
"""
Check if user can authenticate via Azure AD (OIDC through Tunnistamo).

Returns:
bool: True if user has a social auth association with Tunnistamo provider.
"""
return self.social_auth.filter(provider="tunnistamo").exists()

def is_local_user(self) -> bool:
"""
Check if user can authenticate via local password.

Returns:
bool: True if user has a usable password set.
"""
return self.has_usable_password()

def get_auth_type(self) -> Tuple[str, Optional[str]]:
"""
Get user's authentication type with display color.

Returns:
Tuple[str, Optional[str]]: Display text and color code.
- ("Local", "red") for password-only authentication
- ("Azure AD", "green") for OIDC-only authentication
- ("Both", "chocolate") for both authentication methods
- ("None", None) for no authentication method
"""
has_local = self.is_local_user()
has_oidc = self.is_oidc_user()

if has_local and has_oidc:
return ("Both", "chocolate")
if has_local:
return ("Local", "red")
if has_oidc:
return ("Azure AD", "green")
return ("None", None)

class Meta:
verbose_name = _("User")
verbose_name_plural = _("Users")
Expand Down
Loading
Loading