diff --git a/app/core/api/serializers.py b/app/core/api/serializers.py index eccb3902..eabd2ae9 100644 --- a/app/core/api/serializers.py +++ b/app/core/api/serializers.py @@ -101,6 +101,8 @@ class Meta: class ProjectSerializer(serializers.ModelSerializer): """Used to retrieve project info""" + sdgs = serializers.StringRelatedField(many=True) + class Meta: model = Project fields = ( @@ -117,6 +119,7 @@ class Meta: "image_logo", "image_hero", "image_icon", + "sdgs", ) read_only_fields = ( "uuid", @@ -309,6 +312,8 @@ class SdgSerializer(serializers.ModelSerializer): Used to retrieve Sdg """ + projects = serializers.StringRelatedField(many=True) + class Meta: model = Sdg fields = ( @@ -316,6 +321,7 @@ class Meta: "name", "description", "image", + "projects", ) read_only_fields = ( "uuid", diff --git a/app/core/migrations/0029_projectsdgxref_project_sdgs.py b/app/core/migrations/0029_projectsdgxref_project_sdgs.py new file mode 100644 index 00000000..5ff888ff --- /dev/null +++ b/app/core/migrations/0029_projectsdgxref_project_sdgs.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.11 on 2024-10-29 03:19 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0028_alter_userpermission_project"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectSdgXref", + 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"), + ), + ( + "ended_on", + models.DateField(blank=True, null=True, verbose_name="Ended on"), + ), + ( + "project_id", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="core.project" + ), + ), + ( + "sdg_id", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="core.sdg" + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="project", + name="sdgs", + field=models.ManyToManyField( + blank=True, + related_name="projects", + through="core.ProjectSdgXref", + to="core.sdg", + ), + ), + ] diff --git a/app/core/migrations/max_migration.txt b/app/core/migrations/max_migration.txt index aa318cff..58dc9c5e 100644 --- a/app/core/migrations/max_migration.txt +++ b/app/core/migrations/max_migration.txt @@ -1 +1 @@ -0028_alter_userpermission_project +0029_projectsdgxref_project_sdgs diff --git a/app/core/models.py b/app/core/models.py index d987b539..16cb903f 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -144,6 +144,9 @@ class Project(AbstractBaseModel): image_logo = models.URLField(blank=True) image_hero = models.URLField(blank=True) image_icon = models.URLField(blank=True) + sdgs = models.ManyToManyField( + "Sdg", related_name="projects", blank=True, through="ProjectSdgXref" + ) def __str__(self): return f"{self.name}" @@ -426,3 +429,13 @@ class SocMajor(AbstractBaseModel): def __str__(self): return self.title + + +class ProjectSdgXref(AbstractBaseModel): + """ + Joins an SDG to a project + """ + + sdg_id = models.ForeignKey(Sdg, on_delete=models.CASCADE) + project_id = models.ForeignKey(Project, on_delete=models.CASCADE) + ended_on = models.DateField("Ended on", null=True, blank=True) diff --git a/app/core/tests/conftest.py b/app/core/tests/conftest.py index 88b71b80..370823aa 100644 --- a/app/core/tests/conftest.py +++ b/app/core/tests/conftest.py @@ -246,6 +246,11 @@ def sdg(): return Sdg.objects.create(name="Test SDG name") +@pytest.fixture +def sdg1(): + return Sdg.objects.create(name="Test SDG name1") + + @pytest.fixture def affiliation1(project, affiliate): return Affiliation.objects.create( diff --git a/app/core/tests/test_api.py b/app/core/tests/test_api.py index 661d546f..96370459 100644 --- a/app/core/tests/test_api.py +++ b/app/core/tests/test_api.py @@ -10,6 +10,7 @@ pytestmark = pytest.mark.django_db USER_PERMISSIONS_URL = reverse("user-permission-list") +PROJECTS_URL = reverse("project-list") ME_URL = reverse("my_profile") USERS_URL = reverse("user-list") EVENTS_URL = reverse("event-list") @@ -23,7 +24,7 @@ STACK_ELEMENT_URL = reverse("stack-element-list") PERMISSION_TYPE = reverse("permission-type-list") STACK_ELEMENT_TYPE_URL = reverse("stack-element-type-list") -SDG_URL = reverse("sdg-list") +SDGS_URL = reverse("sdg-list") AFFILIATION_URL = reverse("affiliation-list") CHECK_TYPE_URL = reverse("check-type-list") SOC_MAJOR_URL = reverse("soc-major-list") @@ -340,7 +341,7 @@ def test_create_sdg(auth_client): "description": "Test SDG description", "image": "https://unsplash.com", } - res = auth_client.post(SDG_URL, payload) + res = auth_client.post(SDGS_URL, payload) assert res.status_code == status.HTTP_201_CREATED assert res.data["name"] == payload["name"] @@ -381,3 +382,26 @@ def test_create_soc_major(auth_client): res = auth_client.post(SOC_MAJOR_URL, payload) assert res.status_code == status.HTTP_201_CREATED assert res.data["title"] == payload["title"] + + +def test_project_sdg_xref(auth_client, project, sdg, sdg1): + def get_object(objects, target_uuid): + for obj in objects: + if str(obj["uuid"]) == str(target_uuid): + return obj + return None + + project.sdgs.add(sdg) + project.sdgs.add(sdg1) + proj_res = auth_client.get(PROJECTS_URL) + test_proj = get_object(proj_res.data, project.uuid) + assert test_proj is not None + assert len(test_proj["sdgs"]) == 2 + assert sdg.name in test_proj["sdgs"] + assert sdg1.name in test_proj["sdgs"] + + sdg_res = auth_client.get(SDGS_URL) + test_sdg = get_object(sdg_res.data, sdg.uuid) + assert test_sdg is not None + assert len(test_sdg["projects"]) == 1 + assert project.name in test_sdg["projects"] diff --git a/app/core/tests/test_models.py b/app/core/tests/test_models.py index 9617496e..5618806d 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -3,6 +3,8 @@ import pytest from ..models import Event +from ..models import ProjectSdgXref +from ..models import Sdg pytestmark = pytest.mark.django_db @@ -145,3 +147,23 @@ def test_check_type(check_type): def test_soc_major(soc_major): assert str(soc_major) == "Test Soc Major" + + +def test_project_sdg_relationship(project): + climate_action_sdg = Sdg.objects.get(name="Climate Action") + + project.sdgs.add(climate_action_sdg) + assert project.sdgs.count() == 1 + assert project.sdgs.contains(climate_action_sdg) + assert climate_action_sdg.projects.contains(project) + + climate_action_sdg_xref = ProjectSdgXref.objects.get( + project_id=project, + sdg_id=climate_action_sdg, + ) + assert climate_action_sdg_xref.ended_on is None + + project.sdgs.remove(climate_action_sdg) + assert project.sdgs.count() == 0 + assert not project.sdgs.contains(climate_action_sdg) + assert not climate_action_sdg.projects.contains(project)