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
150 changes: 139 additions & 11 deletions main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
30 changes: 30 additions & 0 deletions main/migrations/0013_userproxy.py
Original file line number Diff line number Diff line change
@@ -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()),
],
),
]
159 changes: 159 additions & 0 deletions tests/main/test_admin_views.py
Original file line number Diff line number Diff line change
@@ -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