Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions readthedocs/projects/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
75 changes: 75 additions & 0 deletions readthedocs/projects/migrations/0159_create_group.py
Original file line number Diff line number Diff line change
@@ -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"],
},
),
]
40 changes: 40 additions & 0 deletions readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
62 changes: 62 additions & 0 deletions readthedocs/projects/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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,
)
144 changes: 144 additions & 0 deletions readthedocs/rtd_tests/tests/test_project_groups.py
Original file line number Diff line number Diff line change
@@ -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
Loading