Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a dashboard to track repos with multiple projects #2372

Merged
merged 1 commit into from
Nov 24, 2022
Merged
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
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 %}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to know whether a repo has github outputs here? Does it change anything about the data tidying process?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, but we store it in the database so I figured hey why not!

<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)