Skip to content
Merged
44 changes: 38 additions & 6 deletions backend/apps/owasp/admin/entity_channel.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Admin configuration for the EntityChannel model."""

from django import forms
from django.contrib import admin, messages
from django.contrib.contenttypes.models import ContentType

Expand All @@ -8,8 +9,15 @@


@admin.action(description="Mark selected EntityChannels as reviewed")
def mark_as_reviewed(_modeladmin, request, queryset):
"""Admin action to mark selected EntityChannels as reviewed."""
def mark_as_reviewed(_modeladmin, request, queryset) -> None:
"""Admin action to mark selected EntityChannels as reviewed.

Args:
_modeladmin: The ModelAdmin instance (unused).
request: The HTTP request object.
queryset: QuerySet of EntityChannel instances to mark as reviewed.

"""
messages.success(
request,
f"Marked {queryset.update(is_reviewed=True)} EntityChannel(s) as reviewed.",
Expand Down Expand Up @@ -61,8 +69,19 @@ class EntityChannelAdmin(admin.ModelAdmin):
"channel_id",
)

def channel_search_display(self, obj):
"""Display the channel name for the selected channel."""
def channel_search_display(self, obj) -> str:
"""Display the channel name in the admin list view.

Retrieves and displays the Conversation name if the channel_id
references a valid Slack conversation.

Args:
obj: The EntityChannel instance.

Returns:
str: Channel name as '#name', error message, or '-' if not found.

"""
if obj.channel_id and obj.channel_type:
try:
if obj.channel_type.model == "conversation":
Expand All @@ -74,8 +93,21 @@ def channel_search_display(self, obj):

channel_search_display.short_description = "Channel Name"

def get_form(self, request, obj=None, **kwargs):
"""Get the form for the EntityChannel model."""
def get_form(self, request, obj=None, **kwargs) -> forms.ModelForm:
"""Get the form for the EntityChannel model.

Prepares the form with necessary metadata for channel selection
and custom widgets.

Args:
request: The HTTP request object.
obj: The EntityChannel instance being edited (None for add).
**kwargs: Additional keyword arguments passed to parent.

Returns:
Form: The EntityChannel form with conversation content type metadata.

"""
form = super().get_form(request, obj, **kwargs)
form.conversation_content_type_id = ContentType.objects.get_for_model(Conversation).id

Expand Down
56 changes: 48 additions & 8 deletions backend/apps/owasp/admin/entity_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils.html import format_html
Expand Down Expand Up @@ -41,16 +42,32 @@ class EntityMemberAdmin(admin.ModelAdmin):
ordering = ("member__name", "order")

@admin.action(description="Approve selected members")
def approve_members(self, request, queryset):
"""Approve selected members."""
def approve_members(self, request, queryset) -> None:
"""Admin action to approve selected members.

Sets is_active and is_reviewed flags for selected entity members.

Args:
request: The HTTP request object.
queryset: QuerySet of EntityMember instances to approve.

"""
self.message_user(
request,
f"Successfully approved {queryset.update(is_active=True, is_reviewed=True)} members.",
)

@admin.display(description="Entity", ordering="entity_type")
def entity(self, obj):
"""Return entity link."""
def entity(self, obj) -> str:
"""Display entity as a link in the admin list view.

Args:
obj: The EntityMember instance.

Returns:
str: HTML link to the entity's admin page or '-' if entity is missing.

"""
return (
format_html(
'<a href="{}" target="_blank">{}</a>',
Expand All @@ -65,16 +82,39 @@ def entity(self, obj):
)

@admin.display(description="OWASP URL", ordering="entity_type")
def owasp_url(self, obj):
"""Return entity OWASP site URL."""
def owasp_url(self, obj) -> str:
"""Display entity OWASP website link in admin list view.

Args:
obj: The EntityMember instance.

Returns:
str: HTML link to the entity's OWASP website or '-' if entity is missing.

"""
return (
format_html('<a href="{}" target="_blank">↗️</a>', obj.entity.owasp_url)
if obj.entity
else "-"
)

def get_search_results(self, request, queryset, search_term):
"""Get search results from entity name or key."""
def get_search_results(self, request, queryset, search_term) -> tuple[models.QuerySet, bool]:
"""Extend search results to include entity name or key matches.

Searches across Project, Chapter, and Committee entities by name or key
and includes matching results in the EntityMember search results.

Args:
request: The HTTP request object.
queryset: Initial QuerySet of EntityMember instances.
search_term: The search term entered by the user.

Returns:
tuple: (QuerySet, use_distinct) where QuerySet includes matching members
and related entities by name/key, and use_distinct indicates
if DISTINCT should be applied to the query.

"""
queryset, use_distinct = super().get_search_results(request, queryset, search_term)

if search_term:
Expand Down
19 changes: 15 additions & 4 deletions backend/apps/owasp/admin/member_profile.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Django admin configuration for MemberProfile model."""

from django.contrib import admin
from django.db import models

from apps.owasp.models.member_profile import MemberProfile

Expand Down Expand Up @@ -71,10 +72,20 @@ class MemberProfileAdmin(admin.ModelAdmin):
),
)

def get_queryset(self, request):
"""Optimize queryset with select_related."""
queryset = super().get_queryset(request)
return queryset.select_related("github_user")
def get_queryset(self, request) -> models.QuerySet:
"""Retrieve optimized queryset with related GitHub user.

Applies select_related on github_user to reduce database queries when displaying
member profile lists.

Args:
request: The HTTP request object.

Returns:
QuerySet: MemberProfile queryset with prefetched github_user relations.

"""
return super().get_queryset(request).select_related("github_user")


admin.site.register(MemberProfile, MemberProfileAdmin)
19 changes: 15 additions & 4 deletions backend/apps/owasp/admin/member_snapshot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Django admin configuration for MemberSnapshot model."""

from django.contrib import admin
from django.db import models

from apps.owasp.models.member_snapshot import MemberSnapshot

Expand Down Expand Up @@ -107,10 +108,20 @@ class MemberSnapshotAdmin(admin.ModelAdmin):
),
)

def get_queryset(self, request):
"""Optimize queryset with select_related."""
queryset = super().get_queryset(request)
return queryset.select_related("github_user")
def get_queryset(self, request) -> models.QuerySet:
"""Retrieve optimized queryset with related GitHub user.

Applies select_related on github_user to reduce database queries when displaying
member snapshot lists.

Args:
request: The HTTP request object.

Returns:
QuerySet: MemberSnapshot queryset with select_related("github_user") applied.

"""
return super().get_queryset(request).select_related("github_user")


admin.site.register(MemberSnapshot, MemberSnapshotAdmin)
113 changes: 92 additions & 21 deletions backend/apps/owasp/admin/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.contrib.contenttypes.admin import GenericTabularInline
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.html import format_html
from django.utils.safestring import mark_safe

Expand All @@ -25,16 +26,29 @@ class BaseOwaspAdminMixin:
"key",
)

def get_base_list_display(self, *additional_fields):
"""Get base list display with additional fields."""
return tuple(
("name",) if hasattr(self.model, "name") else (),
*additional_fields,
*self.list_display_field_names,
)
def get_base_list_display(self, *additional_fields) -> tuple[str, ...]:
"""Build base list display tuple with additional fields.

Args:
*additional_fields: Extra field names to include in list display.

Returns:
tuple: Base display fields (name, timestamps) plus additional fields.

"""
base_fields = ("name",) if hasattr(self.model, "name") else ()
return base_fields + tuple(additional_fields) + self.list_display_field_names

def get_base_search_fields(self, *additional_fields) -> tuple[str, ...]:
"""Build base search fields tuple with additional fields.

Args:
*additional_fields: Extra field names to include in search.

Returns:
tuple: Base search fields (name, key) plus additional fields.

def get_base_search_fields(self, *additional_fields):
"""Get base search fields with additional fields."""
"""
return self.search_field_names + additional_fields


Expand Down Expand Up @@ -80,7 +94,20 @@ class EntityChannelInline(GenericTabularInline):
ordering = ("platform", "channel_id")

def formfield_for_dbfield(self, db_field, request, **kwargs):
"""Override to add custom widget for channel_id field and limit channel_type options."""
"""Customize form fields for EntityChannel inline editing.

Adds a custom widget for channel_id field and limits channel_type options
to only Conversation (Slack channels).

Args:
db_field: The database field being customized.
request: The HTTP request object.
**kwargs: Additional keyword arguments passed to parent.

Returns:
Field: The customized form field.

"""
if db_field.name == "channel_id":
kwargs["widget"] = ChannelIdWidget()
elif db_field.name == "channel_type":
Expand All @@ -95,12 +122,28 @@ def formfield_for_dbfield(self, db_field, request, **kwargs):
class GenericEntityAdminMixin(BaseOwaspAdminMixin):
"""Mixin for generic entity admin with common entity functionality."""

def get_queryset(self, request):
"""Get queryset with optimized relations."""
return super().get_queryset(request).prefetch_related("repositories")
def get_queryset(self, request) -> models.QuerySet:
"""Retrieve optimized queryset with prefetched repositories.

Args:
request: The HTTP request object.

Returns:
QuerySet: Queryset with prefetched repositories for efficient display.

"""
return super().get_queryset(request).prefetch_related("repositories") # type: ignore[misc]

def custom_field_github_urls(self, obj):
"""Entity GitHub URLs with uniform formatting."""
def custom_field_github_urls(self, obj) -> str:
"""Display entity GitHub repository links in admin list view.

Args:
obj: The entity object with repositories.

Returns:
str: HTML-formatted links to GitHub repositories.

"""
if not hasattr(obj, "repositories"):
if not hasattr(obj, "owasp_repository") or not obj.owasp_repository:
return ""
Expand All @@ -112,15 +155,31 @@ def custom_field_github_urls(self, obj):
" ".join(links)
)

def custom_field_owasp_url(self, obj):
"""Entity OWASP URL with uniform formatting."""
def custom_field_owasp_url(self, obj) -> str:
"""Display entity OWASP website link in admin list view.

Args:
obj: The entity object with a key attribute.

Returns:
str: HTML-formatted link to OWASP website or empty string.

"""
if not hasattr(obj, "key") or not obj.key:
return ""

return format_html("<a href='https://owasp.org/{}' target='_blank'>↗️</a>", obj.key)

def _format_github_link(self, repository):
"""Format a single GitHub repository link."""
def _format_github_link(self, repository) -> str:
"""Format a GitHub repository link as HTML.

Args:
repository: The repository object with owner and key attributes.

Returns:
str: HTML-formatted link to GitHub repository or empty string if invalid.

"""
if not repository or not hasattr(repository, "owner") or not repository.owner:
return ""
if not hasattr(repository.owner, "login") or not repository.owner.login:
Expand All @@ -143,8 +202,20 @@ class StandardOwaspAdminMixin(BaseOwaspAdminMixin):

def get_common_config(
self, extra_list_display=None, extra_search_fields=None, extra_list_filters=None
):
"""Get common admin configuration to reduce boilerplate."""
) -> dict:
"""Build common admin configuration dictionary.

Reduces boilerplate by merging base fields with custom additions.

Args:
extra_list_display: Additional fields to display in list view.
extra_search_fields: Additional fields to include in search.
extra_list_filters: Additional fields to use for filtering.

Returns:
dict: Configuration dictionary with list_display, search_fields, and list_filter.

"""
config = {}

if extra_list_display:
Expand Down
Loading