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
103 changes: 103 additions & 0 deletions nextcloudappstore/api/v1/tests/test_app_download_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later
"""

from unittest.mock import MagicMock, patch

from django.contrib.auth import get_user_model
from django.urls import reverse

from nextcloudappstore.api.v1.tests.api import ApiTest
from nextcloudappstore.core.models import App, AppRelease

GITHUB_URL = "https://github.com/nextcloud-releases/spreed/releases/download/v29.0.0/spreed.tar.gz"
OTHER_URL = "https://example.com/spreed-28.0.0.tar.gz"

MOCK_GH_RELEASES = [
{
"tag_name": "v29.0.0",
"assets": [{"name": "spreed.tar.gz", "download_count": 13832}],
}
]


class AppDownloadStatsTest(ApiTest):
def setUp(self):
super().setUp()
self.app = App.objects.create(id="spreed", owner=self.user)
AppRelease.objects.create(
app=self.app,
version="29.0.0",
download=GITHUB_URL,
platform_version_spec=">=30.0.0",
)
AppRelease.objects.create(
app=self.app,
version="28.0.0",
download=OTHER_URL,
platform_version_spec=">=29.0.0",
)

def _url(self, pk="spreed"):
return reverse("api:v1:app-download-stats", kwargs={"pk": pk})

@patch("nextcloudappstore.api.v1.views.GitHubClient")
def test_owner_sees_counts(self, MockClient):
MockClient.return_value.get_releases.return_value = MOCK_GH_RELEASES
self._login_token()
response = self.api_client.get(self._url())
self.assertEqual(200, response.status_code)
by_version = {r["version"]: r for r in response.data}
self.assertEqual(13832, by_version["29.0.0"]["download_count"])
self.assertIsNone(by_version["28.0.0"]["download_count"])

@patch("nextcloudappstore.api.v1.views.GitHubClient")
def test_co_maintainer_sees_counts(self, MockClient):
MockClient.return_value.get_releases.return_value = MOCK_GH_RELEASES
other = get_user_model().objects.create_user(username="other", password="other", email="other@test.com")
self.app.co_maintainers.add(other)
self._login("other", "other")
response = self.api_client.get(self._url())
self.assertEqual(200, response.status_code)

def test_unauthenticated_returns_401(self):
response = self.api_client.get(self._url())
self.assertEqual(401, response.status_code)

def test_non_maintainer_returns_403(self):
stranger = get_user_model().objects.create_user(
username="stranger", password="stranger", email="stranger@test.com"
)
App.objects.create(id="other_app", owner=stranger)
self._login_token()
response = self.api_client.get(reverse("api:v1:app-download-stats", kwargs={"pk": "other_app"}))
self.assertEqual(403, response.status_code)

def test_unknown_app_returns_404(self):
self._login_token()
response = self.api_client.get(self._url("nonexistent"))
self.assertEqual(404, response.status_code)

@patch("nextcloudappstore.api.v1.views.GitHubClient")
def test_github_api_error_yields_null_count(self, MockClient):
import requests as req

MockClient.return_value.get_releases.side_effect = req.RequestException("rate limited")
self._login_token()
response = self.api_client.get(self._url())
self.assertEqual(200, response.status_code)
for entry in response.data:
self.assertIsNone(entry["download_count"])

@patch("nextcloudappstore.api.v1.views.GitHubClient")
def test_response_shape(self, MockClient):
MockClient.return_value.get_releases.return_value = MOCK_GH_RELEASES
self._login_token()
response = self.api_client.get(self._url())
self.assertEqual(200, response.status_code)
for entry in response.data:
self.assertIn("version", entry)
self.assertIn("is_nightly", entry)
self.assertIn("download", entry)
self.assertIn("download_count", entry)
2 changes: 2 additions & 0 deletions nextcloudappstore/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from nextcloudappstore.api.v1.views import (
AppApiAppsView,
AppDownloadStatsView,
AppRatingView,
AppRegisterView,
AppReleaseView,
Expand Down Expand Up @@ -45,6 +46,7 @@
re_path(r"^appapi_apps\.json$", etag(apps_all_etag)(AppApiAppsView.as_view()), name="appapi_apps"),
re_path(r"^apps/releases/?$", AppReleaseView.as_view(), name="app-release-create"),
re_path(r"^apps/?$", AppRegisterView.as_view(), name="app-register"),
re_path(r"^apps/(?P<pk>[a-z0-9_]+)/downloads/?$", AppDownloadStatsView.as_view(), name="app-download-stats"),
re_path(r"^apps/(?P<pk>[a-z0-9_]+)/?$", AppView.as_view(), name="app-delete"),
re_path(r"^ratings.json$", etag(app_ratings_etag)(AppRatingView.as_view()), name="app-ratings"),
re_path(
Expand Down
24 changes: 24 additions & 0 deletions nextcloudappstore/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
)
from nextcloudappstore.certificate.validator import CertificateValidator
from nextcloudappstore.core.facades import read_file_contents
from nextcloudappstore.core.github import GitHubClient, get_download_counts
from nextcloudappstore.core.models import (
App,
AppRating,
Expand Down Expand Up @@ -397,3 +398,26 @@ def post(self, request):
except OSError:
return Response({"error": "Unable to save file."}, status=500)
return Response({"message": "File saved successfully"}, status=200)


class AppDownloadStatsView(APIView):
"""Return GitHub download counts for all releases of an app.

Only the app owner and co-maintainers may access this endpoint.
Releases not hosted on GitHub (or whose GitHub API call fails) have
download_count set to null.
"""

authentication_classes = (
authentication.TokenAuthentication,
authentication.BasicAuthentication,
)
permission_classes = (IsAuthenticated,)

def get(self, request, pk):
app = get_object_or_404(App, pk=pk)
if not app.can_update(request.user):
raise PermissionDenied()
releases = list(AppRelease.objects.filter(app=app).order_by("-last_modified"))
client = GitHubClient(settings.GITHUB_API_BASE_URL, settings.GITHUB_API_TOKEN)
return Response(get_download_counts(releases, client))
74 changes: 73 additions & 1 deletion nextcloudappstore/core/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
SPDX-License-Identifier: AGPL-3.0-or-later
"""

import re
from collections.abc import Iterable
from itertools import chain, takewhile

Expand All @@ -11,12 +12,20 @@

from nextcloudappstore.core.models import NextcloudRelease

_GITHUB_RELEASE_URL_RE = re.compile(r"^https?://github\.com/([^/]+)/([^/]+)/releases/download/([^/]+)/(.+)$")


def parse_github_release_url(url: str) -> tuple[str, str, str, str] | None:
"""Parse a GitHub release asset URL into (owner, repo, tag, filename), or None."""
m = _GITHUB_RELEASE_URL_RE.match(url)
return (m.group(1), m.group(2), m.group(3), m.group(4)) if m else None


class GitHubClient:
def __init__(self, base_url: str, api_token: str = None) -> None:
self.base_url = base_url.rstrip("/")
self.api_token = api_token
self.headers = None if self.api_token else {"Authorization": f"token {self.api_token}"}
self.headers = {"Authorization": f"token {self.api_token}"} if self.api_token else None

def get_tags(self, page: int, size: int = 100):
url = f"{self.base_url}/repos/nextcloud/server/tags"
Expand All @@ -25,6 +34,20 @@ def get_tags(self, page: int, size: int = 100):
response.raise_for_status()
return response.json()

def get_releases(self, owner: str, repo: str) -> list:
url = f"{self.base_url}/repos/{owner}/{repo}/releases"
releases = []
page = 1
while True:
response = requests.get(url, params={"per_page": 100, "page": page}, headers=self.headers, timeout=21)
response.raise_for_status()
page_data = response.json()
if not page_data:
break
releases.extend(page_data)
page += 1
return releases


def sync_releases(versions: Iterable[str]) -> None:
"""
Expand Down Expand Up @@ -105,3 +128,52 @@ def __next__(self):
return json
else:
raise StopIteration


def get_download_counts(releases: list, client: GitHubClient) -> list[dict]:
"""
Return per-release download counts fetched from the GitHub releases API.

Each entry in the returned list corresponds to one AppRelease and contains:
version, is_nightly, download (the URL), download_count (int or None).

Counts are None for releases not hosted on GitHub or when the GitHub API
call fails (e.g. rate-limited, private repo, network error).
"""
# Group releases by (owner, repo) to minimise API calls.
repo_to_releases: dict[tuple[str, str], list[tuple[str, str, object]]] = {}
for release in releases:
parsed = parse_github_release_url(release.download)
if parsed:
owner, repo, tag, filename = parsed
repo_to_releases.setdefault((owner, repo), []).append((tag, filename, release))

# Build (owner, repo, tag, filename) -> download_count lookup.
count_map: dict[tuple[str, str, str, str], int] = {}
for owner, repo in repo_to_releases:
try:
gh_releases = client.get_releases(owner, repo)
for gh_release in gh_releases:
tag_name = gh_release.get("tag_name", "")
for asset in gh_release.get("assets", []):
count_map[(owner, repo, tag_name, asset["name"])] = asset["download_count"]
except requests.RequestException:
pass

result = []
for release in releases:
parsed = parse_github_release_url(release.download)
if parsed:
owner, repo, tag, filename = parsed
count = count_map.get((owner, repo, tag, filename))
else:
count = None
result.append(
{
"version": release.version,
"is_nightly": release.is_nightly,
"download": release.download,
"download_count": count,
}
)
return result
10 changes: 10 additions & 0 deletions nextcloudappstore/core/templates/app/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,16 @@ <h2>{% trans "Resources" %}</h2>
</ul>
</section>
{% endif %}
{% if request.user == object.owner or request.user in object.co_maintainers.all %}
<section class="interact-section">
<h2>{% trans "Developer" %}</h2>
<a href="{% url 'app-downloads' object.id %}"
class="btn btn-default btn-light">
<span class="icon icon-chart-bar"></span>
{% trans 'Download statistics' %}
</a>
</section>
{% endif %}
<section class="interact-section">
<h2>{% trans "Interact" %}</h2>
{% if object.issue_tracker %}
Expand Down
72 changes: 72 additions & 0 deletions nextcloudappstore/core/templates/app/downloads.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{% extends 'app/base.html' %}
{% load i18n static humanize %}

{# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}

{% block head-title %}{% trans 'Download Stats' %} - {{ object.name }} - {% trans 'Apps' %} - {% endblock %}

{% block apps %}
<h1>{{ object.name }} &mdash; {% trans 'Download Statistics' %}</h1>
<section>
<a href="{% url 'app-detail' object.id %}">&larr; {% trans 'App details' %}</a>
</section>

<section class="app-releases" style="margin-top: 1.5rem;">
<p class="text-muted">
{% blocktrans %}Download counts are retrieved live from GitHub and reflect the total number of times each release asset has been downloaded.{% endblocktrans %}
</p>

{% if download_stats %}
<table class="table table-striped">
<thead>
<tr>
<th>{% trans 'Version' %}</th>
<th>{% trans 'Channel' %}</th>
<th>{% trans 'Downloads' %}</th>
<th>{% trans 'Source' %}</th>
</tr>
</thead>
<tbody>
{% for entry in download_stats %}
<tr>
<td>{{ entry.version }}</td>
<td>
{% if entry.is_nightly %}
<span class="label label-warning">{% trans 'Nightly' %}</span>
{% else %}
<span class="label label-success">{% trans 'Stable' %}</span>
{% endif %}
</td>
<td>
{% if entry.download_count is not None %}
<strong>{{ entry.download_count|intcomma }}</strong>
{% else %}
<span class="text-muted">&mdash; {% trans 'not available' %}</span>
{% endif %}
</td>
<td>
<a href="{{ entry.download }}" rel="noopener noreferrer" class="text-muted small" style="word-break:break-all;">
{{ entry.download }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>

{% with total=download_stats|length %}
<p class="text-muted small">
{% blocktrans count counter=total %}{{ counter }} release listed.{% plural %}{{ counter }} releases listed.{% endblocktrans %}
{% trans 'Releases not hosted on GitHub show no count.' %}
</p>
{% endwith %}
{% else %}
<div class="panel panel-default">
<div class="panel-body">
<p class="text-center lead">{% trans 'No releases found for this app.' %}</p>
</div>
</div>
{% endif %}
</section>
{% endblock %}
Loading
Loading