diff --git a/main/admin.py b/main/admin.py index 674949a..4f4baa4 100644 --- a/main/admin.py +++ b/main/admin.py @@ -2,30 +2,116 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.http import HttpRequest -from .models import Category, Skill, SkillLevel, User, UserSkill +from .models import ( + Category, + LearningResource, + Provider, + Skill, + SkillLevel, + Tool, + User, + UserSkill, +) admin.site.register(User, UserAdmin) +class CategoryInline(admin.TabularInline[Category, Category]): + """Inline admin class for the Category model.""" + + model = Category + extra = 0 + show_change_link = True + exclude = ("slug",) + verbose_name = "Sub-Category" + verbose_name_plural = "Sub-Categories" + + +class SkillInline(admin.TabularInline[Skill, Category]): + """Inline admin class for the Skill model.""" + + model = Skill + extra = 0 + + @admin.register(Category) class CategoryAdmin(admin.ModelAdmin[Category]): """Admin class for the Category model.""" - list_display = ("name", "description", "parent_category") + list_display = ("name", "parent_category") search_fields = ("name", "description") list_filter = ("parent_category",) - ordering = ("name",) + ordering = ("parent_category", "name") + + def get_inlines( # type: ignore[override] + self, request: HttpRequest, obj: Category | None = None + ) -> list[type[CategoryInline] | type[SkillInline]]: + """Return the inlines to use for edit views depending on Category status. + + If the Category is a Parent Category, show the Sub-Categories. + If the Category is a Sub-Category, show the Skills. + """ + if not obj: + return [] + if obj.parent_category is None: + return [CategoryInline] + return [SkillInline] + + +class LearningResourceInline(admin.TabularInline[LearningResource, Provider]): + """Inline admin class for the LearningResource model.""" + + model = LearningResource + extra = 0 + show_change_link = True + exclude = ("slug",) + + +@admin.register(Provider) +class ProviderAdmin(admin.ModelAdmin[Provider]): + """Admin class for The Provider model.""" + + list_display = ("name", "url", "ror") + search_fields = ("name",) + inlines = (LearningResourceInline,) + + +class ToolInline(admin.TabularInline[Tool, LearningResource]): + """Inline admin class for the Tool model.""" + + model = Tool.learning_resources.through # type: ignore[assignment] + extra = 0 + verbose_name = "Tool, Methodology, Behaviour or Language" + verbose_name_plural = "Tools, Methodologies, Behaviours and Languages" + + +@admin.register(LearningResource) +class LearningResourceAdmin(admin.ModelAdmin[LearningResource]): + """Admin class for The LearningResource model.""" + + list_display = ("name", "language", "url", "provider") + search_fields = ("name",) + inlines = (ToolInline,) + + +@admin.register(Tool) +class ToolAdmin(admin.ModelAdmin[Tool]): + """Admin class for The Tool model.""" + + list_display = ("name", "kind", "url") + search_fields = ("name",) @admin.register(Skill) class SkillAdmin(admin.ModelAdmin[Skill]): """Admin class for the Skill model.""" - list_display = ("name", "description", "category") + list_display = ("name", "category", "category__parent_category") search_fields = ("name", "description") - list_filter = ("category",) - ordering = ("name",) + list_filter = ("category__parent_category", "category") + ordering = ("category__parent_category", "category", "name") @admin.register(SkillLevel) @@ -36,9 +122,51 @@ class SkillLevelAdmin(admin.ModelAdmin[SkillLevel]): search_fields = ("name",) -@admin.register(UserSkill) -class UserSkillAdmin(admin.ModelAdmin[UserSkill]): - """Admin class for The UserSkill model.""" +class UserSkillInline(admin.TabularInline[UserSkill, User]): + """Inline admin class for the UserSkill model.""" + + model = UserSkill + extra = 1 + + +class UserProxy(User): + """A proxy model of the User model to create a User Skills admin view. + + This is required to make a second admin view tied to the User model. + """ + + class Meta: + """Make model a proxy and set verbose names.""" + + proxy = True + verbose_name = "User Skills" + verbose_name_plural = "User Skills" + + +@admin.register(UserProxy) +class CustomUserSkillAdmin(admin.ModelAdmin[UserProxy]): + """Admin class for adding UserSkills in bulk to individual Users.""" + + exclude = ( + "username", + "password", + "first_name", + "last_name", + "email", + "last_login", + "is_superuser", + "is_staff", + "is_active", + "date_joined", + "groups", + "user_permissions", + ) + readonly_fields = ("username", "first_name", "last_name", "email", "is_active") + list_display = ("username", "first_name", "last_name", "email", "is_active") + search_fields = ("username", "first_name", "last_name", "email") + list_filter = ("is_active",) + inlines = (UserSkillInline,) - list_display = ("user", "skill", "skill_level") - search_fields = ("user", "skill") + def has_add_permission(self, request: HttpRequest) -> bool: + """Do not allow adding new users from this view.""" + return False diff --git a/main/migrations/0013_userproxy.py b/main/migrations/0013_userproxy.py new file mode 100644 index 0000000..f955e65 --- /dev/null +++ b/main/migrations/0013_userproxy.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.1 on 2026-02-10 12:12 + +import django.contrib.auth.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0012_learningresource_provider_skill_learning_resources_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='UserProxy', + fields=[ + ], + options={ + 'verbose_name': 'User Skills', + 'verbose_name_plural': 'User Skills', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('main.user',), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/tests/main/test_admin_views.py b/tests/main/test_admin_views.py new file mode 100644 index 0000000..bfabdc3 --- /dev/null +++ b/tests/main/test_admin_views.py @@ -0,0 +1,159 @@ +"""Test suite for the main app admin views.""" + +from http import HTTPStatus +from typing import Any, ClassVar + +import pytest +from django.contrib.auth import get_user_model +from django.db.models import Model + +from main.admin import UserProxy +from main.models import ( + Category, + LearningResource, + Provider, + Skill, + SkillLevel, + Tool, +) + + +class AdminMixin: + """Mixin for testing models that have a visible admin view. + + Note: Using this requires the test class to define: + - A `_model` variable that is the model being tested + - A `_data` dict with the kwargs needed to create an instance of the model + """ + + _model: type[Model] + _data: ClassVar[dict[str, Any]] = {} + + def test_list_view(self, admin_client): + """Test the list view of the admin model.""" + response = admin_client.get(f"/admin/main/{self._model._meta.model_name}/") + assert response.status_code == HTTPStatus.OK + + def test_add_get(self, admin_client): + """Test the GET request is successful.""" + response = admin_client.get(f"/admin/main/{self._model._meta.model_name}/add/") + assert response.status_code == HTTPStatus.OK + + def _create_object(self): + """Create the object being changed.""" + return self._model.objects.create(**self._data) + + @pytest.mark.django_db + def test_change_get(self, admin_client): + """Test the GET request to the change view is successful.""" + obj = self._create_object() + response = admin_client.get( + f"/admin/main/{self._model._meta.model_name}/{obj.pk}/change/" + ) + assert response.status_code == HTTPStatus.OK + + +class TestUserAdmin(AdminMixin): + """Test suite for the User admin views.""" + + _model = get_user_model() + _data: ClassVar[dict[str, Any]] = { + "username": "TestUser", + "password": "testpassword", + } + + +class TestCategoryAdmin(AdminMixin): + """Test suite for the Category admin views.""" + + _model = Category + _data: ClassVar[dict[str, Any]] = { + "name": "Test Category", + "description": "Test Description", + } + + +class TestProviderAdmin(AdminMixin): + """Test suite for the Provider admin views.""" + + _model = Provider + _data: ClassVar[dict[str, Any]] = { + "name": "Test Provider", + "description": "Test Description", + "url": "https://example.com", + "ror": "https://ror.org/12345", + } + + +class TestLearningResourceAdmin(AdminMixin): + """Test suite for the LearningResource admin views.""" + + _model = LearningResource + _data: ClassVar[dict[str, Any]] = { + "name": "Test LearningResource", + "description": "Test Description", + "language": "en", + "url": "https://example.com/resource", + "provider": None, + } + + +class TestToolAdmin(AdminMixin): + """Test suite for the Tool admin views.""" + + _model = Tool + _data: ClassVar[dict[str, Any]] = { + "name": "Test Tool", + "description": "Test Description", + } + + +class TestSkillAdmin(AdminMixin): + """Test suite for the Skill admin views.""" + + _model = Skill + _data: ClassVar[dict[str, Any]] = { + "name": "Test Skill", + "description": "Test Description", + } + + def _create_object(self): + """Overwrite to also create the Category for the Skill being changed.""" + parent_category = Category.objects.create( + name="Test Parent Category", description="Test Parent" + ) + subcategory = Category.objects.create( + name="Test Subcategory", + description="Test Subcategory", + parent_category=parent_category, + ) + return Skill.objects.create(category=subcategory, **self._data) + + +class TestSkillLevelAdmin(AdminMixin): + """Test suite for the SkillLevel admin views.""" + + _model = SkillLevel + _data: ClassVar[dict[str, Any]] = { + "name": "Test SkillLevel", + "description": "Test Description", + "level": 1, + } + + +class TestCustomUserSkillAdmin(AdminMixin): + """Test suite for the CustomUserSkill admin views. + + This Model should not have an add view. + """ + + _model = UserProxy + _data: ClassVar[dict[str, Any]] = { + "username": "TestUser", + "password": "testpassword", + } + + def test_add_get(self, admin_client): + """Overwrite to test the GET request to add an object is forbidden.""" + response = admin_client.get(f"/admin/main/{self._model._meta.model_name}/add/") + assert response.status_code == HTTPStatus.FORBIDDEN