Skip to content

Commit

Permalink
Merge pull request #2372 from opensafely-core/repos-multiple-projects
Browse files Browse the repository at this point in the history
Add a dashboard to track repos with multiple projects
  • Loading branch information
ghickman authored Nov 24, 2022
2 parents ddcc70a + 5bf2235 commit ab6333c
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 9 deletions.
22 changes: 22 additions & 0 deletions assets/src/js/repos-with-multiple-projects-tablesorter.js
Original file line number Diff line number Diff line change
@@ -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",
],
},
});
});
9 changes: 7 additions & 2 deletions staff/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down
47 changes: 44 additions & 3 deletions staff/views/dashboards/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)
Expand Down Expand Up @@ -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},
)
12 changes: 11 additions & 1 deletion templates/staff/dashboards/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ <h5 class="card-title">Projects</h5>
</div>
</div>

<div class="card">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Repos</h5>
<p class="card-text">
Expand All @@ -61,6 +61,16 @@ <h5 class="card-title">Repos</h5>
</div>
</div>

<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Repos with multiple projects</h5>
<p class="card-text">
Repos with multiple projects.
</p>
<a href="{% url 'staff:dashboards:repos-with-multiple-projects' %}" class="btn btn-primary">View</a>
</div>
</div>

</div>
</div>
</div>
Expand Down
123 changes: 123 additions & 0 deletions templates/staff/dashboards/repos_with_multiple_projects.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
{% extends "staff/base.html" %}

{% load static %}

{% block metatitle %}Repos with Multiple Projects: Staff Area | OpenSAFELY Jobs{% endblock metatitle %}

{% block breadcrumbs %}
<nav class="breadcrumb-container breadcrumb--danger" aria-label="breadcrumb">
<div class="container">
<ol class="breadcrumb rounded-0 mb-0 px-0">
<li class="breadcrumb-item">
<a href="{% url 'staff:index' %}">Staff area</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'staff:dashboards:index' %}">Dashboards</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
Repos with multiple projects
</li>
</ol>
</div>
</nav>
{% endblock breadcrumbs %}

{% block jumbotron %}
<div class="jumbotron jumbotron-fluid jumbotron--danger pt-md-2">
<div class="container">
<h1 class="display-4">Repos with multiple projects</h1>
<p class="lead">
Repos with multiple projects on OpenSAFELY
</p>

<p>Remaining: {{ repos|length }}</p>
</div>
</div>
{% endblock jumbotron %}

{% block staff_content %}
<div class="container-fluid">
<div class="row">
<div class="col">
<button type="button" class="reset btn btn-outline-primary mb-3" data-column="0" data-filter="">Reset filters</button>
<div class="table-responsive">
<table class="table table-striped table-sm table--repo">
<thead>
<tr>
<th>Repo</th>
<th>Projects</th>
<th>Workspaces</th>
<th class="text-nowrap">Files released to GitHub</th>
</tr>
</thead>
<tbody>
{% for repo in repos %}
<tr>
<td><a href="{% url 'staff:repo-detail' repo_url=repo.quoted_url %}">{{ repo.name }}</a></td>

<td>
<details>
<summary>
<span class="summary--show">Show</span>
<span class="summary--hide">Hide</span>
{{ repo.projects|length }} projects
</summary>
<ul class="mt-1 mb-0 pl-2 ml-2">
{% for project in repo.projects %}
<li><a href="{{ project.get_staff_url }}">{{ project.name }}</a></li>
{% endfor %}
</ul>
</details>
</td>

<td>
{% if repo.workspaces|length > 1 %}
<details>
<summary>
<span class="summary--show">Show</span>
<span class="summary--hide">Hide</span>
{{ repo.workspaces|length }} workspaces
</summary>
<ul class="mt-1 mb-0 pl-2 ml-2">
{% for workspace in repo.workspaces %}
<li>
<a href="{{ workspace.get_staff_url }}">
{{ workspace.name }}
</a>
</li>
{% endfor %}
</ul>
</details>
{% else %}
{% for workspace in repo.workspaces %}
<a href="{{ workspace.get_staff_url }}">
{{ workspace.name }}
</a>
{% endfor %}
{% endif %}
</td>

{% if repo.has_github_outputs %}
<td class="text-center text-danger font-weight-bold">YES</td>
{% else %}
<td class="text-center text-success">No</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock staff_content %}

{% block extra_styles %}
<link rel="stylesheet" href="{% static 'vendor/tablesorter/theme.bootstrap_4.min.css' %}">
{% endblock %}

{% block extra_js %}
<script src="{% static 'vendor/tablesorter/jquery.tablesorter.min.js' %}"></script>
<script src="{% static 'vendor/tablesorter/jquery.tablesorter.widgets.min.js' %}"></script>
<script src="{% static 'js/repos-with-multiple-projects-tablesorter.js' %}"></script>
{% endblock %}
53 changes: 50 additions & 3 deletions tests/unit/staff/views/dashboards/test_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

0 comments on commit ab6333c

Please sign in to comment.