Skip to content
Open
91 changes: 88 additions & 3 deletions app/core/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from django import forms
from django.contrib import admin
from django.contrib.admin.filters import AllValuesFieldListFilter
from django.contrib.auth.admin import UserAdmin as DefaultUserAdmin
from django.contrib.auth.forms import UserChangeForm as DefaultUserChangeForm
from django.contrib.auth.forms import UserCreationForm as DefaultUserCreationForm
from django.contrib.auth.forms import UsernameField
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

from .models import Accomplishment
Expand Down Expand Up @@ -41,6 +43,7 @@
from .models import User
from .models import UserCheck
from .models import UserEmploymentHistory
from .models import UserPracticeAreaSecondaryXref
from .models import UserStatusType
from .models import Win
from .models import WinType
Expand All @@ -58,6 +61,87 @@ class Meta(DefaultUserCreationForm.Meta):
field_classes = {"username": UsernameField}


class UserAdminForm(UserChangeForm):
"""
Overrides the ui assignment of a through model from outside of the form to inline.
Renders secondary practice area menu inline between "practice area primary" and "practice area target intake".
"""

practice_areas_secondary_virtual = forms.ModelMultipleChoiceField(
queryset=PracticeArea.objects.all(),
required=False,
label="Practice area(s) secondary",
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
# Pre-populate the field with the user's existing secondary practice areas.
self.fields[
"practice_areas_secondary_virtual"
].initial = self.instance.practice_areas_secondary.all()

def clean(self):
# Clean to ensure the user's practice area entries aren't duplicates before saving to the xref table.
cleaned_data = super().clean()
primary = cleaned_data.get("practice_area_primary")
secondaries = cleaned_data.get("practice_areas_secondary_virtual")

if primary and secondaries and primary in secondaries:
raise ValidationError(
{
"practice_areas_secondary_virtual": (
"A user cannot have the same practice area as "
"both primary and secondary."
)
}
)

return cleaned_data

def _save_secondary_areas(self, user):
"""Helper to break complex logic out of save() and satisfy linter."""

# Default to an empty list if user does not have a secondary practice area in xref table.
selected_areas = self.cleaned_data.get("practice_areas_secondary_virtual", [])

# Delete existing records between user and practice area in xref table to avoid duplicate entries.
UserPracticeAreaSecondaryXref.objects.filter(user=user).delete()

if selected_areas:
new_xrefs = [
UserPracticeAreaSecondaryXref(user=user, practice_area=area)
for area in selected_areas
]

UserPracticeAreaSecondaryXref.objects.bulk_create(new_xrefs)

def save(self, commit=True):
"""
Saves the form and handles the custom UserPracticeAreaSecondaryXref bridge table.

When the Django Admin calls save() with commit=False (e.g., when adding a
new user that doesn't have a database ID yet), we must delay our bridge
table updates by hooking them into Django's native save_m2m cleanup process.
"""
user = super().save(commit=commit)

if commit:
self._save_secondary_areas(user)
else:
# Stash Django save_m2m function that handles standard fields.
default_save_m2m = self.save_m2m

# Add the custom save function to default save_m2m function.
def new_save_m2m():
default_save_m2m()
self._save_secondary_areas(user)

self.save_m2m = new_save_m2m

return user


@admin.register(User)
class UserAdmin(DefaultUserAdmin):
fieldsets = (
Expand Down Expand Up @@ -91,7 +175,7 @@ class UserAdmin(DefaultUserAdmin):
"texting_ok",
"time_zone",
"practice_area_primary",
"practice_area_secondary",
"practice_areas_secondary_virtual", # Replaces practice_area_secondary due to xref
"practice_area_target_intake",
"email_cognito",
"user_status",
Expand Down Expand Up @@ -135,11 +219,12 @@ class UserAdmin(DefaultUserAdmin):
},
),
)
form = UserChangeForm
add_form = UserCreationForm
list_display = ("username", "is_staff", "is_active")
list_filter = ("username", "email")

add_form = UserCreationForm
form = UserAdminForm


@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
Expand Down
47 changes: 46 additions & 1 deletion app/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from core.models import UserCheck
from core.models import UserEmploymentHistory
from core.models import UserPermission
from core.models import UserPracticeAreaSecondaryXref
from core.models import UserStatusType
from core.models import Win
from core.models import WinType
Expand All @@ -61,6 +62,15 @@ class Meta:
)


class UserPracticeAreaSecondaryXrefSerializer(serializers.ModelSerializer):
"""Used to retrieve secondary practice areas associated with a user."""

class Meta:
model = UserPracticeAreaSecondaryXref
fields = ("uuid", "created_at", "updated_at", "user", "practice_area")
read_only_fields = ("uuid", "created_at", "updated_at")


class UserPermissionSerializer(serializers.ModelSerializer):
"""Used to retrieve user permissions"""

Expand All @@ -87,6 +97,10 @@ class UserSerializer(serializers.ModelSerializer):

time_zone = TimeZoneSerializerField(use_pytz=False)

practice_areas_secondary = serializers.PrimaryKeyRelatedField(
many=True, queryset=PracticeArea.objects.all(), required=False
)

class Meta:
model = User
fields = (
Expand All @@ -111,7 +125,7 @@ class Meta:
"texting_ok",
"time_zone",
"practice_area_primary",
"practice_area_secondary",
"practice_areas_secondary",
"practice_area_target_intake",
"email_cognito",
"is_active",
Expand All @@ -125,6 +139,37 @@ class Meta:
"email",
)

def update(self, instance, validated_data):
"""
Overrides the default update to handle the virtual practice_areas_secondary field.
Intercepts the secondary areas and uses a bulk operation to manually update the
UserPracticeAreaSecondaryXref bridge table, ensuring the primary practice area
(whether existing or newly updated) is never duplicated as a secondary.
"""

if "practice_areas_secondary" in validated_data:
practice_areas_secondary = validated_data.pop("practice_areas_secondary")

# Determine what the primary area will be after this update.
practice_area_primary = validated_data.get(
"practice_area_primary", instance.practice_area_primary
)
UserPracticeAreaSecondaryXref.objects.filter(user=instance).delete()

# Build the new records in memory while filtering out the primary area.
new_xrefs = [
UserPracticeAreaSecondaryXref(
user=instance, practice_area=practice_area
)
for practice_area in practice_areas_secondary
if practice_area != practice_area_primary
]

if new_xrefs:
UserPracticeAreaSecondaryXref.objects.bulk_create(new_xrefs)

return super().update(instance, validated_data)


class ProjectSerializer(serializers.ModelSerializer):
"""Used to retrieve project info"""
Expand Down
17 changes: 17 additions & 0 deletions app/core/migrations/0057_remove_user_practice_area_secondary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.27 on 2026-06-15 04:16

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('core', '0056_faqviewed_read'),
]

operations = [
migrations.RemoveField(
model_name='user',
name='practice_area_secondary',
),
]
35 changes: 35 additions & 0 deletions app/core/migrations/0058_userpracticeareasecondaryxref_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 4.2.27 on 2026-06-16 07:12

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


class Migration(migrations.Migration):

dependencies = [
('core', '0057_remove_user_practice_area_secondary'),
]

operations = [
migrations.CreateModel(
name='UserPracticeAreaSecondaryXref',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('practice_area', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.practicearea')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='user',
name='practice_areas_secondary',
field=models.ManyToManyField(blank=True, related_name='users_secondary', through='core.UserPracticeAreaSecondaryXref', to='core.practicearea'),
),
migrations.AddConstraint(
model_name='userpracticeareasecondaryxref',
constraint=models.UniqueConstraint(fields=('user', 'practice_area'), name='unique_user_practice_areas_secondary'),
),
]
2 changes: 1 addition & 1 deletion app/core/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0056_faqviewed_read
0058_userpracticeareasecondaryxref_and_more
27 changes: 25 additions & 2 deletions app/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,11 @@ class User(PermissionsMixin, AbstractBaseUser, AbstractBaseModel):
null=True,
on_delete=models.PROTECT,
)
practice_area_secondary = models.ManyToManyField(
"PracticeArea", related_name="secondary_users", blank=True
practice_areas_secondary = models.ManyToManyField(
"PracticeArea",
related_name="users_secondary",
blank=True,
through="UserPracticeAreaSecondaryXref",
)
practice_area_target_intake = models.ManyToManyField(
"PracticeArea", related_name="target_intake_users", blank=True
Expand Down Expand Up @@ -911,3 +914,23 @@ class Meta:

def __str__(self) -> str:
return self.name


class UserPracticeAreaSecondaryXref(AbstractBaseModel):
"""
Cross-reference table linking a user to their secondary practice areas.
"""

user = models.ForeignKey("User", on_delete=models.CASCADE)
practice_area = models.ForeignKey("PracticeArea", on_delete=models.CASCADE)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "practice_area"],
name="unique_user_practice_areas_secondary",
)
]

def __str__(self):
return f"{self.user.username} - {self.practice_area.name}"
18 changes: 9 additions & 9 deletions app/core/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,15 +508,15 @@ def test_user_practice_area_relationship(user, user2):
assert user.practice_area_primary is None
assert not development_practice_area.primary_users.filter(uuid=user.uuid).exists()

user2.practice_area_secondary.add(project_management_practice_area)
assert user2.practice_area_secondary.count() == 1
assert user2.practice_area_secondary.contains(project_management_practice_area)
assert project_management_practice_area.secondary_users.contains(user2)

user2.practice_area_secondary.remove(project_management_practice_area)
assert user2.practice_area_secondary.count() == 0
assert not user2.practice_area_secondary.contains(project_management_practice_area)
assert not project_management_practice_area.secondary_users.contains(user2)
user2.practice_areas_secondary.add(project_management_practice_area)
assert user2.practice_areas_secondary.count() == 1
assert user2.practice_areas_secondary.contains(project_management_practice_area)
assert project_management_practice_area.users_secondary.contains(user2)

user2.practice_areas_secondary.remove(project_management_practice_area)
assert user2.practice_areas_secondary.count() == 0
assert not user2.practice_areas_secondary.contains(project_management_practice_area)
assert not project_management_practice_area.users_secondary.contains(user2)


def test_project_url(project_url):
Expand Down
2 changes: 1 addition & 1 deletion schema.dbml
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ Table User {
is_active bool [default: True, note:'Required by django. A boolean field that indicates whether the user\'s account is active.']
user_status_type int [ref: > UserStatusType.id]
practice_area_primary int [ref: > PracticeArea.id]
practice_area_secondary int[] [ref: <> PracticeArea.id]
practice_areas_secondary int[] [ref: <> PracticeArea.id]
job_title_current varchar
practice_area_target int[] [ref: <> PracticeArea.id]
job_title_target varchar
Expand Down
2 changes: 1 addition & 1 deletion scripts/validate_mkdocs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
set -eux

# build mkdocs with validation, and don't worry about the output
docker-compose exec -T mkdocs sh -c "mkdocs build -d /tmp --strict"
docker compose exec -T mkdocs sh -c "mkdocs build -d /tmp --strict"
Loading