diff --git a/README.md b/README.md index 4ab7c56..9b7ef45 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # People -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 Framework](https://www.django-rest-framework.org/). diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 5318b29..075d8c2 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -108,4 +108,5 @@ class TeamAdmin(admin.ModelAdmin): "created_at", "updated_at", ) + prepopulated_fields = {"slug": ("name",)} search_fields = ("name",) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 6f6eeac..13ba2a0 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -131,11 +131,12 @@ class TeamSerializer(serializers.ModelSerializer): abilities = serializers.SerializerMethodField(read_only=True) accesses = TeamAccessSerializer(many=True, read_only=True) + slug = serializers.SerializerMethodField() class Meta: model = models.Team - fields = ["id", "name", "accesses", "abilities"] - read_only_fields = ["id", "accesses", "abilities"] + fields = ["id", "name", "accesses", "abilities", "slug"] + read_only_fields = ["id", "accesses", "abilities", "slug"] def get_abilities(self, team) -> dict: """Return abilities of the logged-in user on the instance.""" @@ -143,3 +144,7 @@ class TeamSerializer(serializers.ModelSerializer): if request: return team.get_abilities(request.user) return {} + + def get_slug(self, instance): + """Return slug from the team's name.""" + return instance.get_slug() diff --git a/src/backend/core/migrations/0001_initial.py b/src/backend/core/migrations/0001_initial.py index b098077..936f727 100644 --- a/src/backend/core/migrations/0001_initial.py +++ b/src/backend/core/migrations/0001_initial.py @@ -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 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')), ('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)), + ('slug', models.SlugField(max_length=100, unique=True)), ], options={ 'verbose_name': 'Team', diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 12c9f36..d2889ec 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -11,6 +11,7 @@ from django.contrib.auth.base_user import AbstractBaseUser from django.core import exceptions, mail, validators from django.db import models from django.utils.functional import lazy +from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ import jsonschema @@ -315,6 +316,7 @@ class Team(BaseModel): """ name = models.CharField(max_length=100) + slug = models.SlugField(max_length=100, unique=True, null=False) users = models.ManyToManyField( User, @@ -332,6 +334,15 @@ class Team(BaseModel): def __str__(self): 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): """ Compute and return abilities for a given user on the team. diff --git a/src/backend/core/tests/teams/test_core_api_teams_create.py b/src/backend/core/tests/teams/test_core_api_teams_create.py index 9bc196c..21289fa 100644 --- a/src/backend/core/tests/teams/test_core_api_teams_create.py +++ b/src/backend/core/tests/teams/test_core_api_teams_create.py @@ -3,12 +3,10 @@ Tests for Teams API endpoint in People's core app: create """ import pytest 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 ..utils import OIDCToken +from core.tests.utils import OIDCToken pytestmark = pytest.mark.django_db @@ -48,3 +46,73 @@ def test_api_teams_create_authenticated(): team = Team.objects.get() assert team.name == "my team" 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."] diff --git a/src/backend/core/tests/teams/test_core_api_teams_retrieve.py b/src/backend/core/tests/teams/test_core_api_teams_retrieve.py index 1351880..df3ae7a 100644 --- a/src/backend/core/tests/teams/test_core_api_teams_retrieve.py +++ b/src/backend/core/tests/teams/test_core_api_teams_retrieve.py @@ -1,16 +1,11 @@ """ Tests for Teams API endpoint in People's core app: retrieve """ -import random -from collections import Counter -from unittest import mock - import pytest from rest_framework.test import APIClient from core import factories - -from ..utils import OIDCToken +from core.tests.utils import OIDCToken pytestmark = pytest.mark.django_db @@ -82,5 +77,6 @@ def test_api_teams_retrieve_authenticated_related(): assert response.json() == { "id": str(team.id), "name": team.name, + "slug": team.slug, "abilities": team.get_abilities(user), } diff --git a/src/backend/core/tests/teams/test_core_api_teams_update.py b/src/backend/core/tests/teams/test_core_api_teams_update.py index 58a36a9..7500b64 100644 --- a/src/backend/core/tests/teams/test_core_api_teams_update.py +++ b/src/backend/core/tests/teams/test_core_api_teams_update.py @@ -5,12 +5,10 @@ import random import pytest 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 ..utils import OIDCToken +from core.tests.utils import OIDCToken pytestmark = pytest.mark.django_db @@ -100,28 +98,31 @@ def test_api_teams_update_authenticated_administrators(): jwt_token = OIDCToken.for_user(user) 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( f"/api/v1.0/teams/{team.id!s}/", - new_team_values, + new_values, format="json", HTTP_AUTHORIZATION=f"Bearer {jwt_token}", ) assert response.status_code == 200 team.refresh_from_db() - team_values = serializers.TeamSerializer(instance=team).data - for key, value in team_values.items(): - if key in ["id", "accesses"]: - assert value == old_team_values[key] + final_values = serializers.TeamSerializer(instance=team).data + for key, value in final_values.items(): + if key in ["id", "accesses"]: # pylint: disable=R1733 + assert value == initial_values[key] 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(): - """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() user = identity.user jwt_token = OIDCToken.for_user(user) @@ -129,7 +130,9 @@ def test_api_teams_update_authenticated_owners(): team = factories.TeamFactory(users=[(user, "owner")]) 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( f"/api/v1.0/teams/{team.id!s}/", new_team_values, @@ -144,6 +147,7 @@ def test_api_teams_update_authenticated_owners(): if key in ["id", "accesses"]: assert value == old_team_values[key] else: + # name, slug and abilities successfully modified 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_values = serializers.TeamSerializer(instance=team).data 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" diff --git a/src/backend/core/tests/test_models_teams.py b/src/backend/core/tests/test_models_teams.py index 7006e5f..e22ef8d 100644 --- a/src/backend/core/tests/test_models_teams.py +++ b/src/backend/core/tests/test_models_teams.py @@ -46,6 +46,12 @@ def test_models_teams_name_max_length(): 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 diff --git a/src/backend/demo/management/commands/create_demo.py b/src/backend/demo/management/commands/create_demo.py index a6d5cab..17b6828 100755 --- a/src/backend/demo/management/commands/create_demo.py +++ b/src/backend/demo/management/commands/create_demo.py @@ -38,7 +38,7 @@ class BulkQueue: if not objects: 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 db.reset_queries() self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001 @@ -143,6 +143,8 @@ def create_demo(stdout): queue.push( models.Team( name=f"Team {i:d}", + # slug should be automatic but bulk_create doesn't use save + slug=f"team-{i:d}", ) ) queue.flush()