diff --git a/nextcloudappstore/api/v1/tests/test_app_download_stats.py b/nextcloudappstore/api/v1/tests/test_app_download_stats.py new file mode 100644 index 00000000000..875f05decb9 --- /dev/null +++ b/nextcloudappstore/api/v1/tests/test_app_download_stats.py @@ -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) diff --git a/nextcloudappstore/api/v1/urls.py b/nextcloudappstore/api/v1/urls.py index b2ddbfdfc60..324d4184b2f 100644 --- a/nextcloudappstore/api/v1/urls.py +++ b/nextcloudappstore/api/v1/urls.py @@ -8,6 +8,7 @@ from nextcloudappstore.api.v1.views import ( AppApiAppsView, + AppDownloadStatsView, AppRatingView, AppRegisterView, AppReleaseView, @@ -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[a-z0-9_]+)/downloads/?$", AppDownloadStatsView.as_view(), name="app-download-stats"), re_path(r"^apps/(?P[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( diff --git a/nextcloudappstore/api/v1/views.py b/nextcloudappstore/api/v1/views.py index 31070ec150f..9e9da92e4d6 100644 --- a/nextcloudappstore/api/v1/views.py +++ b/nextcloudappstore/api/v1/views.py @@ -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, @@ -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)) diff --git a/nextcloudappstore/core/github.py b/nextcloudappstore/core/github.py index 6d5bce10b22..b1205970d39 100644 --- a/nextcloudappstore/core/github.py +++ b/nextcloudappstore/core/github.py @@ -3,6 +3,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later """ +import re from collections.abc import Iterable from itertools import chain, takewhile @@ -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" @@ -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: """ @@ -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 diff --git a/nextcloudappstore/core/templates/app/detail.html b/nextcloudappstore/core/templates/app/detail.html index 8d19a958097..1b8f2d60b30 100644 --- a/nextcloudappstore/core/templates/app/detail.html +++ b/nextcloudappstore/core/templates/app/detail.html @@ -148,6 +148,16 @@

{% trans "Resources" %}

{% endif %} + {% if request.user == object.owner or request.user in object.co_maintainers.all %} +
+

{% trans "Developer" %}

+ + + {% trans 'Download statistics' %} + +
+ {% endif %}

{% trans "Interact" %}

{% if object.issue_tracker %} diff --git a/nextcloudappstore/core/templates/app/downloads.html b/nextcloudappstore/core/templates/app/downloads.html new file mode 100644 index 00000000000..45e212385ab --- /dev/null +++ b/nextcloudappstore/core/templates/app/downloads.html @@ -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 %} +

{{ object.name }} — {% trans 'Download Statistics' %}

+
+ ← {% trans 'App details' %} +
+ +
+

+ {% blocktrans %}Download counts are retrieved live from GitHub and reflect the total number of times each release asset has been downloaded.{% endblocktrans %} +

+ + {% if download_stats %} + + + + + + + + + + + {% for entry in download_stats %} + + + + + + + {% endfor %} + +
{% trans 'Version' %}{% trans 'Channel' %}{% trans 'Downloads' %}{% trans 'Source' %}
{{ entry.version }} + {% if entry.is_nightly %} + {% trans 'Nightly' %} + {% else %} + {% trans 'Stable' %} + {% endif %} + + {% if entry.download_count is not None %} + {{ entry.download_count|intcomma }} + {% else %} + — {% trans 'not available' %} + {% endif %} + + + {{ entry.download }} + +
+ + {% with total=download_stats|length %} +

+ {% blocktrans count counter=total %}{{ counter }} release listed.{% plural %}{{ counter }} releases listed.{% endblocktrans %} + {% trans 'Releases not hosted on GitHub show no count.' %} +

+ {% endwith %} + {% else %} +
+
+

{% trans 'No releases found for this app.' %}

+
+
+ {% endif %} +
+{% endblock %} diff --git a/nextcloudappstore/core/tests/test_github.py b/nextcloudappstore/core/tests/test_github.py index c88ca95e848..99dba78dbf9 100644 --- a/nextcloudappstore/core/tests/test_github.py +++ b/nextcloudappstore/core/tests/test_github.py @@ -12,7 +12,9 @@ from nextcloudappstore.core.facades import read_relative_file from nextcloudappstore.core.github import ( GitHubClient, + get_download_counts, get_supported_releases, + parse_github_release_url, sync_releases, ) from nextcloudappstore.core.models import NextcloudRelease @@ -88,3 +90,81 @@ def _get_tags(self, page: int, size: int = 100) -> dict[Any, Any]: def _read(self, path: str) -> str: return read_relative_file(__file__, f"data/{path}") + + +class ParseGitHubReleaseUrlTest(TestCase): + def test_standard_url(self): + url = "https://github.com/nextcloud-releases/spreed/releases/download/v29.0.0/spreed.tar.gz" + self.assertEqual( + ("nextcloud-releases", "spreed", "v29.0.0", "spreed.tar.gz"), + parse_github_release_url(url), + ) + + def test_http_url(self): + url = "http://github.com/owner/repo/releases/download/v1.0/app.tar.gz" + self.assertEqual(("owner", "repo", "v1.0", "app.tar.gz"), parse_github_release_url(url)) + + def test_non_github_url_returns_none(self): + self.assertIsNone(parse_github_release_url("https://example.com/app.tar.gz")) + + def test_github_non_release_url_returns_none(self): + self.assertIsNone(parse_github_release_url("https://github.com/owner/repo/files/123/app.tar.gz")) + + def test_raw_githubusercontent_returns_none(self): + self.assertIsNone( + parse_github_release_url("https://raw.githubusercontent.com/owner/repo/refs/tags/v1.0/build/app.tar.gz") + ) + + +class GetDownloadCountsTest(TestCase): + def _make_release(self, version, download, is_nightly=False): + r = MagicMock() + r.version = version + r.download = download + r.is_nightly = is_nightly + return r + + def test_counts_from_github(self): + release = self._make_release( + "29.0.0", "https://github.com/nextcloud-releases/spreed/releases/download/v29.0.0/spreed.tar.gz" + ) + client = MagicMock(spec=GitHubClient) + client.get_releases.return_value = [ + {"tag_name": "v29.0.0", "assets": [{"name": "spreed.tar.gz", "download_count": 13832}]} + ] + result = get_download_counts([release], client) + self.assertEqual(1, len(result)) + self.assertEqual(13832, result[0]["download_count"]) + self.assertEqual("29.0.0", result[0]["version"]) + + def test_non_github_url_yields_none(self): + release = self._make_release("1.0.0", "https://example.com/app.tar.gz") + client = MagicMock(spec=GitHubClient) + result = get_download_counts([release], client) + self.assertIsNone(result[0]["download_count"]) + client.get_releases.assert_not_called() + + def test_api_error_yields_none(self): + import requests as req + + release = self._make_release("1.0.0", "https://github.com/owner/repo/releases/download/v1.0.0/app.tar.gz") + client = MagicMock(spec=GitHubClient) + client.get_releases.side_effect = req.RequestException("error") + result = get_download_counts([release], client) + self.assertIsNone(result[0]["download_count"]) + + def test_deduplicates_api_calls_per_repo(self): + releases = [ + self._make_release("2.0.0", "https://github.com/owner/repo/releases/download/v2.0.0/app.tar.gz"), + self._make_release("1.0.0", "https://github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"), + ] + client = MagicMock(spec=GitHubClient) + client.get_releases.return_value = [ + {"tag_name": "v2.0.0", "assets": [{"name": "app.tar.gz", "download_count": 200}]}, + {"tag_name": "v1.0.0", "assets": [{"name": "app.tar.gz", "download_count": 100}]}, + ] + result = get_download_counts(releases, client) + # Only one API call for the same owner/repo + client.get_releases.assert_called_once_with("owner", "repo") + self.assertEqual(200, result[0]["download_count"]) + self.assertEqual(100, result[1]["download_count"]) diff --git a/nextcloudappstore/core/views.py b/nextcloudappstore/core/views.py index f0f557f9d6e..a72819224b8 100644 --- a/nextcloudappstore/core/views.py +++ b/nextcloudappstore/core/views.py @@ -29,7 +29,15 @@ AppRegisterForm, AppReleaseUploadForm, ) -from nextcloudappstore.core.models import App, AppRating, Category, Donation, Podcast +from nextcloudappstore.core.github import GitHubClient, get_download_counts +from nextcloudappstore.core.models import ( + App, + AppRating, + AppRelease, + Category, + Donation, + Podcast, +) from nextcloudappstore.core.serializers import AppRatingSerializer from nextcloudappstore.core.versioning import pad_min_version @@ -309,3 +317,23 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["form"] = AppRegisterForm() return context + + +class AppDownloadStatsView(LoginRequiredMixin, DetailView): + template_name = "app/downloads.html" + slug_field = "id" + slug_url_kwarg = "id" + queryset = App.objects.prefetch_related("translations") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["categories"] = Category.objects.prefetch_related("translations").all() + app = self.object + if not app.can_update(self.request.user): + from django.core.exceptions import PermissionDenied + + raise PermissionDenied() + releases = list(AppRelease.objects.filter(app=app).order_by("-last_modified")) + client = GitHubClient(settings.GITHUB_API_BASE_URL, settings.GITHUB_API_TOKEN) + context["download_stats"] = get_download_counts(releases, client) + return context diff --git a/nextcloudappstore/urls.py b/nextcloudappstore/urls.py index 16a9c079646..657e626ba08 100644 --- a/nextcloudappstore/urls.py +++ b/nextcloudappstore/urls.py @@ -17,6 +17,7 @@ from nextcloudappstore.core.feeds import AppReleaseAtomFeed, AppReleaseRssFeed from nextcloudappstore.core.views import ( AppDetailView, + AppDownloadStatsView, AppRatingApi, AppRegisterView, AppReleasesView, @@ -44,6 +45,7 @@ re_path(r"^developer/apps/new/?$", AppRegisterView.as_view(), name="app-register"), re_path(r"^apps/(?P[\w_]+)/?$", AppDetailView.as_view(), name="app-detail"), re_path(r"^apps/(?P[\w_]+)/releases/?$", AppReleasesView.as_view(), name="app-releases"), + re_path(r"^apps/(?P[\w_]+)/downloads/?$", AppDownloadStatsView.as_view(), name="app-downloads"), re_path(r"^apps/(?P[\w_]+)/description/?$", app_description, name="app-description"), re_path(r"^apps/(?P[\w_]+)/ratings.json$", etag(app_rating_etag)(AppRatingApi.as_view()), name="app-ratings"), path("api/", include("nextcloudappstore.api.urls", namespace="api")),