diff --git a/readthedocs/projects/admin.py b/readthedocs/projects/admin.py index ece9bc3deb1..997be052050 100644 --- a/readthedocs/projects/admin.py +++ b/readthedocs/projects/admin.py @@ -21,6 +21,7 @@ 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 @@ -464,6 +465,21 @@ class AddonsConfigAdmin(admin.ModelAdmin): list_editable = ("enabled",) +@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, group): + """Return the number of projects in this group.""" + return 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_group.py b/readthedocs/projects/migrations/0159_create_group.py new file mode 100644 index 00000000000..8691e627c94 --- /dev/null +++ b/readthedocs/projects/migrations/0159_create_group.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="Group", + 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 group", + max_length=255, + unique=True, + verbose_name="Name", + ), + ), + ( + "slug", + models.SlugField( + help_text="Slug for the group", + max_length=255, + unique=True, + verbose_name="Slug", + ), + ), + ( + "projects", + models.ManyToManyField( + blank=True, + help_text="Projects in this group", + related_name="groups", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "Group", + "verbose_name_plural": "Groups", + "ordering": ["name"], + }, + ), + ] diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 97dd5de94d7..5fb531fe80b 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 Group(TimeStampedModel): + """ + 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 group"), + ) + slug = models.SlugField( + _("Slug"), + max_length=255, + unique=True, + help_text=_("Slug for the group"), + ) + projects = models.ManyToManyField( + "projects.Project", + related_name="groups", + blank=True, + help_text=_("Projects in this group"), + ) + + class Meta: + verbose_name = _("Group") + verbose_name_plural = _("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..cbfe3ad7af4 100644 --- a/readthedocs/projects/signals.py +++ b/readthedocs/projects/signals.py @@ -10,7 +10,9 @@ 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 ProjectRelationship log = structlog.get_logger(__name__) @@ -55,3 +57,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 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 group for subprojects + group, _ = Group.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 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 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 group for translations + group, _ = Group.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 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 new file mode 100644 index 00000000000..54013f64bce --- /dev/null +++ b/readthedocs/rtd_tests/tests/test_project_groups.py @@ -0,0 +1,144 @@ +"""Tests for Group model and search integration.""" + +import pytest +from django.test import TestCase +from django_dynamic_fixture import get + +from readthedocs.projects.models import Project +from readthedocs.projects.models import Group +from readthedocs.projects.models import ProjectRelationship + + +@pytest.mark.django_db +class TestGroup(TestCase): + """Test Group model.""" + + def test_create_group(self): + """Test creating a group.""" + group = Group.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 = 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 = 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 + assert len(slugs) == 2 + + +@pytest.mark.django_db +class TestGroupSignals(TestCase): + """Test automatic group creation via signals.""" + + def test_subproject_creates_group(self): + """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 + ProjectRelationship.objects.create( + parent=parent, + child=child, + ) + + # Check that a group was created + group_slug = f"{parent.slug}-subprojects" + 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 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 group was created + group_slug = f"{main_project.slug}-translations" + 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() + 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 = 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() + assert child2 in group.projects.all() + + +@pytest.mark.django_db +class TestGroupAdmin(TestCase): + """Test Group admin interface.""" + + 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_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 e061533c48f..198dfd9b7c7 100644 --- a/readthedocs/search/api/v3/executor.py +++ b/readthedocs/search/api/v3/executor.py @@ -2,6 +2,7 @@ from itertools import islice from readthedocs.builds.constants import INTERNAL +from readthedocs.projects.models import Group from readthedocs.projects.models import Project from readthedocs.search.api.v3.queryparser import SearchQueryParser from readthedocs.search.faceted_search import PageSearch @@ -22,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 @@ -110,6 +117,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 +131,30 @@ 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 group. + """ + try: + group = ( + Group.objects.prefetch_related("projects").get( + slug=group_slug + ) + ) + except Group.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): diff --git a/readthedocs/search/tests/test_project_groups.py b/readthedocs/search/tests/test_project_groups.py new file mode 100644 index 00000000000..bf69616beb9 --- /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 Group +from readthedocs.search.api.v3.executor import SearchExecutor +from readthedocs.search.api.v3.queryparser import SearchQueryParser + + +@pytest.mark.django_db +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"] + + 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 TestGroupSearchExecutor(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 = Group.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 = 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 + 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