diff --git a/backend/apps/owasp/admin/entity_channel.py b/backend/apps/owasp/admin/entity_channel.py index 2f2bcf3c86..bb4d263fce 100644 --- a/backend/apps/owasp/admin/entity_channel.py +++ b/backend/apps/owasp/admin/entity_channel.py @@ -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 @@ -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.", @@ -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": @@ -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 diff --git a/backend/apps/owasp/admin/entity_member.py b/backend/apps/owasp/admin/entity_member.py index 1aef460fbb..1290b2c014 100644 --- a/backend/apps/owasp/admin/entity_member.py +++ b/backend/apps/owasp/admin/entity_member.py @@ -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 @@ -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( '{}', @@ -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('↗️', 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: diff --git a/backend/apps/owasp/admin/member_profile.py b/backend/apps/owasp/admin/member_profile.py index 9e5ec7ddaa..412bc9eb38 100644 --- a/backend/apps/owasp/admin/member_profile.py +++ b/backend/apps/owasp/admin/member_profile.py @@ -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 @@ -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) diff --git a/backend/apps/owasp/admin/member_snapshot.py b/backend/apps/owasp/admin/member_snapshot.py index 84a7233852..aa764c51cd 100644 --- a/backend/apps/owasp/admin/member_snapshot.py +++ b/backend/apps/owasp/admin/member_snapshot.py @@ -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 @@ -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) diff --git a/backend/apps/owasp/admin/mixins.py b/backend/apps/owasp/admin/mixins.py index 59b7839484..96137e9042 100644 --- a/backend/apps/owasp/admin/mixins.py +++ b/backend/apps/owasp/admin/mixins.py @@ -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 @@ -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 @@ -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": @@ -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 "" @@ -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("↗️", 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: @@ -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: diff --git a/backend/apps/owasp/admin/project.py b/backend/apps/owasp/admin/project.py index ccbcd817fd..28d7e7ae35 100644 --- a/backend/apps/owasp/admin/project.py +++ b/backend/apps/owasp/admin/project.py @@ -54,7 +54,18 @@ class ProjectAdmin(admin.ModelAdmin, GenericEntityAdminMixin): ) def custom_field_name(self, obj) -> str: - """Project custom name.""" + """Display project name or key in admin list view. + + Uses the project name as the primary display, falling back to the key + if the name is not available. + + Args: + obj: The Project instance. + + Returns: + str: The project name or key. + + """ return f"{obj.name or obj.key}" custom_field_name.short_description = "Name" diff --git a/backend/apps/owasp/admin/project_health_metrics.py b/backend/apps/owasp/admin/project_health_metrics.py index bcc9574d6b..16fd662389 100644 --- a/backend/apps/owasp/admin/project_health_metrics.py +++ b/backend/apps/owasp/admin/project_health_metrics.py @@ -28,8 +28,16 @@ class ProjectHealthMetricsAdmin(admin.ModelAdmin, StandardOwaspAdminMixin): ) search_fields = ("project__name",) - def project(self, obj): - """Display project name.""" + def project(self, obj) -> str: + """Display project name in admin list view. + + Args: + obj: The ProjectHealthMetrics instance. + + Returns: + str: The project name or 'N/A' if no project is associated. + + """ return obj.project.name if obj.project else "N/A" diff --git a/backend/apps/owasp/admin/widgets.py b/backend/apps/owasp/admin/widgets.py index 409fdee692..bf56e80934 100644 --- a/backend/apps/owasp/admin/widgets.py +++ b/backend/apps/owasp/admin/widgets.py @@ -7,13 +7,35 @@ class ChannelIdWidget(forms.TextInput): """Custom widget for channel_id with search functionality.""" - def __init__(self, *args, **kwargs): - """Initialize the widget with custom attributes.""" + def __init__(self, *args, **kwargs) -> None: + """Initialize the ChannelIdWidget. + + Sets up custom CSS classes and placeholder text for the text input field. + + Args: + *args: Positional arguments passed to parent TextInput. + **kwargs: Keyword arguments passed to parent TextInput. + + """ super().__init__(*args, **kwargs) self.attrs.update({"class": "vForeignKeyRawIdAdminField", "placeholder": "Channel ID"}) - def render(self, name, value, attrs=None, renderer=None): - """Render the widget with a search button.""" + def render(self, name, value, attrs=None, renderer=None) -> str: + """Render the widget with a search button. + + Displays the text input field alongside a lookup button for selecting + related Slack conversation objects. + + Args: + name: The HTML field name. + value: The current field value. + attrs: Optional HTML attributes for the widget. + renderer: Optional form renderer instance. + + Returns: + str: HTML markup for the widget and search button. + + """ widget_html = super().render(name, value, attrs, renderer) search_button = ( diff --git a/backend/apps/slack/admin/member.py b/backend/apps/slack/admin/member.py index 7dfa92e34a..e658ae2750 100644 --- a/backend/apps/slack/admin/member.py +++ b/backend/apps/slack/admin/member.py @@ -23,8 +23,21 @@ class MemberAdmin(admin.ModelAdmin): "user__login", ) - def approve_suggested_users(self, request, queryset): - """Approve all suggested users for selected members, enforcing one-to-one constraints.""" + def approve_suggested_users(self, request, queryset) -> None: + """Approve suggested users for selected Slack members. + + For each member in the selection, if exactly one suggested user exists, + assign that user to the member. If multiple or no suggested users exist, + display appropriate warning or error messages. + + Args: + request: The HTTP request object. + queryset: The queryset of Member instances to process. + + Returns: + None: Displays messages to the user via Django's messages framework. + + """ for entity in queryset: suggestions = entity.suggested_users.all()