From c68fd49122fd13185572ffa72d0a3b651ece4416 Mon Sep 17 00:00:00 2001 From: dmartin4820 Date: Mon, 16 Sep 2024 19:55:19 -0700 Subject: [PATCH 1/4] feat: add many-to-many relationship to project: Sdg --- .../0028_projectsdgxref_project_sdgs.py | 67 +++++++++++++++++++ app/core/migrations/max_migration.txt | 2 +- app/core/models.py | 13 ++++ app/core/tests/test_models.py | 22 ++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 app/core/migrations/0028_projectsdgxref_project_sdgs.py diff --git a/app/core/migrations/0028_projectsdgxref_project_sdgs.py b/app/core/migrations/0028_projectsdgxref_project_sdgs.py new file mode 100644 index 00000000..b0d5fd4c --- /dev/null +++ b/app/core/migrations/0028_projectsdgxref_project_sdgs.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.11 on 2024-09-28 05:30 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0027_socmajor"), + ] + + 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 49e70d1d..59968bc6 100644 --- a/app/core/migrations/max_migration.txt +++ b/app/core/migrations/max_migration.txt @@ -1 +1 @@ -0027_socmajor +0028_projectsdgxref_project_sdgs diff --git a/app/core/models.py b/app/core/models.py index b49ecee7..120b1347 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}" @@ -424,3 +427,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/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) From d215331c523d91f064f93f60de83f666baa97e2d Mon Sep 17 00:00:00 2001 From: dmartin4820 Date: Tue, 17 Sep 2024 22:24:35 -0700 Subject: [PATCH 2/4] feat: update serializers: project and sdg --- app/core/api/serializers.py | 6 ++++++ 1 file changed, 6 insertions(+) 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", From fbfcabe2b9d5acbdc1a041316f0ab6fc5fe46b96 Mon Sep 17 00:00:00 2001 From: dmartin4820 Date: Tue, 17 Sep 2024 22:30:47 -0700 Subject: [PATCH 3/4] docs(add-model): create tips for many-to-many relationships --- docs/how-to/add-model-and-api-endpoints.md | 146 +++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/docs/how-to/add-model-and-api-endpoints.md b/docs/how-to/add-model-and-api-endpoints.md index ef67ac5c..1365208f 100644 --- a/docs/how-to/add-model-and-api-endpoints.md +++ b/docs/how-to/add-model-and-api-endpoints.md @@ -51,6 +51,23 @@ Let's start! assert str(recurring_event) == payload["name"] ``` + For testing many-to-many relationships, we can add + + ```python title="app/core/tests/test_models.py" linenums="1" + def test_project_recurring_event_relationship(project): + recurring_event = RecurringEvent.objects.get(name="{Name of Recurring Event}") + + project.recurring_events.add(recurring_event) + assert project.recurring_events.count() == 1 + assert project.recurring_events.contains(recurring_event) + assert recurring_event.projects.contains(project) + + project.sdgs.remove(recurring_event) + assert project.recurring_events.count() == 0 + assert not project.recurring_events.contains(recurring_event) + assert not recurring_event.projects.contains(project) + ``` + 1. See it fail ```bash @@ -99,6 +116,45 @@ class RecurringEvent(AbstractBaseModel): # (1)! 1. Try to add the relationships to non-existent models, but comment them out. Another developer will complete them when they go to implement those models. 1. Always override the `__str__` function to output something more meaningful than the default. It lets us do a quick test of the model by calling `str([model])`. It's also useful for the admin site model list view. +??? note "Updating models.py for many-to-many relationships" + For adding many-to-many relationships with additional fields, such as `ended_on`, we can add + + ```python title="app/core/tests/test_models.py" linenums="1" + class Project(AbstractBaseModel): + ... + recurring_events = models.ManyToManyField( + "RecurringEvent", + related_name="projects", + blank=True, + through="ProjectRecurringEventXref", + ) + ... + + + class ProjectRecurringEventXref(AbstractBaseModel): + """ + Joins a recurring event to a project + """ + + recurring_event_id = models.ForeignKey(RecurringEvent, on_delete=models.CASCADE) + project_id = models.ForeignKey(Project, on_delete=models.CASCADE) + ended_on = models.DateField("Ended on", null=True, blank=True) + ``` + + For adding many-to-many relationships without additional fields, we can just add + + ```python title="app/core/tests/test_models.py" linenums="1" + class Project(AbstractBaseModel): + ... + recurring_events = models.ManyToManyField( + "RecurringEvent", + related_name="projects", + blank=True, + through="ProjectRecurringEventXref", + ) + ... + ``` + ### Run migrations This generates the database migration files @@ -247,6 +303,67 @@ This is code that serializes objects into strings for the API endpoints, and des In `app/core/api/serializers.py` +??? note "Updating serializers.py for many-to-many relationships" + Following the many-to-many relationship between project and recurring event from above, + + Update the existing serializer classes + + ```python title="app/core/api/serializers.py" linenums="1" + class ProjectSerializer(serializers.ModelSerializer): + """Used to retrieve project info""" + + recurring_events = serializers.StringRelatedField(many=True) + + class Meta: + model = Project + fields = ( + "uuid", + "name", + "description", + "created_at", + "updated_at", + "completed_at", + "github_org_id", + "github_primary_repo_id", + "hide", + "google_drive_id", + "image_logo", + "image_hero", + "image_icon", + "recurring_events", + ) + read_only_fields = ( + "uuid", + "created_at", + "updated_at", + "completed_at", + ) + + + class RecurringEventSerializer(serializers.ModelSerializer): + """Used to retrieve recurring_event info""" + + projects = serializers.StringRelatedField(many=True) + + class Meta: + model = RecurringEvent + fields = ( + "uuid", + "name", + "start_time", + "duration_in_min", + "video_conference_url", + "additional_info", + "project", + "projects", + ) + read_only_fields = ( + "uuid", + "created_at", + "updated_at", + ) + ``` + 1. Import the new model ```python title="app/core/api/serializers.py" linenums="1" @@ -491,6 +608,35 @@ In `app/core/api/urls.py` ./scripts/test.sh ``` +??? note "Test many-to-many relationships" + In `app/core/tests/test_api.py` + + 1. Import API URL + + ```python title="app/core/tests/test_api.py" linenums="1" + PROJECT_URL = reverse("project-list") + ``` + + 1. Add test case + + ```python title="app/core/tests/test_api.py" linenums="1" + def test_project_sdg_xref(auth_client, project, sdg): + project.sdgs.add(sdg) + project.save() + + proj_res = auth_client.get(PROJECT_URL) + sdg_res = auth_client.get(SDG_URL) + + assert filter(lambda proj: str(proj["uuid"]) == str(project.pk), proj_res.data) + assert filter(lambda _sdg: str(_sdg["uuid"]) == str(sdg.pk), sdg_res) + ``` + + 1. Run the test script to show it passing + + ```bash + ./scripts/test.sh + ``` + ??? note "Check and commit" This is a good place to pause, check, and commit progress. From a3ff26709a54ea3b7d132486bc7268ae12b6fd48 Mon Sep 17 00:00:00 2001 From: dmartin4820 Date: Wed, 18 Sep 2024 22:35:10 -0700 Subject: [PATCH 4/4] feat: add project to sdg relationship test --- app/core/tests/test_api.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/core/tests/test_api.py b/app/core/tests/test_api.py index 661d546f..7d4023c3 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") +PROJECT_URL = reverse("project-list") ME_URL = reverse("my_profile") USERS_URL = reverse("user-list") EVENTS_URL = reverse("event-list") @@ -381,3 +382,14 @@ 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): + project.sdgs.add(sdg) + project.save() + + proj_res = auth_client.get(PROJECT_URL) + sdg_res = auth_client.get(SDG_URL) + + assert filter(lambda proj: str(proj["uuid"]) == str(project.pk), proj_res.data) + assert filter(lambda _sdg: str(_sdg["uuid"]) == str(sdg.pk), sdg_res)