Skip to content
Open
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
11 changes: 8 additions & 3 deletions docs/dev-guide/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ DIRECT is a Django project with a single application, `main`.
| `direct_webapp/urls.py` | Project routing, delegating application routes to `main.urls`. |
| `direct_webapp/context_processors.py` | Shared template context, including the site title. |
| `main/models/framework_models.py` | The competency framework's models. |
| `main/models/user_models.py` | The custom user model and user-specific skill assessments. |
| `main/models/user_models.py` | The custom user model, profiles, and profile skill assessments. |
| `main/signals.py` | Signal receivers, including automatic profile creation for new users. |
| `main/models/community_models.py` | Community content, including events. |
| `main/views/` | Views grouped by page, account, and data concerns. |
| `main/forms.py` | Input validation, form-driven updates, and crispy-form layouts. |
Expand All @@ -30,8 +31,12 @@ application does not have a mandatory service or repository layer.
## Domain orientation

The framework describes skills and their levels. A user's self-assessment is
separate from that shared framework data: `UserSkill` associates a user, a skill,
and a skill level.
separate from that shared framework data: `SkillProfile` associates a profile, a
skill, and a skill level. A profile can exist without a user, but every user has
exactly one profile, created automatically by a `post_save` receiver in
`main/signals.py`. Code can therefore rely on `user.profile`. Users loaded from
fixtures do not trigger this receiver, so such fixtures must include their
profiles.

The configured authentication model is `main.User`. Account views use the current
request's user for personal data. Existing access behavior includes authentication
Expand Down
7 changes: 7 additions & 0 deletions docs/dev-guide/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ pre-commit run --files <relevant-file-paths>
mkdocs build --strict
```

Ensure the documentation that is built is deleted:

```bash
rm -r site/
```

At handover, report commands run and their results, including failures or checks
that could not run. Distinguish a pre-existing failure from one introduced by
the change; an unrun check is not a passing check.
Expand All @@ -76,4 +82,5 @@ Before handover:
- Update directly related documentation and examples for feature changes.
- Run pre-commit checks on changed files.
- Run the documentation build for documentation changes.
- Delete built documentation.
- Report commands run and their results.
115 changes: 68 additions & 47 deletions main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
CompetencyDomain,
Event,
LearningResource,
Profile,
Provider,
Skill,
SkillLevel,
SkillProfile,
Team,
TeamMembership,
ToolLanguageMethodology,
User,
UserSkill,
)


Expand Down Expand Up @@ -159,13 +160,6 @@ class SkillLevelAdmin(ImportExportModelAdmin[SkillLevel]):
resource_classes = (SkillLevelResource,)


class UserSkillInline(admin.TabularInline[UserSkill, User]):
"""Inline admin class for the UserSkill model."""

model = UserSkill
extra = 1


@admin.register(Event)
class EventAdmin(ImportExportModelAdmin[Event]):
"""Admin class for the Event model."""
Expand All @@ -176,53 +170,80 @@ class EventAdmin(ImportExportModelAdmin[Event]):
resource_classes = (EventResource,)


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 SkillProfileInline(admin.TabularInline[SkillProfile, Profile]):
"""Inline admin class for the SkillProfile model."""

class Meta:
"""Make model a proxy and set verbose names."""

proxy = True
verbose_name = "User skills profile"
model = SkillProfile
extra = 1


@admin.register(UserProxy)
class CustomUserSkillsProfilesAdmin(admin.ModelAdmin[UserProxy]):
"""Admin class for adding UserSkills in bulk to individual Users."""
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin[Profile]):
Comment thread
AdrianDAlessandro marked this conversation as resolved.
Resolved
"""Admin class for the Profile model."""

exclude = (
"username",
"password",
"first_name",
"last_name",
"email",
"last_login",
"is_superuser",
"is_staff",
"is_active",
"date_joined",
"groups",
"user_permissions",
"agreed_to_tos",
"date_agreed",
fields = (
"user",
"user_first_name",
"user_last_name",
"user_email",
"user_is_active",
)
readonly_fields = ("username", "first_name", "last_name", "email", "is_active")
list_display = ("username", "is_active")
search_fields = ("username", "email")
list_filter = ("is_active",)
inlines = (UserSkillInline,)

def has_add_permission(self, request: HttpRequest) -> bool:
"""Do not allow adding new users from this view."""
return False
list_display = (
"user",
"user__first_name",
"user__last_name",
"user__email",
"user__is_active",
)
search_fields = (
"user__username",
"user__first_name",
"user__last_name",
"user__email",
"user__is_active",
)
list_filter = ("user__is_active",)
readonly_fields = fields
inlines = (SkillProfileInline,)

@admin.display(description="First name", ordering="user__first_name")
def user_first_name(self, obj: Profile) -> str:
"""Render user first name in the detail/edit view."""
if obj.user is None:
return ""
return obj.user.first_name

@admin.display(description="Last name", ordering="user__last_name")
def user_last_name(self, obj: Profile) -> str:
"""Render user last name in the detail/edit view."""
if obj.user is None:
return ""
return obj.user.last_name

@admin.display(description="Email", ordering="user__email")
def user_email(self, obj: Profile) -> str:
"""Render user email in the detail/edit view."""
if obj.user is None:
return ""
return obj.user.email

@admin.display(description="Active", boolean=True, ordering="user__is_active")
def user_is_active(self, obj: Profile) -> bool:
"""Render user is_active status in the detail/edit view."""
if obj.user is None:
return False
return obj.user.is_active

def has_delete_permission(
self, request: HttpRequest, obj: UserProxy | None = None
self, request: HttpRequest, obj: Profile | None = None
) -> bool:
"""Do not allow deleting users from this view."""
"""Prevent direct deletion of Profiles with associated users."""
# Show checkboxes for bulk-deletion
if obj is None:
return True
Comment on lines +242 to +243

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there might be an issue with this. But I might be completely wrong 😅

So this is here so that the bulk deletion checkboxes appear, that's fine. But, the problem is that Django’s built-in Delete selected profiles action then deletes the selected profiles and I'm not sure it repeats this custom permission check separately for each profile. So a user with the relevant delete permissions can select a user-linked profile and delete it in bulk, even though deleting that same profile from its individual profile page is blocked.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure of the exact order of things, but this is what I landed on when trying multiple options. It does appear that this function is called multiple times, at multiple stages, both with and without obj defined. When trying to delete a user-linked profile with the bulk-delete facility, you get a page saying you don't have permission:

Screenshot 2026-11-02 at 14 05 45

But it remains possible to delete profiles with no associated user.

# Allow deletion via the User admin page and when there is no related user
if "user" in request.path or obj.user is None:
return True
return False


Expand Down
4 changes: 4 additions & 0 deletions main/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ class MainConfig(AppConfig):

default_auto_field = "django.db.models.BigAutoField"
name = "main"

def ready(self) -> None:
"""Connect the app's signal receivers."""
from . import signals # noqa: F401
60 changes: 25 additions & 35 deletions main/forms.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
"""Forms module for the main app."""

from typing import TYPE_CHECKING, Any
from typing import Any

from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Div, Layout, Submit
from django import forms
from django.core.exceptions import ValidationError
from django.forms import ModelForm
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
Expand All @@ -16,10 +15,7 @@
RegistrationFormUniqueEmail,
)

from .models import Skill, SkillLevel, UserSkill

if TYPE_CHECKING: # pragma: no cover
from .models import User as UserType
from .models import Profile, Skill, SkillLevel, SkillProfile


def _build_tos_form_label() -> str:
Expand Down Expand Up @@ -73,20 +69,12 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
self.fields["tos"].label = _(_build_tos_form_label())


class UserSkillForm(ModelForm[UserSkill]):
"""Form for user skills."""

class Meta: # noqa: D106
model = UserSkill
fields = ("skill", "skill_level")


class UserSkillsForm(forms.Form):
"""Form for creating UserSkills for all skills in the database."""
class SkillProfileForm(forms.Form):
"""Form for creating SkillProfiles for all skills in the database."""

def __init__(self, *args: Any, user: "UserType", **kwargs: Any) -> None:
def __init__(self, *args: Any, profile: Profile, **kwargs: Any) -> None:
"""Initialize the form with a field for each skill."""
self.user = user
self.profile = profile
super().__init__(*args, **kwargs)

# Get all skills, ordered by competency for better organization
Expand All @@ -107,19 +95,21 @@ def __init__(self, *args: Any, user: "UserType", **kwargs: Any) -> None:
for skill in skills:
field_name = f"skill_{skill.id}"

# Check if user already has this skill
existing_user_skill = None
if self.user:
# Check if profile already has this skill
existing_skill_profile = None
if self.profile:
try:
existing_user_skill = UserSkill.objects.get(
user=self.user, skill=skill
existing_skill_profile = SkillProfile.objects.get(
profile=self.profile, skill=skill
)
except UserSkill.DoesNotExist:
except SkillProfile.DoesNotExist:
pass

# Set initial value if user already has this skill
initial_value = (
existing_user_skill.skill_level.id if existing_user_skill else None
existing_skill_profile.skill_level.id
if existing_skill_profile
else None
)

# Organize skills by competency hierarchy for layout
Expand Down Expand Up @@ -244,8 +234,8 @@ def __init__(self, *args: Any, user: "UserType", **kwargs: Any) -> None:

self.helper.layout = Layout(*layout_elements)

def save(self, user: "UserType") -> tuple[list[UserSkill], list[UserSkill]]:
"""Save the form data as UserSkill instances."""
def save(self, profile: Profile) -> tuple[list[SkillProfile], list[SkillProfile]]:
"""Save the form data as SkillProfile instances."""
created_skills = []
updated_skills = []

Expand All @@ -255,17 +245,17 @@ def save(self, user: "UserType") -> tuple[list[UserSkill], list[UserSkill]]:
skill = Skill.objects.get(id=skill_id)
skill_level = SkillLevel.objects.get(id=skill_level_id)

# Check if UserSkill already exists
user_skill, created = UserSkill.objects.get_or_create(
user=user, skill=skill, defaults={"skill_level": skill_level}
# Check if SkillProfile already exists
skill_profile, created = SkillProfile.objects.get_or_create(
profile=profile, skill=skill, defaults={"skill_level": skill_level}
)

if not created:
# Update existing UserSkill
user_skill.skill_level = skill_level
user_skill.save()
updated_skills.append(user_skill)
# Update existing SkillProfile
skill_profile.skill_level = skill_level
skill_profile.save()
updated_skills.append(skill_profile)
else:
created_skills.append(user_skill)
created_skills.append(skill_profile)

return created_skills, updated_skills
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 6.1.1 on 2026-10-27 08:16

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('main', '0031_team_teammembership'),
]

operations = [
migrations.AlterField(
model_name='userskill',
name='user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='userskill',
name='profile',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='main.profile'),
),
]
Loading
Loading