From c7d4641fff28ca4a97d5f71964a74cc8326de0b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 06:56:14 +0000 Subject: [PATCH 1/5] Initial plan From fd3b88c09070d4f009546a65498fca3f155e5f24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:00:27 +0000 Subject: [PATCH 2/5] Add ProjectGroup model and search integration Co-authored-by: ericholscher <25510+ericholscher@users.noreply.github.com> --- readthedocs/projects/admin.py | 16 ++++ .../migrations/0159_create_projectgroup.py | 75 +++++++++++++++++++ readthedocs/projects/models.py | 40 ++++++++++ readthedocs/projects/signals.py | 63 ++++++++++++++++ readthedocs/search/api/v3/executor.py | 25 +++++++ readthedocs/search/api/v3/queryparser.py | 1 + 6 files changed, 220 insertions(+) create mode 100644 readthedocs/projects/migrations/0159_create_projectgroup.py diff --git a/readthedocs/projects/admin.py b/readthedocs/projects/admin.py index ece9bc3deb1..d524c07cfaa 100644 --- a/readthedocs/projects/admin.py +++ b/readthedocs/projects/admin.py @@ -25,6 +25,7 @@ from .models import HTTPHeader from .models import ImportedFile from .models import Project +from .models import ProjectGroup from .models import ProjectRelationship from .models import WebHook from .models import WebHookEvent @@ -464,6 +465,21 @@ class AddonsConfigAdmin(admin.ModelAdmin): list_editable = ("enabled",) +@admin.register(ProjectGroup) +class ProjectGroupAdmin(admin.ModelAdmin): + model = ProjectGroup + list_display = ("name", "slug", "project_count", "created", "modified") + search_fields = ("name", "slug") + filter_horizontal = ("projects",) + prepopulated_fields = {"slug": ("name",)} + readonly_fields = ("created", "modified") + + def project_count(self, project_group): + """Return the number of projects in this group.""" + return project_group.projects.count() + project_count.short_description = "Project Count" + + admin.site.register(EmailHook) admin.site.register(WebHook) admin.site.register(WebHookEvent) diff --git a/readthedocs/projects/migrations/0159_create_projectgroup.py b/readthedocs/projects/migrations/0159_create_projectgroup.py new file mode 100644 index 00000000000..77d70286260 --- /dev/null +++ b/readthedocs/projects/migrations/0159_create_projectgroup.py @@ -0,0 +1,75 @@ +# Generated by Django 5.2.10 on 2026-02-04 07:00 + +from django.db import migrations +from django.db import models +import django_extensions.db.fields +from django_safemigrate import Safe + + +class Migration(migrations.Migration): + safe = Safe.before_deploy() + + dependencies = [ + ("projects", "0158_add_search_subproject_filter_option"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectGroup", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "name", + models.CharField( + help_text="Name of the project group", + max_length=255, + unique=True, + verbose_name="Name", + ), + ), + ( + "slug", + models.SlugField( + help_text="Slug for the project group", + max_length=255, + unique=True, + verbose_name="Slug", + ), + ), + ( + "projects", + models.ManyToManyField( + blank=True, + help_text="Projects in this group", + related_name="project_groups", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "Project Group", + "verbose_name_plural": "Project Groups", + "ordering": ["name"], + }, + ), + ] diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 97dd5de94d7..ceadb47e9f7 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -80,6 +80,46 @@ def default_privacy_level(): return settings.DEFAULT_PRIVACY_LEVEL +class ProjectGroup(TimeStampedModel): + """ + Project Group model. + + Groups projects together for searching across them with a single query parameter. + By default, subprojects are grouped together, and translations are grouped together. + """ + + name = models.CharField( + _("Name"), + max_length=255, + unique=True, + help_text=_("Name of the project group"), + ) + slug = models.SlugField( + _("Slug"), + max_length=255, + unique=True, + help_text=_("Slug for the project group"), + ) + projects = models.ManyToManyField( + "projects.Project", + related_name="project_groups", + blank=True, + help_text=_("Projects in this group"), + ) + + class Meta: + verbose_name = _("Project Group") + verbose_name_plural = _("Project Groups") + ordering = ["name"] + + def __str__(self): + return self.name + + def get_project_slugs(self): + """Return a list of project slugs in this group.""" + return list(self.projects.values_list("slug", flat=True)) + + class ProjectRelationship(models.Model): """ Project to project relationship. diff --git a/readthedocs/projects/signals.py b/readthedocs/projects/signals.py index cdb8dda1cf5..8f100cb14fa 100644 --- a/readthedocs/projects/signals.py +++ b/readthedocs/projects/signals.py @@ -5,12 +5,15 @@ import django.dispatch import structlog from django.db.models.signals import post_save +from django.db.models.signals import m2m_changed from django.dispatch import receiver from readthedocs.integrations.models import GitHubAppIntegrationProviderData from readthedocs.integrations.models import Integration from readthedocs.projects.models import AddonsConfig from readthedocs.projects.models import Project +from readthedocs.projects.models import ProjectGroup +from readthedocs.projects.models import ProjectRelationship log = structlog.get_logger(__name__) @@ -55,3 +58,63 @@ def create_integration_on_github_app_project(instance, *args, **kwargs): ) ) integration.save() + + +@receiver(post_save, sender=ProjectRelationship) +def add_subprojects_to_group(sender, instance, created, **kwargs): + """ + Automatically add subprojects to the parent project's subproject group. + + When a subproject relationship is created, add the child to a project group + named after the parent project with "_subprojects" suffix. + """ + if created: + parent = instance.parent + child = instance.child + group_name = f"{parent.name} - Subprojects" + group_slug = f"{parent.slug}-subprojects" + + # Get or create the project group for subprojects + group, _ = ProjectGroup.objects.get_or_create( + slug=group_slug, + defaults={"name": group_name}, + ) + + # Add both parent and child to the group + group.projects.add(parent, child) + log.info( + "Added subproject to project group", + parent_slug=parent.slug, + child_slug=child.slug, + group_slug=group_slug, + ) + + +@receiver(post_save, sender=Project) +def add_translations_to_group(sender, instance, created, **kwargs): + """ + Automatically add translations to the main language project's translation group. + + When a project has a main_language_project, add it to a project group + named after the main language project with "_translations" suffix. + """ + project = instance + if project.main_language_project: + main_project = project.main_language_project + group_name = f"{main_project.name} - Translations" + group_slug = f"{main_project.slug}-translations" + + # Get or create the project group for translations + group, _ = ProjectGroup.objects.get_or_create( + slug=group_slug, + defaults={"name": group_name}, + ) + + # Add both main project and translation to the group + group.projects.add(main_project, project) + log.info( + "Added translation to project group", + main_project_slug=main_project.slug, + translation_slug=project.slug, + group_slug=group_slug, + ) diff --git a/readthedocs/search/api/v3/executor.py b/readthedocs/search/api/v3/executor.py index e061533c48f..f767cdffa67 100644 --- a/readthedocs/search/api/v3/executor.py +++ b/readthedocs/search/api/v3/executor.py @@ -3,6 +3,7 @@ from readthedocs.builds.constants import INTERNAL from readthedocs.projects.models import Project +from readthedocs.projects.models import ProjectGroup from readthedocs.search.api.v3.queryparser import SearchQueryParser from readthedocs.search.faceted_search import PageSearch @@ -110,6 +111,10 @@ def _get_projects_to_search(self): if self.parser.arguments["user"] == "@me": yield from self._get_projects_from_user() + # Add all projects from project groups. + for group_slug in self.parser.arguments["project_group"]: + yield from self._get_projects_from_group(group_slug) + def _get_projects_from_user(self): for project in Project.objects.for_user(user=self.request.user): version = self._get_project_version( @@ -120,6 +125,26 @@ def _get_projects_from_user(self): if version and self._has_permission(self.request, version): yield project, version + def _get_projects_from_group(self, group_slug): + """ + Get a tuple (project, version) of all projects in a group. + + :param group_slug: The slug of the project group. + """ + try: + group = ProjectGroup.objects.prefetch_related("projects").get(slug=group_slug) + except ProjectGroup.DoesNotExist: + return + + for project in group.projects.all(): + version = self._get_project_version( + project=project, + version_slug=project.default_version, + include_hidden=False, + ) + if version and self._has_permission(self.request, version): + yield project, version + def _get_subprojects(self, project, version_slug=None): """ Get a tuple (project, version) of all subprojects of `project`. diff --git a/readthedocs/search/api/v3/queryparser.py b/readthedocs/search/api/v3/queryparser.py index 7cb40213a40..d4d7d903088 100644 --- a/readthedocs/search/api/v3/queryparser.py +++ b/readthedocs/search/api/v3/queryparser.py @@ -17,6 +17,7 @@ class SearchQueryParser: "project": list, "subprojects": list, "user": str, + "project_group": list, } def __init__(self, query): From 9c718e30939e819e8071bf3bf52c100d501b3730 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:01:20 +0000 Subject: [PATCH 3/5] Add tests for ProjectGroup functionality Co-authored-by: ericholscher <25510+ericholscher@users.noreply.github.com> --- .../rtd_tests/tests/test_project_groups.py | 145 +++++++++++++ .../search/tests/test_project_groups.py | 194 ++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 readthedocs/rtd_tests/tests/test_project_groups.py create mode 100644 readthedocs/search/tests/test_project_groups.py diff --git a/readthedocs/rtd_tests/tests/test_project_groups.py b/readthedocs/rtd_tests/tests/test_project_groups.py new file mode 100644 index 00000000000..7f9987809d1 --- /dev/null +++ b/readthedocs/rtd_tests/tests/test_project_groups.py @@ -0,0 +1,145 @@ +"""Tests for ProjectGroup model and search integration.""" + +import pytest +from django.test import TestCase +from django_dynamic_fixture import get + +from readthedocs.builds.models import Version +from readthedocs.projects.models import Project +from readthedocs.projects.models import ProjectGroup +from readthedocs.projects.models import ProjectRelationship + + +@pytest.mark.django_db +class TestProjectGroup(TestCase): + """Test ProjectGroup model.""" + + def test_create_project_group(self): + """Test creating a project group.""" + group = ProjectGroup.objects.create( + name="Test Group", + slug="test-group", + ) + assert group.name == "Test Group" + assert group.slug == "test-group" + assert group.projects.count() == 0 + + def test_add_projects_to_group(self): + """Test adding projects to a group.""" + group = ProjectGroup.objects.create( + name="Test Group", + slug="test-group", + ) + project1 = get(Project, slug="project1", name="Project 1") + project2 = get(Project, slug="project2", name="Project 2") + + group.projects.add(project1, project2) + + assert group.projects.count() == 2 + assert project1 in group.projects.all() + assert project2 in group.projects.all() + + def test_get_project_slugs(self): + """Test getting project slugs from a group.""" + group = ProjectGroup.objects.create( + name="Test Group", + slug="test-group", + ) + project1 = get(Project, slug="project1", name="Project 1") + project2 = get(Project, slug="project2", name="Project 2") + + group.projects.add(project1, project2) + + slugs = group.get_project_slugs() + assert "project1" in slugs + assert "project2" in slugs + assert len(slugs) == 2 + + +@pytest.mark.django_db +class TestProjectGroupSignals(TestCase): + """Test automatic project group creation via signals.""" + + def test_subproject_creates_group(self): + """Test that creating a subproject relationship creates a project group.""" + parent = get(Project, slug="parent-project", name="Parent Project") + child = get(Project, slug="child-project", name="Child Project") + + # Create subproject relationship + relationship = ProjectRelationship.objects.create( + parent=parent, + child=child, + ) + + # Check that a project group was created + group_slug = f"{parent.slug}-subprojects" + group = ProjectGroup.objects.filter(slug=group_slug).first() + + assert group is not None + assert group.name == f"{parent.name} - Subprojects" + assert parent in group.projects.all() + assert child in group.projects.all() + + def test_translation_creates_group(self): + """Test that setting a main_language_project creates a project group.""" + main_project = get(Project, slug="main-project", name="Main Project") + translation = get( + Project, + slug="translation-project", + name="Translation Project", + main_language_project=main_project, + ) + + # Check that a project group was created + group_slug = f"{main_project.slug}-translations" + group = ProjectGroup.objects.filter(slug=group_slug).first() + + assert group is not None + assert group.name == f"{main_project.name} - Translations" + assert main_project in group.projects.all() + assert translation in group.projects.all() + + def test_multiple_subprojects_same_group(self): + """Test that multiple subprojects are added to the same group.""" + parent = get(Project, slug="parent-project", name="Parent Project") + child1 = get(Project, slug="child-project-1", name="Child Project 1") + child2 = get(Project, slug="child-project-2", name="Child Project 2") + + # Create first subproject relationship + ProjectRelationship.objects.create(parent=parent, child=child1) + + # Create second subproject relationship + ProjectRelationship.objects.create(parent=parent, child=child2) + + # Check that both are in the same group + group_slug = f"{parent.slug}-subprojects" + group = ProjectGroup.objects.get(slug=group_slug) + + assert group.projects.count() == 3 # parent + 2 children + assert parent in group.projects.all() + assert child1 in group.projects.all() + assert child2 in group.projects.all() + + +@pytest.mark.django_db +class TestProjectGroupAdmin(TestCase): + """Test ProjectGroup admin interface.""" + + def test_project_group_str(self): + """Test string representation of ProjectGroup.""" + group = ProjectGroup.objects.create( + name="Test Group", + slug="test-group", + ) + assert str(group) == "Test Group" + + def test_project_group_ordering(self): + """Test that project groups are ordered by name.""" + group_b = ProjectGroup.objects.create(name="B Group", slug="b-group") + group_a = ProjectGroup.objects.create(name="A Group", slug="a-group") + group_c = ProjectGroup.objects.create(name="C Group", slug="c-group") + + groups = list(ProjectGroup.objects.all()) + assert groups[0] == group_a + assert groups[1] == group_b + assert groups[2] == group_c diff --git a/readthedocs/search/tests/test_project_groups.py b/readthedocs/search/tests/test_project_groups.py new file mode 100644 index 00000000000..2cdea7803a8 --- /dev/null +++ b/readthedocs/search/tests/test_project_groups.py @@ -0,0 +1,194 @@ +"""Tests for search integration with project groups.""" + +import pytest +from django.test import TestCase +from django_dynamic_fixture import get + +from readthedocs.builds.models import Version +from readthedocs.projects.models import Project +from readthedocs.projects.models import ProjectGroup +from readthedocs.search.api.v3.executor import SearchExecutor +from readthedocs.search.api.v3.queryparser import SearchQueryParser + + +@pytest.mark.django_db +class TestProjectGroupSearchQueryParser(TestCase): + """Test search query parser with project_group parameter.""" + + def test_parse_project_group_argument(self): + """Test that project_group argument is parsed correctly.""" + parser = SearchQueryParser("test query project_group:my-group") + parser.parse() + + assert parser.query == "test query" + assert "my-group" in parser.arguments["project_group"] + + def test_parse_multiple_project_groups(self): + """Test parsing multiple project_group arguments.""" + parser = SearchQueryParser( + "test query project_group:group1 project_group:group2" + ) + parser.parse() + + assert parser.query == "test query" + assert "group1" in parser.arguments["project_group"] + assert "group2" in parser.arguments["project_group"] + assert len(parser.arguments["project_group"]) == 2 + + def test_project_group_with_other_arguments(self): + """Test project_group combined with other arguments.""" + parser = SearchQueryParser( + "test query project:myproject project_group:my-group" + ) + parser.parse() + + assert parser.query == "test query" + assert "myproject" in parser.arguments["project"] + assert "my-group" in parser.arguments["project_group"] + + +@pytest.mark.django_db +class TestProjectGroupSearchExecutor(TestCase): + """Test search executor with project groups.""" + + def setUp(self): + """Set up test data.""" + # Create projects + self.project1 = get( + Project, + slug="project1", + name="Project 1", + privacy_level="public", + ) + self.project2 = get( + Project, + slug="project2", + name="Project 2", + privacy_level="public", + ) + self.project3 = get( + Project, + slug="project3", + name="Project 3", + privacy_level="public", + ) + + # Create versions for each project + self.version1 = get( + Version, + project=self.project1, + slug="latest", + active=True, + built=True, + privacy_level="public", + ) + self.version2 = get( + Version, + project=self.project2, + slug="latest", + active=True, + built=True, + privacy_level="public", + ) + self.version3 = get( + Version, + project=self.project3, + slug="latest", + active=True, + built=True, + privacy_level="public", + ) + + # Create project group + self.group = ProjectGroup.objects.create( + name="Test Group", + slug="test-group", + ) + self.group.projects.add(self.project1, self.project2) + + def test_search_executor_with_project_group(self): + """Test that search executor includes projects from group.""" + from unittest.mock import Mock + + request = Mock() + request.user = Mock() + request.user.is_authenticated = False + + executor = SearchExecutor( + request=request, + query="test project_group:test-group", + ) + + projects = list(executor.projects) + project_slugs = [project.slug for project, version in projects] + + assert "project1" in project_slugs + assert "project2" in project_slugs + # project3 should not be in the results + assert "project3" not in project_slugs + + def test_search_executor_with_nonexistent_group(self): + """Test that search executor handles nonexistent groups gracefully.""" + from unittest.mock import Mock + + request = Mock() + request.user = Mock() + request.user.is_authenticated = False + + executor = SearchExecutor( + request=request, + query="test project_group:nonexistent-group", + ) + + projects = list(executor.projects) + # Should return empty list for nonexistent group + assert len(projects) == 0 + + def test_search_executor_multiple_groups(self): + """Test search executor with multiple project groups.""" + from unittest.mock import Mock + + # Create another group with project3 + group2 = ProjectGroup.objects.create( + name="Test Group 2", + slug="test-group-2", + ) + group2.projects.add(self.project3) + + request = Mock() + request.user = Mock() + request.user.is_authenticated = False + + executor = SearchExecutor( + request=request, + query="test project_group:test-group project_group:test-group-2", + ) + + projects = list(executor.projects) + project_slugs = [project.slug for project, version in projects] + + # All three projects should be in results + assert "project1" in project_slugs + assert "project2" in project_slugs + assert "project3" in project_slugs + + def test_search_executor_combined_filters(self): + """Test search executor with project_group and project filters.""" + from unittest.mock import Mock + + request = Mock() + request.user = Mock() + request.user.is_authenticated = False + + executor = SearchExecutor( + request=request, + query="test project_group:test-group project:project3", + ) + + projects = list(executor.projects) + project_slugs = [project.slug for project, version in projects] + + # Should include projects from group and explicit project + assert "project1" in project_slugs + assert "project2" in project_slugs + assert "project3" in project_slugs From 25ded59f9b66490de38ae3535c66599e414ad459 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:02:16 +0000 Subject: [PATCH 4/5] Fix linting issues in project groups code Co-authored-by: ericholscher <25510+ericholscher@users.noreply.github.com> --- readthedocs/projects/signals.py | 1 - readthedocs/search/api/v3/executor.py | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/readthedocs/projects/signals.py b/readthedocs/projects/signals.py index 8f100cb14fa..21596128e2f 100644 --- a/readthedocs/projects/signals.py +++ b/readthedocs/projects/signals.py @@ -5,7 +5,6 @@ import django.dispatch import structlog from django.db.models.signals import post_save -from django.db.models.signals import m2m_changed from django.dispatch import receiver from readthedocs.integrations.models import GitHubAppIntegrationProviderData diff --git a/readthedocs/search/api/v3/executor.py b/readthedocs/search/api/v3/executor.py index f767cdffa67..954de93c6d8 100644 --- a/readthedocs/search/api/v3/executor.py +++ b/readthedocs/search/api/v3/executor.py @@ -23,7 +23,13 @@ class SearchExecutor: """ def __init__( - self, *, request, query, arguments_required=True, default_all=False, max_projects=100 + self, + *, + request, + query, + arguments_required=True, + default_all=False, + max_projects=100, ): self.request = request self.query = query @@ -132,7 +138,11 @@ def _get_projects_from_group(self, group_slug): :param group_slug: The slug of the project group. """ try: - group = ProjectGroup.objects.prefetch_related("projects").get(slug=group_slug) + group = ( + ProjectGroup.objects.prefetch_related("projects").get( + slug=group_slug + ) + ) except ProjectGroup.DoesNotExist: return From 5ff1ccbdbfa604b9c6422940c7383792307c951e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:55:40 +0000 Subject: [PATCH 5/5] Rename ProjectGroup to Group throughout codebase Co-authored-by: ericholscher <25510+ericholscher@users.noreply.github.com> --- readthedocs/projects/admin.py | 12 +-- ...e_projectgroup.py => 0159_create_group.py} | 12 +-- readthedocs/projects/models.py | 14 +-- readthedocs/projects/signals.py | 18 ++-- .../rtd_tests/tests/test_project_groups.py | 89 +++++++++---------- readthedocs/search/api/v3/executor.py | 8 +- .../search/tests/test_project_groups.py | 52 +++++------ 7 files changed, 102 insertions(+), 103 deletions(-) rename readthedocs/projects/migrations/{0159_create_projectgroup.py => 0159_create_group.py} (86%) diff --git a/readthedocs/projects/admin.py b/readthedocs/projects/admin.py index d524c07cfaa..997be052050 100644 --- a/readthedocs/projects/admin.py +++ b/readthedocs/projects/admin.py @@ -21,11 +21,11 @@ from .models import EmailHook from .models import EnvironmentVariable from .models import Feature +from .models import Group from .models import HTMLFile from .models import HTTPHeader from .models import ImportedFile from .models import Project -from .models import ProjectGroup from .models import ProjectRelationship from .models import WebHook from .models import WebHookEvent @@ -465,18 +465,18 @@ class AddonsConfigAdmin(admin.ModelAdmin): list_editable = ("enabled",) -@admin.register(ProjectGroup) -class ProjectGroupAdmin(admin.ModelAdmin): - model = ProjectGroup +@admin.register(Group) +class GroupAdmin(admin.ModelAdmin): + model = Group list_display = ("name", "slug", "project_count", "created", "modified") search_fields = ("name", "slug") filter_horizontal = ("projects",) prepopulated_fields = {"slug": ("name",)} readonly_fields = ("created", "modified") - def project_count(self, project_group): + def project_count(self, group): """Return the number of projects in this group.""" - return project_group.projects.count() + return group.projects.count() project_count.short_description = "Project Count" diff --git a/readthedocs/projects/migrations/0159_create_projectgroup.py b/readthedocs/projects/migrations/0159_create_group.py similarity index 86% rename from readthedocs/projects/migrations/0159_create_projectgroup.py rename to readthedocs/projects/migrations/0159_create_group.py index 77d70286260..8691e627c94 100644 --- a/readthedocs/projects/migrations/0159_create_projectgroup.py +++ b/readthedocs/projects/migrations/0159_create_group.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="ProjectGroup", + name="Group", fields=[ ( "id", @@ -41,7 +41,7 @@ class Migration(migrations.Migration): ( "name", models.CharField( - help_text="Name of the project group", + help_text="Name of the group", max_length=255, unique=True, verbose_name="Name", @@ -50,7 +50,7 @@ class Migration(migrations.Migration): ( "slug", models.SlugField( - help_text="Slug for the project group", + help_text="Slug for the group", max_length=255, unique=True, verbose_name="Slug", @@ -61,14 +61,14 @@ class Migration(migrations.Migration): models.ManyToManyField( blank=True, help_text="Projects in this group", - related_name="project_groups", + related_name="groups", to="projects.project", ), ), ], options={ - "verbose_name": "Project Group", - "verbose_name_plural": "Project Groups", + "verbose_name": "Group", + "verbose_name_plural": "Groups", "ordering": ["name"], }, ), diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index ceadb47e9f7..5fb531fe80b 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -80,9 +80,9 @@ def default_privacy_level(): return settings.DEFAULT_PRIVACY_LEVEL -class ProjectGroup(TimeStampedModel): +class Group(TimeStampedModel): """ - Project Group model. + Group model. Groups projects together for searching across them with a single query parameter. By default, subprojects are grouped together, and translations are grouped together. @@ -92,24 +92,24 @@ class ProjectGroup(TimeStampedModel): _("Name"), max_length=255, unique=True, - help_text=_("Name of the project group"), + help_text=_("Name of the group"), ) slug = models.SlugField( _("Slug"), max_length=255, unique=True, - help_text=_("Slug for the project group"), + help_text=_("Slug for the group"), ) projects = models.ManyToManyField( "projects.Project", - related_name="project_groups", + related_name="groups", blank=True, help_text=_("Projects in this group"), ) class Meta: - verbose_name = _("Project Group") - verbose_name_plural = _("Project Groups") + verbose_name = _("Group") + verbose_name_plural = _("Groups") ordering = ["name"] def __str__(self): diff --git a/readthedocs/projects/signals.py b/readthedocs/projects/signals.py index 21596128e2f..cbfe3ad7af4 100644 --- a/readthedocs/projects/signals.py +++ b/readthedocs/projects/signals.py @@ -10,8 +10,8 @@ from readthedocs.integrations.models import GitHubAppIntegrationProviderData from readthedocs.integrations.models import Integration from readthedocs.projects.models import AddonsConfig +from readthedocs.projects.models import Group from readthedocs.projects.models import Project -from readthedocs.projects.models import ProjectGroup from readthedocs.projects.models import ProjectRelationship @@ -64,7 +64,7 @@ def add_subprojects_to_group(sender, instance, created, **kwargs): """ Automatically add subprojects to the parent project's subproject group. - When a subproject relationship is created, add the child to a project group + When a subproject relationship is created, add the child to a group named after the parent project with "_subprojects" suffix. """ if created: @@ -73,8 +73,8 @@ def add_subprojects_to_group(sender, instance, created, **kwargs): group_name = f"{parent.name} - Subprojects" group_slug = f"{parent.slug}-subprojects" - # Get or create the project group for subprojects - group, _ = ProjectGroup.objects.get_or_create( + # Get or create the group for subprojects + group, _ = Group.objects.get_or_create( slug=group_slug, defaults={"name": group_name}, ) @@ -82,7 +82,7 @@ def add_subprojects_to_group(sender, instance, created, **kwargs): # Add both parent and child to the group group.projects.add(parent, child) log.info( - "Added subproject to project group", + "Added subproject to group", parent_slug=parent.slug, child_slug=child.slug, group_slug=group_slug, @@ -94,7 +94,7 @@ def add_translations_to_group(sender, instance, created, **kwargs): """ Automatically add translations to the main language project's translation group. - When a project has a main_language_project, add it to a project group + When a project has a main_language_project, add it to a group named after the main language project with "_translations" suffix. """ project = instance @@ -103,8 +103,8 @@ def add_translations_to_group(sender, instance, created, **kwargs): group_name = f"{main_project.name} - Translations" group_slug = f"{main_project.slug}-translations" - # Get or create the project group for translations - group, _ = ProjectGroup.objects.get_or_create( + # Get or create the group for translations + group, _ = Group.objects.get_or_create( slug=group_slug, defaults={"name": group_name}, ) @@ -112,7 +112,7 @@ def add_translations_to_group(sender, instance, created, **kwargs): # Add both main project and translation to the group group.projects.add(main_project, project) log.info( - "Added translation to project group", + "Added translation to group", main_project_slug=main_project.slug, translation_slug=project.slug, group_slug=group_slug, diff --git a/readthedocs/rtd_tests/tests/test_project_groups.py b/readthedocs/rtd_tests/tests/test_project_groups.py index 7f9987809d1..54013f64bce 100644 --- a/readthedocs/rtd_tests/tests/test_project_groups.py +++ b/readthedocs/rtd_tests/tests/test_project_groups.py @@ -1,22 +1,21 @@ -"""Tests for ProjectGroup model and search integration.""" +"""Tests for Group model and search integration.""" import pytest from django.test import TestCase from django_dynamic_fixture import get -from readthedocs.builds.models import Version from readthedocs.projects.models import Project -from readthedocs.projects.models import ProjectGroup +from readthedocs.projects.models import Group from readthedocs.projects.models import ProjectRelationship @pytest.mark.django_db -class TestProjectGroup(TestCase): - """Test ProjectGroup model.""" +class TestGroup(TestCase): + """Test Group model.""" - def test_create_project_group(self): - """Test creating a project group.""" - group = ProjectGroup.objects.create( + def test_create_group(self): + """Test creating a group.""" + group = Group.objects.create( name="Test Group", slug="test-group", ) @@ -26,30 +25,30 @@ def test_create_project_group(self): def test_add_projects_to_group(self): """Test adding projects to a group.""" - group = ProjectGroup.objects.create( + group = Group.objects.create( name="Test Group", slug="test-group", ) project1 = get(Project, slug="project1", name="Project 1") project2 = get(Project, slug="project2", name="Project 2") - + group.projects.add(project1, project2) - + assert group.projects.count() == 2 assert project1 in group.projects.all() assert project2 in group.projects.all() def test_get_project_slugs(self): """Test getting project slugs from a group.""" - group = ProjectGroup.objects.create( + group = Group.objects.create( name="Test Group", slug="test-group", ) project1 = get(Project, slug="project1", name="Project 1") project2 = get(Project, slug="project2", name="Project 2") - + group.projects.add(project1, project2) - + slugs = group.get_project_slugs() assert "project1" in slugs assert "project2" in slugs @@ -57,31 +56,31 @@ def test_get_project_slugs(self): @pytest.mark.django_db -class TestProjectGroupSignals(TestCase): - """Test automatic project group creation via signals.""" +class TestGroupSignals(TestCase): + """Test automatic group creation via signals.""" def test_subproject_creates_group(self): - """Test that creating a subproject relationship creates a project group.""" + """Test that creating a subproject relationship creates a group.""" parent = get(Project, slug="parent-project", name="Parent Project") child = get(Project, slug="child-project", name="Child Project") - + # Create subproject relationship - relationship = ProjectRelationship.objects.create( + ProjectRelationship.objects.create( parent=parent, child=child, ) - - # Check that a project group was created + + # Check that a group was created group_slug = f"{parent.slug}-subprojects" - group = ProjectGroup.objects.filter(slug=group_slug).first() - + group = Group.objects.filter(slug=group_slug).first() + assert group is not None assert group.name == f"{parent.name} - Subprojects" assert parent in group.projects.all() assert child in group.projects.all() def test_translation_creates_group(self): - """Test that setting a main_language_project creates a project group.""" + """Test that setting a main_language_project creates a group.""" main_project = get(Project, slug="main-project", name="Main Project") translation = get( Project, @@ -89,11 +88,11 @@ def test_translation_creates_group(self): name="Translation Project", main_language_project=main_project, ) - - # Check that a project group was created + + # Check that a group was created group_slug = f"{main_project.slug}-translations" - group = ProjectGroup.objects.filter(slug=group_slug).first() - + group = Group.objects.filter(slug=group_slug).first() + assert group is not None assert group.name == f"{main_project.name} - Translations" assert main_project in group.projects.all() @@ -104,17 +103,17 @@ def test_multiple_subprojects_same_group(self): parent = get(Project, slug="parent-project", name="Parent Project") child1 = get(Project, slug="child-project-1", name="Child Project 1") child2 = get(Project, slug="child-project-2", name="Child Project 2") - + # Create first subproject relationship ProjectRelationship.objects.create(parent=parent, child=child1) - + # Create second subproject relationship ProjectRelationship.objects.create(parent=parent, child=child2) - + # Check that both are in the same group group_slug = f"{parent.slug}-subprojects" - group = ProjectGroup.objects.get(slug=group_slug) - + group = Group.objects.get(slug=group_slug) + assert group.projects.count() == 3 # parent + 2 children assert parent in group.projects.all() assert child1 in group.projects.all() @@ -122,24 +121,24 @@ def test_multiple_subprojects_same_group(self): @pytest.mark.django_db -class TestProjectGroupAdmin(TestCase): - """Test ProjectGroup admin interface.""" +class TestGroupAdmin(TestCase): + """Test Group admin interface.""" - def test_project_group_str(self): - """Test string representation of ProjectGroup.""" - group = ProjectGroup.objects.create( + def test_group_str(self): + """Test string representation of Group.""" + group = Group.objects.create( name="Test Group", slug="test-group", ) assert str(group) == "Test Group" - def test_project_group_ordering(self): - """Test that project groups are ordered by name.""" - group_b = ProjectGroup.objects.create(name="B Group", slug="b-group") - group_a = ProjectGroup.objects.create(name="A Group", slug="a-group") - group_c = ProjectGroup.objects.create(name="C Group", slug="c-group") - - groups = list(ProjectGroup.objects.all()) + def test_group_ordering(self): + """Test that groups are ordered by name.""" + group_b = Group.objects.create(name="B Group", slug="b-group") + group_a = Group.objects.create(name="A Group", slug="a-group") + group_c = Group.objects.create(name="C Group", slug="c-group") + + groups = list(Group.objects.all()) assert groups[0] == group_a assert groups[1] == group_b assert groups[2] == group_c diff --git a/readthedocs/search/api/v3/executor.py b/readthedocs/search/api/v3/executor.py index 954de93c6d8..198dfd9b7c7 100644 --- a/readthedocs/search/api/v3/executor.py +++ b/readthedocs/search/api/v3/executor.py @@ -2,8 +2,8 @@ from itertools import islice from readthedocs.builds.constants import INTERNAL +from readthedocs.projects.models import Group from readthedocs.projects.models import Project -from readthedocs.projects.models import ProjectGroup from readthedocs.search.api.v3.queryparser import SearchQueryParser from readthedocs.search.faceted_search import PageSearch @@ -135,15 +135,15 @@ def _get_projects_from_group(self, group_slug): """ Get a tuple (project, version) of all projects in a group. - :param group_slug: The slug of the project group. + :param group_slug: The slug of the group. """ try: group = ( - ProjectGroup.objects.prefetch_related("projects").get( + Group.objects.prefetch_related("projects").get( slug=group_slug ) ) - except ProjectGroup.DoesNotExist: + except Group.DoesNotExist: return for project in group.projects.all(): diff --git a/readthedocs/search/tests/test_project_groups.py b/readthedocs/search/tests/test_project_groups.py index 2cdea7803a8..bf69616beb9 100644 --- a/readthedocs/search/tests/test_project_groups.py +++ b/readthedocs/search/tests/test_project_groups.py @@ -6,20 +6,20 @@ from readthedocs.builds.models import Version from readthedocs.projects.models import Project -from readthedocs.projects.models import ProjectGroup +from readthedocs.projects.models import Group from readthedocs.search.api.v3.executor import SearchExecutor from readthedocs.search.api.v3.queryparser import SearchQueryParser @pytest.mark.django_db -class TestProjectGroupSearchQueryParser(TestCase): +class TestGroupSearchQueryParser(TestCase): """Test search query parser with project_group parameter.""" def test_parse_project_group_argument(self): """Test that project_group argument is parsed correctly.""" parser = SearchQueryParser("test query project_group:my-group") parser.parse() - + assert parser.query == "test query" assert "my-group" in parser.arguments["project_group"] @@ -29,7 +29,7 @@ def test_parse_multiple_project_groups(self): "test query project_group:group1 project_group:group2" ) parser.parse() - + assert parser.query == "test query" assert "group1" in parser.arguments["project_group"] assert "group2" in parser.arguments["project_group"] @@ -41,14 +41,14 @@ def test_project_group_with_other_arguments(self): "test query project:myproject project_group:my-group" ) parser.parse() - + assert parser.query == "test query" assert "myproject" in parser.arguments["project"] assert "my-group" in parser.arguments["project_group"] @pytest.mark.django_db -class TestProjectGroupSearchExecutor(TestCase): +class TestGroupSearchExecutor(TestCase): """Test search executor with project groups.""" def setUp(self): @@ -72,7 +72,7 @@ def setUp(self): name="Project 3", privacy_level="public", ) - + # Create versions for each project self.version1 = get( Version, @@ -98,9 +98,9 @@ def setUp(self): built=True, privacy_level="public", ) - + # Create project group - self.group = ProjectGroup.objects.create( + self.group = Group.objects.create( name="Test Group", slug="test-group", ) @@ -109,19 +109,19 @@ def setUp(self): def test_search_executor_with_project_group(self): """Test that search executor includes projects from group.""" from unittest.mock import Mock - + request = Mock() request.user = Mock() request.user.is_authenticated = False - + executor = SearchExecutor( request=request, query="test project_group:test-group", ) - + projects = list(executor.projects) project_slugs = [project.slug for project, version in projects] - + assert "project1" in project_slugs assert "project2" in project_slugs # project3 should not be in the results @@ -130,16 +130,16 @@ def test_search_executor_with_project_group(self): def test_search_executor_with_nonexistent_group(self): """Test that search executor handles nonexistent groups gracefully.""" from unittest.mock import Mock - + request = Mock() request.user = Mock() request.user.is_authenticated = False - + executor = SearchExecutor( request=request, query="test project_group:nonexistent-group", ) - + projects = list(executor.projects) # Should return empty list for nonexistent group assert len(projects) == 0 @@ -147,26 +147,26 @@ def test_search_executor_with_nonexistent_group(self): def test_search_executor_multiple_groups(self): """Test search executor with multiple project groups.""" from unittest.mock import Mock - + # Create another group with project3 - group2 = ProjectGroup.objects.create( + group2 = Group.objects.create( name="Test Group 2", slug="test-group-2", ) group2.projects.add(self.project3) - + request = Mock() request.user = Mock() request.user.is_authenticated = False - + executor = SearchExecutor( request=request, query="test project_group:test-group project_group:test-group-2", ) - + projects = list(executor.projects) project_slugs = [project.slug for project, version in projects] - + # All three projects should be in results assert "project1" in project_slugs assert "project2" in project_slugs @@ -175,19 +175,19 @@ def test_search_executor_multiple_groups(self): def test_search_executor_combined_filters(self): """Test search executor with project_group and project filters.""" from unittest.mock import Mock - + request = Mock() request.user = Mock() request.user.is_authenticated = False - + executor = SearchExecutor( request=request, query="test project_group:test-group project:project3", ) - + projects = list(executor.projects) project_slugs = [project.slug for project, version in projects] - + # Should include projects from group and explicit project assert "project1" in project_slugs assert "project2" in project_slugs