diff --git a/assets/src/js/repos-with-multiple-projects-tablesorter.js b/assets/src/js/repos-with-multiple-projects-tablesorter.js new file mode 100644 index 000000000..d6fa53a5e --- /dev/null +++ b/assets/src/js/repos-with-multiple-projects-tablesorter.js @@ -0,0 +1,22 @@ +/* global $ */ +$(() => { + $("table").tablesorter({ + theme: "bootstrap", + widthFixed: true, + sortReset: true, + widgets: ["filter", "columns"], + headers: { + 0: { sortInitialOrder: "desc" }, + }, + widgetOptions: { + columns: ["table-info"], + filter_reset: ".reset", + filter_cssFilter: [ + "form-control", + "form-control", + "form-control", + "form-control", + ], + }, + }); +}); diff --git a/staff/urls.py b/staff/urls.py index 4b674df27..1f40f9366 100644 --- a/staff/urls.py +++ b/staff/urls.py @@ -19,7 +19,7 @@ from .views.dashboards.copiloting import Copiloting from .views.dashboards.index import DashboardIndex from .views.dashboards.projects import ProjectsDashboard -from .views.dashboards.repos import PrivateReposDashboard +from .views.dashboards.repos import PrivateReposDashboard, ReposWithMultipleProjects from .views.index import Index from .views.orgs import ( OrgCreate, @@ -91,7 +91,12 @@ path("", DashboardIndex.as_view(), name="index"), path("copiloting/", Copiloting.as_view(), name="copiloting"), path("project/", ProjectsDashboard.as_view(), name="projects"), - path("repos", PrivateReposDashboard.as_view(), name="repos"), + path("repos/", PrivateReposDashboard.as_view(), name="repos"), + path( + "repos-with-multiple-projects/", + ReposWithMultipleProjects.as_view(), + name="repos-with-multiple-projects", + ), ] org_urls = [ diff --git a/staff/views/dashboards/repos.py b/staff/views/dashboards/repos.py index ea659f78d..82caa6d1e 100644 --- a/staff/views/dashboards/repos.py +++ b/staff/views/dashboards/repos.py @@ -3,8 +3,8 @@ import structlog from csp.decorators import csp_exempt -from django.db.models import Count, Min -from django.db.models.functions import Least +from django.db.models import Count, Min, Prefetch +from django.db.models.functions import Least, Lower from django.template.response import TemplateResponse from django.utils import timezone from django.utils.decorators import method_decorator @@ -14,7 +14,7 @@ from jobserver.authorization import CoreDeveloper from jobserver.authorization.decorators import require_role from jobserver.github import _get_github_api -from jobserver.models import Project, Workspace +from jobserver.models import Project, Repo, Workspace logger = structlog.get_logger(__name__) @@ -136,3 +136,44 @@ def select(repo): return TemplateResponse( request, "staff/dashboards/repos.html", {"repos": repos} ) + + +@method_decorator(require_role(CoreDeveloper), name="dispatch") +class ReposWithMultipleProjects(View): + @csp_exempt + def get(self, request, *args, **kwargs): + def iter_repos(): + repos = ( + Repo.objects.annotate( + project_count=Count("workspaces__project", distinct=True) + ) + .filter(project_count__gte=2) + .prefetch_related( + Prefetch( + "workspaces", + Workspace.objects.order_by("name"), + to_attr="ordered_workspaces", + ), + # Prefetch( + # "workspaces__project", Project.objects.all(), to_attr="derp" + # ), + ) + .order_by(Lower("url")) + ) + + for repo in repos: + projects = Project.objects.filter(workspaces__repo=repo).distinct() + yield { + "has_github_outputs": repo.has_github_outputs, + "name": repo.name, + "projects": projects, + "quoted_url": repo.quoted_url, + "workspaces": repo.ordered_workspaces, + } + + repos = list(iter_repos()) + return TemplateResponse( + request, + "staff/dashboards/repos_with_multiple_projects.html", + {"repos": repos}, + ) diff --git a/templates/staff/dashboards/index.html b/templates/staff/dashboards/index.html index 526f7edf7..7da379dd9 100644 --- a/templates/staff/dashboards/index.html +++ b/templates/staff/dashboards/index.html @@ -50,7 +50,7 @@
Projects
-
+
Repos

@@ -61,6 +61,16 @@

Repos
+
+
+
Repos with multiple projects
+

+ Repos with multiple projects. +

+ View +
+
+
diff --git a/templates/staff/dashboards/repos_with_multiple_projects.html b/templates/staff/dashboards/repos_with_multiple_projects.html new file mode 100644 index 000000000..748b8fc78 --- /dev/null +++ b/templates/staff/dashboards/repos_with_multiple_projects.html @@ -0,0 +1,123 @@ +{% extends "staff/base.html" %} + +{% load static %} + +{% block metatitle %}Repos with Multiple Projects: Staff Area | OpenSAFELY Jobs{% endblock metatitle %} + +{% block breadcrumbs %} + +{% endblock breadcrumbs %} + +{% block jumbotron %} +
+
+

Repos with multiple projects

+

+ Repos with multiple projects on OpenSAFELY +

+ +

Remaining: {{ repos|length }}

+
+
+{% endblock jumbotron %} + +{% block staff_content %} +
+
+
+ +
+ + + + + + + + + + + {% for repo in repos %} + + + + + + + + {% if repo.has_github_outputs %} + + {% else %} + + {% endif %} + + {% endfor %} + +
RepoProjectsWorkspacesFiles released to GitHub
{{ repo.name }} +
+ + Show + Hide + {{ repo.projects|length }} projects + + +
+
+ {% if repo.workspaces|length > 1 %} +
+ + Show + Hide + {{ repo.workspaces|length }} workspaces + + +
+ {% else %} + {% for workspace in repo.workspaces %} + + {{ workspace.name }} + + {% endfor %} + {% endif %} +
YESNo
+
+
+
+
+{% endblock staff_content %} + +{% block extra_styles %} + +{% endblock %} + +{% block extra_js %} + + + +{% endblock %} diff --git a/tests/unit/staff/views/dashboards/test_repos.py b/tests/unit/staff/views/dashboards/test_repos.py index afd24f654..33941c8d1 100644 --- a/tests/unit/staff/views/dashboards/test_repos.py +++ b/tests/unit/staff/views/dashboards/test_repos.py @@ -5,9 +5,18 @@ from django.core.exceptions import PermissionDenied from django.utils import timezone -from staff.views.dashboards.repos import PrivateReposDashboard - -from .....factories import JobFactory, JobRequestFactory, RepoFactory, WorkspaceFactory +from staff.views.dashboards.repos import ( + PrivateReposDashboard, + ReposWithMultipleProjects, +) + +from .....factories import ( + JobFactory, + JobRequestFactory, + ProjectFactory, + RepoFactory, + WorkspaceFactory, +) from .....fakes import FakeGitHubAPI from .....utils import minutes_ago @@ -89,3 +98,41 @@ def test_privatereposdashboard_unauthorized(rf): with pytest.raises(PermissionDenied): PrivateReposDashboard.as_view()(request) + + +def test_reposwithmultipleprojects_success( + rf, django_assert_num_queries, core_developer +): + + # research-repo-1 + repo1 = RepoFactory(url="https://github.com/opensafely/repo-1") + WorkspaceFactory(repo=repo1) + WorkspaceFactory(repo=repo1) + + repo2 = RepoFactory(url="https://github.com/opensafely/repo-2") + WorkspaceFactory(repo=repo2) + WorkspaceFactory(repo=repo2) + + repo3 = RepoFactory(url="https://github.com/opensafely/repo-3") + WorkspaceFactory.create_batch(5, repo=repo3, project=ProjectFactory()) + + request = rf.get("/") + request.user = core_developer + + with django_assert_num_queries(2): + response = ReposWithMultipleProjects.as_view()(request) + + assert response.status_code == 200 + + assert len(response.context_data["repos"]) == 2 + assert {r["name"] for r in response.context_data["repos"]} == { + repo1.name, + repo2.name, + } + + +def test_reposwithmultipleprojects_unauthorized(rf): + request = rf.get("/") + request.user = AnonymousUser() + with pytest.raises(PermissionDenied): + ReposWithMultipleProjects.as_view()(request)