(models) add slug field to Team model

Add slug field for team objects. Unique slug based on team names,
in an effort to avoid duplicates.
This commit is contained in:
Marie PUPO JEAMMET
2024-02-01 18:15:25 +01:00
committed by Marie
parent c117f67952
commit d2bf44d2fd
10 changed files with 153 additions and 29 deletions

View File

@@ -2,6 +2,8 @@
People is an application to handle users and teams. People is an application to handle users and teams.
This project is as of yet **not ready for production**. Expect breaking changes.
People is built on top of [Django Rest People is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/). Framework](https://www.django-rest-framework.org/).

View File

@@ -108,4 +108,5 @@ class TeamAdmin(admin.ModelAdmin):
"created_at", "created_at",
"updated_at", "updated_at",
) )
prepopulated_fields = {"slug": ("name",)}
search_fields = ("name",) search_fields = ("name",)

View File

@@ -131,11 +131,12 @@ class TeamSerializer(serializers.ModelSerializer):
abilities = serializers.SerializerMethodField(read_only=True) abilities = serializers.SerializerMethodField(read_only=True)
accesses = TeamAccessSerializer(many=True, read_only=True) accesses = TeamAccessSerializer(many=True, read_only=True)
slug = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.Team model = models.Team
fields = ["id", "name", "accesses", "abilities"] fields = ["id", "name", "accesses", "abilities", "slug"]
read_only_fields = ["id", "accesses", "abilities"] read_only_fields = ["id", "accesses", "abilities", "slug"]
def get_abilities(self, team) -> dict: def get_abilities(self, team) -> dict:
"""Return abilities of the logged-in user on the instance.""" """Return abilities of the logged-in user on the instance."""
@@ -143,3 +144,7 @@ class TeamSerializer(serializers.ModelSerializer):
if request: if request:
return team.get_abilities(request.user) return team.get_abilities(request.user)
return {} return {}
def get_slug(self, instance):
"""Return slug from the team's name."""
return instance.get_slug()

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0 on 2024-01-14 13:41 # Generated by Django 5.0.1 on 2024-02-06 15:08
import core.models import core.models
import django.core.validators import django.core.validators
@@ -27,6 +27,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')), ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')), ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100, unique=True)),
], ],
options={ options={
'verbose_name': 'Team', 'verbose_name': 'Team',

View File

@@ -11,6 +11,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.core import exceptions, mail, validators from django.core import exceptions, mail, validators
from django.db import models from django.db import models
from django.utils.functional import lazy from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import jsonschema import jsonschema
@@ -315,6 +316,7 @@ class Team(BaseModel):
""" """
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100, unique=True, null=False)
users = models.ManyToManyField( users = models.ManyToManyField(
User, User,
@@ -332,6 +334,15 @@ class Team(BaseModel):
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs):
"""Overriding save function to compute the slug."""
self.slug = self.get_slug()
return super().save(*args, **kwargs)
def get_slug(self):
"""Compute slug value from name."""
return slugify(self.name)
def get_abilities(self, user): def get_abilities(self, user):
""" """
Compute and return abilities for a given user on the team. Compute and return abilities for a given user on the team.

View File

@@ -3,12 +3,10 @@ Tests for Teams API endpoint in People's core app: create
""" """
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import AccessToken
from core.factories import IdentityFactory, TeamFactory, UserFactory from core.factories import IdentityFactory, TeamFactory
from core.models import Team from core.models import Team
from core.tests.utils import OIDCToken
from ..utils import OIDCToken
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -48,3 +46,73 @@ def test_api_teams_create_authenticated():
team = Team.objects.get() team = Team.objects.get()
assert team.name == "my team" assert team.name == "my team"
assert team.accesses.filter(role="owner", user=user).exists() assert team.accesses.filter(role="owner", user=user).exists()
def test_api_teams_create_authenticated_slugify_name():
"""
Creating teams should automatically generate a slug.
"""
identity = IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
response = APIClient().post(
"/api/v1.0/teams/",
{"name": "my team"},
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
team = Team.objects.get()
assert team.name == "my team"
assert team.slug == "my-team"
@pytest.mark.parametrize(
"param",
[
("my team", "my-team"),
("my team", "my-team"),
("MY TEAM TOO", "my-team-too"),
("mon équipe", "mon-equipe"),
("front devs & UX", "front-devs-ux"),
],
)
def test_api_teams_create_authenticated_expected_slug(param):
"""
Creating teams should automatically create unaccented, no unicode, lower-case slug.
"""
identity = IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
response = APIClient().post(
"/api/v1.0/teams/",
{
"name": param[0],
},
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
team = Team.objects.get()
assert team.name == param[0]
assert team.slug == param[1]
def test_api_teams_create_authenticated_unique_slugs():
"""
Creating teams should raise an error if already existing slug.
"""
TeamFactory(name="existing team")
identity = IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
response = APIClient().post(
"/api/v1.0/teams/",
{
"name": "èxisting team",
},
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 400
assert response.json()["slug"] == ["Team with this Slug already exists."]

View File

@@ -1,16 +1,11 @@
""" """
Tests for Teams API endpoint in People's core app: retrieve Tests for Teams API endpoint in People's core app: retrieve
""" """
import random
from collections import Counter
from unittest import mock
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories from core import factories
from core.tests.utils import OIDCToken
from ..utils import OIDCToken
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -82,5 +77,6 @@ def test_api_teams_retrieve_authenticated_related():
assert response.json() == { assert response.json() == {
"id": str(team.id), "id": str(team.id),
"name": team.name, "name": team.name,
"slug": team.slug,
"abilities": team.get_abilities(user), "abilities": team.get_abilities(user),
} }

View File

@@ -5,12 +5,10 @@ import random
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import AccessToken
from core import factories, models from core import factories
from core.api import serializers from core.api import serializers
from core.tests.utils import OIDCToken
from ..utils import OIDCToken
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -100,28 +98,31 @@ def test_api_teams_update_authenticated_administrators():
jwt_token = OIDCToken.for_user(user) jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")]) team = factories.TeamFactory(users=[(user, "administrator")])
old_team_values = serializers.TeamSerializer(instance=team).data initial_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data # generate new random values
new_values = serializers.TeamSerializer(instance=factories.TeamFactory.build()).data
response = APIClient().put( response = APIClient().put(
f"/api/v1.0/teams/{team.id!s}/", f"/api/v1.0/teams/{team.id!s}/",
new_team_values, new_values,
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}", HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
) )
assert response.status_code == 200 assert response.status_code == 200
team.refresh_from_db() team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data final_values = serializers.TeamSerializer(instance=team).data
for key, value in team_values.items(): for key, value in final_values.items():
if key in ["id", "accesses"]: if key in ["id", "accesses"]: # pylint: disable=R1733
assert value == old_team_values[key] assert value == initial_values[key]
else: else:
assert value == new_team_values[key] # name, slug and abilities successfully modified
assert value == new_values[key]
def test_api_teams_update_authenticated_owners(): def test_api_teams_update_authenticated_owners():
"""Administrators of a team should be allowed to update it.""" """Administrators of a team should be allowed to update it,
apart from read-only fields."""
identity = factories.IdentityFactory() identity = factories.IdentityFactory()
user = identity.user user = identity.user
jwt_token = OIDCToken.for_user(user) jwt_token = OIDCToken.for_user(user)
@@ -129,7 +130,9 @@ def test_api_teams_update_authenticated_owners():
team = factories.TeamFactory(users=[(user, "owner")]) team = factories.TeamFactory(users=[(user, "owner")])
old_team_values = serializers.TeamSerializer(instance=team).data old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data new_team_values = serializers.TeamSerializer(
instance=factories.TeamFactory.build()
).data
response = APIClient().put( response = APIClient().put(
f"/api/v1.0/teams/{team.id!s}/", f"/api/v1.0/teams/{team.id!s}/",
new_team_values, new_team_values,
@@ -144,6 +147,7 @@ def test_api_teams_update_authenticated_owners():
if key in ["id", "accesses"]: if key in ["id", "accesses"]:
assert value == old_team_values[key] assert value == old_team_values[key]
else: else:
# name, slug and abilities successfully modified
assert value == new_team_values[key] assert value == new_team_values[key]
@@ -174,3 +178,31 @@ def test_api_teams_update_administrator_or_owner_of_another():
team.refresh_from_db() team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values assert team_values == old_team_values
def test_api_teams_update_existing_slug_should_return_error():
"""
Updating a team's name to an existing slug should return a bad request,
instead of creating a duplicate.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
factories.TeamFactory(name="Existing team", users=[(user, "administrator")])
my_team = factories.TeamFactory(name="New team", users=[(user, "administrator")])
updated_values = serializers.TeamSerializer(instance=my_team).data
# Update my team's name for existing team. Creates a duplicate slug
updated_values["name"] = "existing team"
response = APIClient().put(
f"/api/v1.0/teams/{my_team.id!s}/",
updated_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 400
assert response.json()["slug"] == ["Team with this Slug already exists."]
# Both teams names and slugs should be unchanged
assert my_team.name == "New team"
assert my_team.slug == "new-team"

View File

@@ -46,6 +46,12 @@ def test_models_teams_name_max_length():
factories.TeamFactory(name="a " * 51) factories.TeamFactory(name="a " * 51)
def test_models_teams_slug_empty():
"""Slug field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Team.objects.create(slug="")
# get_abilities # get_abilities

View File

@@ -38,7 +38,7 @@ class BulkQueue:
if not objects: if not objects:
return return
objects[0]._meta.model.objects.bulk_create(objects, ignore_conflicts=True) # noqa: SLF001 objects[0]._meta.model.objects.bulk_create(objects, ignore_conflicts=False) # noqa: SLF001
# In debug mode, Django keeps query cache which creates a memory leak in this case # In debug mode, Django keeps query cache which creates a memory leak in this case
db.reset_queries() db.reset_queries()
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001 self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
@@ -143,6 +143,8 @@ def create_demo(stdout):
queue.push( queue.push(
models.Team( models.Team(
name=f"Team {i:d}", name=f"Team {i:d}",
# slug should be automatic but bulk_create doesn't use save
slug=f"team-{i:d}",
) )
) )
queue.flush() queue.flush()