Repository: webkom/lego
Pull request #3798: Add statistics to Articles
Author: login: itsisak
Labels: name: new-feature
description: Pull requests that introduce or issues that suggest a new feature
name: review-needed
description: Pull requests that need review
Created: 2025-03-21T15:25:06Z | Merged: 2025-03-26T12:43:47Z

# Description

Create endpoint for getting statistics from plausible for articles. Also move logic for fetching from plausible to own function. Mercilessly "kokt" from events. 

# Testing

Wrote some tests mocking a response from Plausible, but there are Nno fixtures has data on plausible so should probably test this in staging.

Resolves ABA-1375



Diff:
diff --git a/lego/apps/articles/tests/test_articles_api.py b/lego/apps/articles/tests/test_articles_api.py
index b239b05ec..c8340485f 100644
--- a/lego/apps/articles/tests/test_articles_api.py
+++ b/lego/apps/articles/tests/test_articles_api.py
@@ -1,3 +1,6 @@
+from datetime import datetime, timedelta
+from unittest import mock
+
 from django.urls import reverse
 from rest_framework import status
 
@@ -15,6 +18,10 @@ def get_detail_url(pk):
     return reverse("api:v1:article-detail", kwargs={"pk": pk})
 
 
+def get_statistics_url(pk):
+    return reverse("api:v1:article-statistics", kwargs={"pk": pk})
+
+
 def create_user():
     return User.objects.create(username="testuser")
 
@@ -305,3 +312,96 @@ def test_only_one_pinned(self):
         self.assertTrue(self.article.pinned)
         self.assertFalse(pinned.pinned)
         self.assertEqual(Article.objects.filter(pinned=True).count(), 1)
+
+
+number_of_plausible_items = 3
+
+
+def mock_plausible_response(*args, **kwargs):
+    class MockPlausibleResponse:
+        def __init__(self, *args, **kwargs):
+            self.status_code = status.HTTP_200_OK
+            self.data = [
+                self.generate_plausible_item(i)
+                for i in range(number_of_plausible_items)
+            ]
+
+        def generate_plausible_item(self, i):
+            return {
+                "bounce_rate": str(30),
+                "date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
+                "pageviews": str(i * 2),
+                "visit_duration": str(i * 10),
+                "visitors": str(i),
+            }
+
+        def json(self):
+            return self.data
+
+    return MockPlausibleResponse()
+
+
+@mock.patch(
+    "lego.utils.functions._request_plausible",
+    return_value=mock_plausible_response(),
+)
+class ArticleStatisticsCase(BaseAPITestCase):
+    def setUp(self):
+        self.user = create_user()
+        self.edit_group = create_group()
+        self.view_group = create_group()
+
+        self.auth_article = create_article(require_auth=True)
+        self.auth_article.can_view_groups.add(self.view_group)
+        self.auth_article.can_edit_groups.add(self.edit_group)
+
+    def test_unauthenticated(self, *args):
+        """Unauthenticated users should not be able to view statistics"""
+        response = self.client.get(get_statistics_url(self.auth_article.pk))
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def test_view_group(self, *args):
+        """Users who can view the article should be able to view statistics"""
+        self.view_group.add_user(self.user)
+        self.client.force_authenticate(user=self.user)
+        response = self.client.get(get_statistics_url(self.auth_article.pk))
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def test_edit_group(self, *args):
+        """Users in can_edit_groups for an article should be able to view statistics"""
+        self.edit_group.add_user(self.user)
+        self.client.force_authenticate(user=self.user)
+
+        response = self.client.get(get_statistics_url(self.auth_article.pk))
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertTrue(response.json())
+        self.assertEqual(len(response.json()), number_of_plausible_items)
+
+    def test_keyword_permission(self, *args):
+        """Users with keyword permission should be able to view statistics"""
+        self.view_group.permissions = ["/sudo/admin/articles/edit/"]
+        self.view_group.save()
+        self.client.force_authenticate(user=self.user)
+
+        # Since Article is an ObjectPermissionModel, we need to add the
+        # user to the view_group to be able to call a GET endpoint
+        self.view_group.add_user(self.user)
+
+        response = self.client.get(get_statistics_url(self.auth_article.pk))
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertTrue(response.json())
+        self.assertEqual(len(response.json()), number_of_plausible_items)
+
+    def test_own_article(self, *args):
+        """Users who created the article should be able to view statistics"""
+        self.auth_article.created_by = self.user
+        self.auth_article.save()
+        self.client.force_authenticate(user=self.user)
+
+        response = self.client.get(get_statistics_url(self.auth_article.pk))
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertTrue(response.json())
+        self.assertEqual(len(response.json()), number_of_plausible_items)
diff --git a/lego/apps/articles/views.py b/lego/apps/articles/views.py
index 8e346d8e3..b98fabf63 100644
--- a/lego/apps/articles/views.py
+++ b/lego/apps/articles/views.py
@@ -1,7 +1,10 @@
 from django.http import Http404
 from django.shortcuts import get_object_or_404
-from rest_framework import viewsets
+from rest_framework import status
+from rest_framework.decorators import action
 from rest_framework.exceptions import NotAuthenticated, PermissionDenied
+from rest_framework.response import Response
+from rest_framework.viewsets import ModelViewSet
 
 from lego.apps.articles.filters import ArticleFilterSet
 from lego.apps.articles.models import Article
@@ -12,9 +15,10 @@
 )
 from lego.apps.permissions.api.views import AllowedPermissionsMixin
 from lego.apps.permissions.constants import OBJECT_PERMISSIONS_FIELDS
+from lego.utils.functions import request_plausible_statistics
 
 
-class ArticlesViewSet(AllowedPermissionsMixin, viewsets.ModelViewSet):
+class ArticlesViewSet(AllowedPermissionsMixin, ModelViewSet):
     queryset = Article.objects.all()
     ordering = "-created_at"
     serializer_class = DetailedArticleSerializer
@@ -52,3 +56,20 @@ def get_object(self) -> Article:
         except (NotAuthenticated, PermissionDenied) as e:
             raise Http404 from e
         return obj
+
+    @action(detail=True, methods=["GET"])
+    def statistics(self, request, pk=None, *args, **kwargs) -> Response:
+        article = Article.objects.get(id=pk)
+        user = request.user
+
+        if not user or not user.is_authenticated:
+            return Response(status=status.HTTP_401_UNAUTHORIZED)
+
+        has_perm = user.has_perm("statistics", article) or user.has_perm(
+            "edit", article
+        )
+        if not has_perm:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+
+        response = request_plausible_statistics(article)
+        return Response(response.json())
diff --git a/lego/apps/events/views.py b/lego/apps/events/views.py
index 1419cf383..ca8c7f65c 100644
--- a/lego/apps/events/views.py
+++ b/lego/apps/events/views.py
@@ -1,7 +1,3 @@
-from datetime import datetime
-from urllib.parse import quote
-
-from django.conf import settings
 from django.db import transaction
 from django.db.models import Count, Prefetch, Q
 from django.http import Http404
@@ -13,7 +9,6 @@
 from rest_framework.response import Response
 from rest_framework.serializers import BaseSerializer
 
-import requests
 from celery.canvas import chain
 
 from lego.apps.events import constants
@@ -72,7 +67,7 @@
 from lego.apps.permissions.constants import EDIT, VIEW
 from lego.apps.permissions.utils import get_permission_handler
 from lego.apps.users.models import PhotoConsent, User
-from lego.utils.functions import verify_captcha
+from lego.utils.functions import request_plausible_statistics, verify_captcha
 
 
 class EventViewSet(AllowedPermissionsMixin, viewsets.ModelViewSet):
@@ -262,29 +257,7 @@ def statistics(self, request, *args, **kwargs):
         event_id = self.kwargs.get("pk", None)
         event = Event.objects.get(pk=event_id)
 
-        headers = {
-            "Authorization": f"Bearer {settings.PLAUSIBLE_KEY}",
-            "Content-Type": "application/json",
-        }
-
-        created_at = event.created_at.strftime("%Y-%m-%d")
-        now = datetime.now().strftime("%Y-%m-%d")
-        # Plausible wants the date on this schema: YYYY-MM-DD,YYYY-MM-DD
-        date = f"{created_at},{now}"
-
-        url_path = f"/events/{event.id}"
-
-        filters = f"event:page=={quote(url_path)}"
-        api_url = (
-            "https://ls.webkom.dev/api/v1/stats/timeseries"
-            "?site_id=abakus.no"
-            "&metrics=visitors,pageviews,bounce_rate,visit_duration"
-            "&period=custom&date={date}"
-            "&filters={filters}"
-        ).format(date=date, filters=filters)
-
-        response = requests.get(api_url, headers=headers)
-
+        response = request_plausible_statistics(event)
         return Response(response.json())
 
     @decorators.action(detail=True, methods=["POST"], serializer_class=BaseSerializer)
diff --git a/lego/utils/functions.py b/lego/utils/functions.py
index 9cd620d30..80293243f 100644
--- a/lego/utils/functions.py
+++ b/lego/utils/functions.py
@@ -1,9 +1,14 @@
+from datetime import datetime
+from urllib.parse import quote
+
 from django.conf import settings
+from rest_framework.response import Response
 
 import requests
 from structlog import get_logger
 
 from lego.apps.users.models import AbakusGroup
+from lego.utils.models import BasisModel
 
 log = get_logger()
 
@@ -37,3 +42,50 @@ def insert_abakus_groups(tree, parent=None):
                 name=key, defaults={**kwargs, "parent": parent}
             )[0]
         insert_abakus_groups(value[1], node)
+
+
+# Seperate external API call to mock only this function in tests
+def _request_plausible(url: str, headers: dict) -> Response:
+    return requests.get(url, headers=headers)
+
+
+def request_plausible_statistics(obj: BasisModel, **kwargs) -> Response:
+    created_at = obj.created_at.strftime("%Y-%m-%d")
+    now = datetime.now().strftime("%Y-%m-%d")
+    date = f"{created_at},{now}"  # Plausible wants the date on this schema: YYYY-MM-DD,YYYY-MM-DD
+
+    model_name = obj._meta.model_name
+    url_root = kwargs.get("url_root", None)
+
+    if not (model_name or url_root):
+        raise ValueError("Valid obj or url_root must be provided")
+
+    url_path = f"/{url_root or model_name + 's'}/{obj.id}"
+    filters = f"event:page=={quote(url_path)}"
+
+    api_url = (
+        f"https://ls.webkom.dev/api/v1/stats/timeseries"
+        f"?site_id=abakus.no"
+        f"&metrics=visitors,pageviews,bounce_rate,visit_duration"
+        f"&period=custom&date={date}"
+        f"&filters={filters}"
+    )
+
+    key = None
+    if hasattr(settings, "PLAUSIBLE_KEY"):
+        key = settings.PLAUSIBLE_KEY
+
+    headers = {
+        "Authorization": f"Bearer {key or ''}",
+        "Content-Type": "application/json",
+    }
+
+    try:
+        return _request_plausible(api_url, headers)
+    except requests.exceptions.RequestException as e:
+        log.error(
+            "plausible_statistics_error",
+            exception=e,
+            api_url=api_url,
+            headers=headers,
+        )


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 3**

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: ASSERTIVE**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 94cf036a726bc5c1df69aa1e3adc0ef577ddf011 and b0574528b8e76888f796f7978a25f5be6902798e.

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `lego/apps/articles/tests/test_articles_api.py` (3 hunks)
* `lego/apps/articles/views.py` (3 hunks)
* `lego/apps/events/views.py` (2 hunks)
* `lego/utils/functions.py` (2 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (1)</summary>

<details>
<summary>lego/apps/events/views.py (1)</summary><blockquote>

<details>
<summary>lego/utils/functions.py (2) (2)</summary>

* `request_plausible_statistics` (48-68)
* `verify_captcha` (17-31)

</details>

</blockquote></details>

</details><details>
<summary>🪛 Ruff (0.8.2)</summary>

<details>
<summary>lego/apps/articles/views.py</summary>

62-62: Missing return type annotation for public function `statistics`

(ANN201)

---

62-62: Missing type annotation for function argument `request`

(ANN001)

---

62-62: Missing type annotation for function argument `pk`

(ANN001)

---

62-62: Missing type annotation for `*args`

(ANN002)

---

62-62: Unused method argument: `args`

(ARG002)

---

62-62: Missing type annotation for `**kwargs`

(ANN003)

---

62-62: Unused method argument: `kwargs`

(ARG002)

---

71-71: Trailing comma missing

Add trailing comma

(COM812)

</details>
<details>
<summary>lego/utils/functions.py</summary>

50-50: `datetime.datetime.now()` called without a `tz` argument

(DTZ005)

---

55-61: Use f-string instead of `format` call

Convert to f-string

(UP032)

---

68-68: Probable use of `requests` call without timeout

(S113)

</details>
<details>
<summary>lego/apps/articles/tests/test_articles_api.py</summary>

6-6: `rest_framework.response.Response` imported but unused

Remove unused import: `rest_framework.response.Response`

(F401)

---

22-22: Missing return type annotation for public function `get_statistics_url`

(ANN201)

---

22-22: Missing type annotation for function argument `pk`

(ANN001)

---

26-26: Missing return type annotation for public function `create_user`

(ANN201)

---

30-30: Missing return type annotation for public function `create_other_user`

(ANN201)

---

322-322: Missing return type annotation for public function `generate_plausible_item`

(ANN201)

---

322-322: Missing type annotation for function argument `i`

(ANN001)

---

325-325: `datetime.datetime.now()` called without a `tz` argument

(DTZ005)

---

335-335: Missing return type annotation for public function `mocked_plausible_request`

(ANN201)

---

335-335: Missing type annotation for `*args`

(ANN002)

---

335-335: Unused function argument: `args`

(ARG001)

---

335-335: Missing type annotation for `**kwargs`

(ANN003)

---

335-335: Unused function argument: `kwargs`

(ARG001)

---

337-337: Missing return type annotation for special method `__init__`

Add return type annotation: `None`

(ANN204)

---

337-337: Missing type annotation for `*args`

(ANN002)

---

337-337: Unused method argument: `args`

(ARG002)

---

337-337: Missing type annotation for `**kwargs`

(ANN003)

---

337-337: Unused method argument: `kwargs`

(ARG002)

---

343-343: Missing return type annotation for private function `json`

(ANN202)

---

354-354: Missing return type annotation for public function `setUp`

Add return type annotation: `None`

(ANN201)

---

367-367: Missing return type annotation for public function `test_unauthenticated`

Add return type annotation: `None`

(ANN201)

---

367-367: Missing type annotation for `*args`

(ANN002)

---

367-367: Unused method argument: `args`

(ARG002)

---

369-369: Use a regular `assert` instead of unittest-style `assertEqual`

Replace `assertEqual(...)` with `assert ...`

(PT009)

---

371-371: Missing return type annotation for public function `test_authenticated_view`

Add return type annotation: `None`

(ANN201)

---

371-371: Missing type annotation for `*args`

(ANN002)

---

371-371: Unused method argument: `args`

(ARG002)

---

374-374: Use a regular `assert` instead of unittest-style `assertEqual`

Replace `assertEqual(...)` with `assert ...`

(PT009)

---

376-376: Missing return type annotation for public function `test_authenticated_edit`

Add return type annotation: `None`

(ANN201)

---

376-376: Missing type annotation for `*args`

(ANN002)

---

376-376: Unused method argument: `args`

(ARG002)

---

379-379: Use a regular `assert` instead of unittest-style `assertEqual`

Replace `assertEqual(...)` with `assert ...`

(PT009)

---

380-380: Use a regular `assert` instead of unittest-style `assertTrue`

Replace `assertTrue(...)` with `assert ...`

(PT009)

---

381-381: Use a regular `assert` instead of unittest-style `assertEqual`

Replace `assertEqual(...)` with `assert ...`

(PT009)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>lego/apps/events/views.py (2)</summary>

`75-75`: **Good refactoring to use the shared utility function.**

The import of `request_plausible_statistics` from the centralized utility simplifies the code and promotes code reuse.

---

`265-266`: **Clean implementation using the shared utility.**

The refactored code is more concise and maintainable by delegating the responsibility of constructing and making the external request to a dedicated function.

</details>
<details>
<summary>lego/utils/functions.py (1)</summary>

`1-12`: **Appropriate imports for the new functionality.**

The added imports properly support the new function's requirements.

</details>
<details>
<summary>lego/apps/articles/tests/test_articles_api.py (1)</summary>

`349-382`: **Well-structured test cases for the statistics endpoint.**

The test cases appropriately verify:
1. Unauthenticated access is rejected
2. Authenticated users without proper permissions cannot access statistics
3. Users with edit permissions can access statistics

This provides good coverage for the new functionality.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

354-354: Missing return type annotation for public function `setUp`

Add return type annotation: `None`

(ANN201)

---

367-367: Missing return type annotation for public function `test_unauthenticated`

Add return type annotation: `None`

(ANN201)

---

367-367: Missing type annotation for `*args`

(ANN002)

---

367-367: Unused method argument: `args`

(ARG002)

---

369-369: Use a regular `assert` instead of unittest-style `assertEqual`

Replace `assertEqual(...)` with `assert ...`

(PT009)

---

371-371: Missing return type annotation for public function `test_authenticated_view`

Add return type annotation: `None`

(ANN201)

---

371-371: Missing type annotation for `*args`

(ANN002)

---

371-371: Unused method argument: `args`

(ARG002)

---

374-374: Use a regular `assert` instead of unittest-style `assertEqual`

Replace `assertEqual(...)` with `assert ...`

(PT009)

---

376-376: Missing return type annotation for public function `test_authenticated_edit`

Add return type annotation: `None`

(ANN201)

---

376-376: Missing type annotation for `*args`

(ANN002)

---

376-376: Unused method argument: `args`

(ARG002)

---

379-379: Use a regular `assert` instead of unittest-style `assertEqual`

Replace `assertEqual(...)` with `assert ...`

(PT009)

---

380-380: Use a regular `assert` instead of unittest-style `assertTrue`

Replace `assertTrue(...)` with `assert ...`

(PT009)

---

381-381: Use a regular `assert` instead of unittest-style `assertEqual`

Replace `assertEqual(...)` with `assert ...`

(PT009)

</details>

</details>

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: DISMISSED
author_association: NONE
submitted_at: 2025-03-21T15:29:18Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>lego/apps/articles/views.py (2)</summary><blockquote>

`3-7`: **Update imports to remove unused functions.**

The import `get_permission_handler` from `lego.apps.permissions.utils` is not used in the file and should be removed to avoid confusion and keep the code clean.

```diff
from lego.apps.permissions.constants import OBJECT_PERMISSIONS_FIELDS
-from lego.apps.permissions.utils import get_permission_handler
from lego.utils.functions import request_plausible_statistics
```


Also applies to: 18-19

---

`69-71`: **Good permission handling but consider using permissions framework consistently.**

Using `user.has_perm()` directly works, but for consistency with the rest of the codebase, consider using the permission handler approach as seen in other parts of the application.

```diff
-    has_perm = user.has_perm("statistics", article) or user.has_perm(
-        "edit", article
-    )
-    if not has_perm:
+    permission_handler = get_permission_handler(Article)
+    user_has_perm = permission_handler.has_object_permissions(
+        user, "statistics", article
+    ) or permission_handler.has_object_permissions(
+        user, "edit", article
+    )
+    if not user_has_perm:
        return Response(status=status.HTTP_403_FORBIDDEN)
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 8dddb93f62dde67e023131b7f8f4f8aef36b96ca and d0dd1eee21c39288191cebdc803fdc2ff020e361.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `lego/apps/articles/views.py` (3 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

<details>
<summary>lego/apps/articles/views.py</summary>

18-18: `lego.apps.permissions.utils.get_permission_handler` imported but unused

Remove unused import: `lego.apps.permissions.utils.get_permission_handler`

(F401)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (2)</summary><blockquote>

<details>
<summary>lego/apps/articles/views.py (2)</summary>

`22-22`: **LGTM: ViewSet class updated to use direct import.**

Changed from using `viewsets.ModelViewSet` to a direct import of `ModelViewSet`, which is a good practice for cleaner imports.

---

`61-76`: **Add error handling and type annotations to the statistics endpoint.**

The implementation of the statistics endpoint is good, but it could be improved with better error handling and type annotations as mentioned in previous reviews.


1. Add error handling for article retrieval and the Plausible request
2. Add type annotations for the `request` parameter
3. Consider error handling for edge cases

```diff
@action(detail=True, methods=["GET"])
-def statistics(self, request, pk=None, *args, **kwargs) -> Response:
+def statistics(self, request, pk=None, *args, **kwargs) -> Response:
-    article = Article.objects.get(id=pk)
+    try:
+        article = Article.objects.get(id=pk)
+    except Article.DoesNotExist:
+        return Response(
+            {"error": "Article not found"},
+            status=status.HTTP_404_NOT_FOUND
+        )
    user = request.user

    if not user or not user.is_authenticated:
        return Response(status=status.HTTP_401_UNAUTHORIZED)

    has_perm = user.has_perm("statistics", article) or user.has_perm(
        "edit", article
    )
    if not has_perm:
        return Response(status=status.HTTP_403_FORBIDDEN)

-    response = request_plausible_statistics(article)
-    return Response(response.json())
+    try:
+        response = request_plausible_statistics(article)
+        return Response(response.json())
+    except Exception as e:
+        import logging
+        log = logging.getLogger(__name__)
+        log.error("plausible_statistics_error", exc_info=True, extra={
+            "article_id": article.id
+        })
+        return Response(
+            {"error": "Failed to retrieve statistics"},
+            status=status.HTTP_503_SERVICE_UNAVAILABLE
+        )
```

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-23T16:12:14Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>lego/apps/articles/views.py (1)</summary><blockquote>

`61-76`: _🛠️ Refactor suggestion_

**Add error handling for the statistics endpoint**

The statistics endpoint properly checks authentication and permissions, but it lacks error handling for potential exceptions from the Plausible request. Also, adding type annotations would improve code quality.

```diff
    @action(detail=True, methods=["GET"])
-   def statistics(self, request, pk=None, *args, **kwargs) -> Response:
+   def statistics(self, request, pk=None, *args, **kwargs) -> Response:
        article = Article.objects.get(id=pk)
        user = request.user

        if not user or not user.is_authenticated:
            return Response(status=status.HTTP_401_UNAUTHORIZED)

        has_perm = user.has_perm("statistics", article) or user.has_perm(
            "edit", article
        )
        if not has_perm:
            return Response(status=status.HTTP_403_FORBIDDEN)

-       response = request_plausible_statistics(article)
-       return Response(response.json())
+       try:
+           response = request_plausible_statistics(article)
+           return Response(response.json())
+       except Exception as e:
+           # Log the error
+           return Response(
+               {"error": "Failed to retrieve statistics"},
+               status=status.HTTP_503_SERVICE_UNAVAILABLE
+           )
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>lego/apps/articles/views.py (1)</summary><blockquote>

`18-18`: **Remove unused import**

The `get_permission_handler` import is not being used in this file. It should be removed to keep the imports clean.

```diff
-from lego.apps.permissions.utils import get_permission_handler
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

18-18: `lego.apps.permissions.utils.get_permission_handler` imported but unused

Remove unused import: `lego.apps.permissions.utils.get_permission_handler`

(F401)

</details>

</details>

</blockquote></details>
<details>
<summary>lego/apps/articles/tests/test_articles_api.py (1)</summary><blockquote>

`6-6`: **Remove unused import**

The `Response` import is not being used in this file. It should be removed to keep the imports clean.

```diff
-from rest_framework.response import Response
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

6-6: `rest_framework.response.Response` imported but unused

Remove unused import: `rest_framework.response.Response`

(F401)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between d0dd1eee21c39288191cebdc803fdc2ff020e361 and 8351c0c2733e30a4272001de0767acbcf1e0a2c4.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `lego/apps/articles/tests/test_articles_api.py` (3 hunks)
* `lego/apps/articles/views.py` (3 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

<details>
<summary>lego/apps/articles/tests/test_articles_api.py</summary>

6-6: `rest_framework.response.Response` imported but unused

Remove unused import: `rest_framework.response.Response`

(F401)

</details>
<details>
<summary>lego/apps/articles/views.py</summary>

18-18: `lego.apps.permissions.utils.get_permission_handler` imported but unused

Remove unused import: `lego.apps.permissions.utils.get_permission_handler`

(F401)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>lego/apps/articles/views.py (1)</summary>

`69-71`: **Good permission implementation**

The permission check correctly verifies if the user has either "statistics" or "edit" permissions on the article.

</details>
<details>
<summary>lego/apps/articles/tests/test_articles_api.py (4)</summary>

`22-24`: **Good implementation of the statistics URL helper function**

This helper function is well implemented and follows the same pattern as the other URL helper functions in the file.

---

`321-342`: **Use timezone-aware datetime and add type annotations**

The helper functions for testing are well-implemented, but there are small improvements for timezone consistency and code quality:



```diff
-def mock_plausible_response(*args, **kwargs):
+def mock_plausible_response(*args, **kwargs) -> object:
    class MockPlausibleResponse:
        def __init__(self, *args, **kwargs):
            self.status_code = status.HTTP_200_OK
            self.data = [
                self.generate_plausible_item(i)
                for i in range(number_of_plausible_items)
            ]

-        def generate_plausible_item(self, i):
+        def generate_plausible_item(self, i: int) -> dict:
            return {
                "bounce_rate": str(30),
-               "date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
+               "date": (datetime.now().replace(tzinfo=timezone.utc) - timedelta(days=i)).strftime("%Y-%m-%d"),
                "pageviews": str(i * 2),
                "visit_duration": str(i * 10),
                "visitors": str(i),
            }

-        def json(self):
+        def json(self) -> list:
            return self.data

    return MockPlausibleResponse()
```

Don't forget to add the timezone import:
```diff
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
```

---

`345-348`: **Good use of mock for testing**

Using `mock.patch` to replace the real API call with a mock response is a good testing practice. This approach isolates the tests from the external Plausible service.

---

`349-409`: **Comprehensive test cases for the statistics endpoint**

The test cases cover all important permission scenarios:
1. Unauthenticated users cannot access statistics
2. Users with only view permission cannot access statistics
3. Users with edit permission can access statistics
4. Users with appropriate keyword permissions can access statistics
5. Article creators can access their own statistics

This is a thorough and well-structured test suite.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-23T16:32:58Z
user: login: jonasdeluna
body: nice!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-23T16:34:04Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 8351c0c2733e30a4272001de0767acbcf1e0a2c4 and 7dd4b3cc1ea947e1c37714355b4a2f20190a39eb.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `lego/apps/articles/tests/test_articles_api.py` (3 hunks)
* `lego/apps/articles/views.py` (3 hunks)

</details>

<details>
<summary>🔇 Additional comments (13)</summary><blockquote>

<details>
<summary>lego/apps/articles/views.py (3)</summary>

`3-7`: **LGTM! Appropriate imports for the new functionality.**

The new imports from rest_framework and the model view change are appropriate for implementing the statistics endpoint.

---

`18-18`: **LGTM! Proper import for the Plausible statistics utility.**

Importing the request_plausible_statistics function from the utils package is appropriate for fetching statistics data.

---

`21-21`: **LGTM! Good refactoring of the import usage.**

Simplifying the import usage by directly using ModelViewSet rather than viewsets.ModelViewSet is a good practice.

</details>
<details>
<summary>lego/apps/articles/tests/test_articles_api.py (10)</summary>

`21-22`: **LGTM! Well-defined helper function.**

Good practice to have a dedicated helper function for the statistics URL consistent with existing patterns.

---

`317-317`: **LGTM! Good constant definition.**

Defining a constant for the number of plausible items is a good practice for maintainability.

---

`320-341`: **Add type annotations and use timezone-aware datetime.**

The mock response implementation needs some improvements:

1. Use timezone-aware datetime to ensure consistent test results
2. Add type annotations for better code quality


Apply this diff:

```diff
def mock_plausible_response(*args, **kwargs):
    class MockPlausibleResponse:
        def __init__(self, *args, **kwargs):
            self.status_code = status.HTTP_200_OK
            self.data = [
                self.generate_plausible_item(i)
                for i in range(number_of_plausible_items)
            ]

-        def generate_plausible_item(self, i):
+        def generate_plausible_item(self, i: int) -> dict:
            return {
                "bounce_rate": str(30),
-                "date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
+                "date": (datetime.now(timezone.utc) - timedelta(days=i)).strftime("%Y-%m-%d"),
                "pageviews": str(i * 2),
                "visit_duration": str(i * 10),
                "visitors": str(i),
            }

-        def json(self):
+        def json(self) -> list:
            return self.data

    return MockPlausibleResponse()
```

Don't forget to import timezone from django.utils.

---

`344-348`: **LGTM! Good mocking approach.**

The mock patching of the request_plausible_statistics function is well implemented.

---

`349-357`: **LGTM! Good test setup.**

The test setup properly creates the necessary user groups and articles with appropriate permissions.

---

`358-362`: **LGTM! Good test for unauthenticated access.**

The test correctly verifies that unauthenticated users cannot access the statistics endpoint.

---

`363-369`: **LGTM! Good test for view group access.**

The test properly verifies that users who can only view the article (but not edit) cannot access statistics.

---

`370-380`: **LGTM! Well-structured test for edit group access.**

The test correctly verifies that users with edit permissions can access statistics and checks the response structure.

---

`381-396`: **LGTM! Comprehensive test for keyword permissions.**

The test thoroughly checks that users with the appropriate keyword permission can access statistics. Good comments explaining the requirement to add the user to the view group.

---

`397-407`: **LGTM! Good test for article creators.**

The test verifies that the original creator of an article can access its statistics, which is an important use case.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-23T16:37:02Z
user: login: itsisak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T16:38:31Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-23T16:38:54Z
user: login: ivarnakken
body: Very cool!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-23T18:25:00Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>lego/utils/functions.py (3)</summary><blockquote>

`50-53`: **Add timezone awareness to `datetime.now()`.**

The current implementation uses a naive datetime object for the current time. For consistency and accuracy, you should use a timezone-aware datetime.


```diff
-    now = datetime.now().strftime("%Y-%m-%d")
+    from django.utils import timezone
+    now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
```

---

`56-56`: **Simplify redundant default value in `kwargs.get()`.**

The `.get()` method already returns `None` by default, so the explicit `None` is unnecessary.


```diff
-    url_root = kwargs.get("url_root", None)
+    url_root = kwargs.get("url_root")
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

56-56: Use `kwargs.get("url_root")` instead of `kwargs.get("url_root", None)`

Replace `kwargs.get("url_root", None)` with `kwargs.get("url_root")`

(SIM910)

</details>

</details>

---

`50-90`: **Add docstring to document function parameters and return value.**

This function would benefit from a docstring explaining its purpose, parameters, and return value to assist other developers who might use it.


```diff
 def request_plausible_statistics(obj: BasisModel, **kwargs) -> Response:
+    """
+    Fetch statistics from Plausible analytics for a given model instance.
+    
+    Args:
+        obj: The model instance to fetch statistics for
+        **kwargs: Additional arguments
+            url_root: Optional custom URL root to use instead of model name
+            
+    Returns:
+        Response: The HTTP response from Plausible
+        
+    Raises:
+        ValueError: If neither model_name nor url_root is available
+        requests.exceptions.RequestException: If the request to Plausible fails
+    """
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

56-56: Use `kwargs.get("url_root")` instead of `kwargs.get("url_root", None)`

Replace `kwargs.get("url_root", None)` with `kwargs.get("url_root")`

(SIM910)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between a06efc40fdfbec738dadea3e31030cfd4d6e5e04 and 2d2e5c696535894e9da07ba27f2ee078316a4260.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `lego/apps/articles/tests/test_articles_api.py` (3 hunks)
* `lego/utils/functions.py` (2 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

<details>
<summary>lego/utils/functions.py</summary>

56-56: Use `kwargs.get("url_root")` instead of `kwargs.get("url_root", None)`

Replace `kwargs.get("url_root", None)` with `kwargs.get("url_root")`

(SIM910)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (12)</summary><blockquote>

<details>
<summary>lego/utils/functions.py (3)</summary>

`47-48`: **LGTM: Good separation for testability.**

Separating the external API call into its own function facilitates easier mocking in tests, which is good practice.

---

`72-75`: **LGTM: Safe handling of settings key.**

The code correctly checks if the setting exists before trying to access it, preventing potential attribute errors.

---

`81-89`: **LGTM: Good error handling with proper logging.**

The implementation properly handles request exceptions and logs detailed error information, which is essential for diagnosing issues with external API calls.

</details>
<details>
<summary>lego/apps/articles/tests/test_articles_api.py (9)</summary>

`317-318`: **LGTM: Well-defined constant for test configuration.**

Defining the number of test items as a constant makes it easy to adjust the test data size and keeps the value consistent throughout the tests.

---

`332-332`: **Add timezone awareness to `datetime.now()`.**

Similar to the recommendation in the main function, you should use a timezone-aware datetime here to ensure consistency and prevent potential timezone-related issues.



```diff
-                "date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
+                from django.utils import timezone
+                "date": (datetime.now(timezone.utc) - timedelta(days=i)).strftime("%Y-%m-%d"),
```

---

`320-342`: **Add type annotations to functions and methods.**

For better code quality and to help with IDE auto-completion and type checking, consider adding type annotations to the mock functions and methods.



```diff
-def mock_plausible_response(*args, **kwargs):
+def mock_plausible_response(*args, **kwargs) -> 'MockPlausibleResponse':

class MockPlausibleResponse:
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:

-    def generate_plausible_item(self, i):
+    def generate_plausible_item(self, i: int) -> dict:

-    def json(self):
+    def json(self) -> list[dict]:
```

---

`344-348`: **LGTM: Good use of mock patching at the class level.**

Using the mock.patch decorator at the class level is an efficient way to apply the mock to all test methods, reducing code duplication.

---

`358-362`: **LGTM: Comprehensive test for unauthenticated users.**

This test properly verifies that unauthenticated users cannot access the statistics endpoint.

---

`363-369`: **LGTM: Accurate testing of permission restrictions.**

This test correctly verifies that users who can only view an article cannot view its statistics, as they need edit permissions.

---

`370-380`: **LGTM: Thorough testing of edit permissions.**

This test correctly validates that users with edit permissions can access statistics and verifies the response data structure.

---

`381-396`: **LGTM: Good testing of keyword permissions with clear comments.**

The test includes helpful comments explaining why additional setup (adding the user to view_group) is needed for the ObjectPermissionModel, making the code more maintainable.

---

`397-407`: **LGTM: Proper testing of content creator permissions.**

This test correctly verifies that the creator of an article can view its statistics, which is an important permission case to validate.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-25T13:57:47Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>lego/utils/functions.py (3)</summary><blockquote>

`47-49`: **Add timeout parameter to improve robustness in _request_plausible**

The helper function correctly extracts the API call for easier mocking, but it should include a timeout parameter to prevent hanging requests.

```diff
def _request_plausible(url: str, headers: dict) -> Response:
-    return requests.get(url, headers=headers)
+    return requests.get(url, headers=headers, timeout=10)
```

---

`58-58`: **Simplify kwargs.get call**

Since `None` is already the default return value for `dict.get()`, specifying it explicitly is redundant.

```diff
-    url_root = kwargs.get("url_root", None)
+    url_root = kwargs.get("url_root")
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

58-58: Use `kwargs.get("url_root")` instead of `kwargs.get("url_root", None)`

Replace `kwargs.get("url_root", None)` with `kwargs.get("url_root")`

(SIM910)

</details>

</details>

---

`74-81`: **Add validation for missing PLAUSIBLE_KEY**

The current implementation sets the API key to an empty string if not available in settings, which might cause API calls to fail silently. Consider adding explicit validation or fallback behavior.

```diff
-    key = None
-    if hasattr(settings, "PLAUSIBLE_KEY"):
-        key = settings.PLAUSIBLE_KEY
-
-    headers = {
-        "Authorization": f"Bearer {key or ''}",
-        "Content-Type": "application/json",
-    }
+    if not hasattr(settings, "PLAUSIBLE_KEY") or not settings.PLAUSIBLE_KEY:
+        log.warning("plausible_key_missing", message="PLAUSIBLE_KEY not set in settings")
+        # Consider what behavior you want here - fail early or continue with empty token?
+    
+    headers = {
+        "Authorization": f"Bearer {getattr(settings, 'PLAUSIBLE_KEY', '')}",
+        "Content-Type": "application/json",
+    }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 2d2e5c696535894e9da07ba27f2ee078316a4260 and 40b944c57bc00199cecb09f8f9fc711768bf8dae.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `lego/utils/functions.py` (2 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

<details>
<summary>lego/utils/functions.py</summary>

58-58: Use `kwargs.get("url_root")` instead of `kwargs.get("url_root", None)`

Replace `kwargs.get("url_root", None)` with `kwargs.get("url_root")`

(SIM910)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (2)</summary><blockquote>

<details>
<summary>lego/utils/functions.py (2)</summary>

`54-55`: **Use timezone-aware datetime**

The current implementation uses a timezone-naive datetime, which could lead to incorrect date ranges if the server's timezone differs from UTC.

```diff
-    now = datetime.now().strftime("%Y-%m-%d")
+    from django.utils import timezone
+    now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
```

---

`52-92`: **Overall the implementation looks good**

The function correctly handles fetching statistics from Plausible with proper error handling and URL construction. The suggestions above will further improve its robustness, but the current implementation is already a good improvement over the previous approach.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

58-58: Use `kwargs.get("url_root")` instead of `kwargs.get("url_root", None)`

Replace `kwargs.get("url_root", None)` with `kwargs.get("url_root")`

(SIM910)

</details>

</details>

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-25T14:11:04Z
user: login: ch0rizo
body: 🪨 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T12:42:16Z

Review comments:
diff_hunk: @@ -37,3 +43,26 @@ def insert_abakus_groups(tree, parent=None):
                 name=key, defaults={**kwargs, "parent": parent}
             )[0]
         insert_abakus_groups(value[1], node)
+
+
+def request_plausible_statistics(obj: BasisModel, url_root: Optional[str]) -> Response:
+    created_at = obj.created_at.strftime("%Y-%m-%d")
+    now = datetime.now().strftime("%Y-%m-%d")
+    date = f"{created_at},{now}"  # Plausible wants the date on this schema: YYYY-MM-DD,YYYY-MM-DD
+    url_path = f"/{url_root or (obj._meta.model_name + 's')}/{obj.id}"
+    filters = f"event:page=={quote(url_path)}"
+
+    api_url = (
+        "https://ls.webkom.dev/api/v1/stats/timeseries"
+        "?site_id=abakus.no"
+        "&metrics=visitors,pageviews,bounce_rate,visit_duration"
+        "&period=custom&date={date}"
+        "&filters={filters}"
+    ).format(date=date, filters=filters)
+
+    headers = {
+        "Authorization": f"Bearer {settings.PLAUSIBLE_KEY}",
+        "Content-Type": "application/json",
+    }
+
+    return requests.get(api_url, headers=headers)
path: lego/utils/functions.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Add timezone awareness and request timeout to improve robustness.**

The function implementation looks good overall but could benefit from a few improvements:

1. The current datetime should use timezone awareness
2. The request should include a timeout parameter
3. Consider using f-strings for better readability in the API URL construction


```diff
def request_plausible_statistics(obj: BasisModel, url_root: Optional[str]) -> Response:
    created_at = obj.created_at.strftime("%Y-%m-%d")
-   now = datetime.now().strftime("%Y-%m-%d")
+   now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    date = f"{created_at},{now}"  # Plausible wants the date on this schema: YYYY-MM-DD,YYYY-MM-DD
    url_path = f"/{url_root or (obj._meta.model_name + 's')}/{obj.id}"
    filters = f"event:page=={quote(url_path)}"

-   api_url = (
-       "https://ls.webkom.dev/api/v1/stats/timeseries"
-       "?site_id=abakus.no"
-       "&metrics=visitors,pageviews,bounce_rate,visit_duration"
-       "&period=custom&date={date}"
-       "&filters={filters}"
-   ).format(date=date, filters=filters)
+   api_url = (
+       f"https://ls.webkom.dev/api/v1/stats/timeseries"
+       f"?site_id=abakus.no"
+       f"&metrics=visitors,pageviews,bounce_rate,visit_duration"
+       f"&period=custom&date={date}"
+       f"&filters={filters}"
+   )

    headers = {
        "Authorization": f"Bearer {settings.PLAUSIBLE_KEY}",
        "Content-Type": "application/json",
    }

-   return requests.get(api_url, headers=headers)
+   return requests.get(api_url, headers=headers, timeout=10)
```

Additionally, consider adding error handling similar to the `verify_captcha` function above to handle potential request failures.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if any existing code handles request failures
rg -A 5 -B 5 "requests.exceptions.RequestException" --glob "*.py"
```

Length of output: 753

---



</details>

**Action Required: Improve Robustness in `request_plausible_statistics` Function**

The improvements made so far address timezone awareness, URL construction with f-strings, and the inclusion of a request timeout. However, the call to `requests.get` still lacks error handling, which is important for consistency with how `verify_captcha` handles failures.

- **Timezone Awareness:** Use `datetime.now(timezone.utc)` to ensure the current datetime is timezone aware.
- **Timeout:** Include a timeout (e.g., `timeout=10`) in the request to prevent indefinite hangs.
- **URL Construction:** Convert the API URL construction to use f-strings for clearer readability.
- **Error Handling:** Wrap the request in a try/except block to catch `requests.exceptions.RequestException` and log the error similarly to the error handling in `verify_captcha`. For example:

```diff
 def request_plausible_statistics(obj: BasisModel, url_root: Optional[str]) -> Response:
     created_at = obj.created_at.strftime("%Y-%m-%d")
-    now = datetime.now().strftime("%Y-%m-%d")
+    now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
     date = f"{created_at},{now}"  # Plausible expects a date format: YYYY-MM-DD,YYYY-MM-DD
     url_path = f"/{url_root or (obj._meta.model_name + 's')}/{obj.id}"
     filters = f"event:page=={quote(url_path)}"
 
-    api_url = (
-        "https://ls.webkom.dev/api/v1/stats/timeseries"
-        "?site_id=abakus.no"
-        "&metrics=visitors,pageviews,bounce_rate,visit_duration"
-        "&period=custom&date={date}"
-        "&filters={filters}"
-    ).format(date=date, filters=filters)
+    api_url = (
+        f"https://ls.webkom.dev/api/v1/stats/timeseries"
+        f"?site_id=abakus.no"
+        f"&metrics=visitors,pageviews,bounce_rate,visit_duration"
+        f"&period=custom&date={date}"
+        f"&filters={filters}"
+    )
 
     headers = {
         "Authorization": f"Bearer {settings.PLAUSIBLE_KEY}",
         "Content-Type": "application/json",
     }
 
-    return requests.get(api_url, headers=headers)
+    try:
+         response = requests.get(api_url, headers=headers, timeout=10)
+         response.raise_for_status()
+         return response
+    except requests.exceptions.RequestException as e:
+         log.error("plausible_statistics_error", exception=e, api_url=api_url)
+         # Optionally, handle the exception or re-raise it
+         raise
```

Please update the function accordingly to enhance its robustness and consistency with other similar requests in the codebase.



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
def request_plausible_statistics(obj: BasisModel, url_root: Optional[str]) -> Response:
    created_at = obj.created_at.strftime("%Y-%m-%d")
    now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    date = f"{created_at},{now}"  # Plausible wants the date on this schema: YYYY-MM-DD,YYYY-MM-DD
    url_path = f"/{url_root or (obj._meta.model_name + 's')}/{obj.id}"
    filters = f"event:page=={quote(url_path)}"

    api_url = (
        f"https://ls.webkom.dev/api/v1/stats/timeseries"
        f"?site_id=abakus.no"
        f"&metrics=visitors,pageviews,bounce_rate,visit_duration"
        f"&period=custom&date={date}"
        f"&filters={filters}"
    )

    headers = {
        "Authorization": f"Bearer {settings.PLAUSIBLE_KEY}",
        "Content-Type": "application/json",
    }

    try:
         response = requests.get(api_url, headers=headers, timeout=10)
         response.raise_for_status()
         return response
    except requests.exceptions.RequestException as e:
         log.error("plausible_statistics_error", exception=e, api_url=api_url)
         # Optionally, handle the exception or re-raise it
         raise
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

50-50: `datetime.datetime.now()` called without a `tz` argument

(DTZ005)

---

55-61: Use f-string instead of `format` call

Convert to f-string

(UP032)

---

68-68: Probable use of `requests` call without timeout

(S113)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 8dddb93
created_at: 2025-03-21T15:29:15Z
author_association: NONE
diff_hunk: @@ -52,3 +57,21 @@ def get_object(self) -> Article:
         except (NotAuthenticated, PermissionDenied) as e:
             raise Http404 from e
         return obj
+
+    @action(detail=True, methods=["GET"])
+    def statistics(self, request, pk=None, *args, **kwargs):
+        article = Article.objects.get(id=pk)
+        user = request.user
+
+        if not user or not user.is_authenticated:
+            return Response(status=status.HTTP_401_UNAUTHORIZED)
+
+        permission_handler = get_permission_handler(Article)
+        user_has_edit_perm = permission_handler.has_object_permissions(
+            user, "statistics", article
+        )
+        if not user_has_edit_perm:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+
+        response = request_plausible_statistics(article)
+        return Response(response.json())
path: lego/apps/articles/views.py
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**Good implementation of the statistics endpoint, but add type annotations and error handling.**

The statistics endpoint properly checks authentication and permissions before returning statistics. A few improvements:

1. Add type annotations for better code quality
2. Add error handling for potential exceptions from the Plausible request
3. Add trailing comma for consistency


```diff
    @action(detail=True, methods=["GET"])
-   def statistics(self, request, pk=None, *args, **kwargs):
+   def statistics(self, request, pk=None, *args, **kwargs) -> Response:
        article = Article.objects.get(id=pk)
        user = request.user

        if not user or not user.is_authenticated:
            return Response(status=status.HTTP_401_UNAUTHORIZED)

        permission_handler = get_permission_handler(Article)
        user_has_edit_perm = permission_handler.has_object_permissions(
-           user, "statistics", article
+           user, "statistics", article,
        )
        if not user_has_edit_perm:
            return Response(status=status.HTTP_403_FORBIDDEN)

-       response = request_plausible_statistics(article)
-       return Response(response.json())
+       try:
+           response = request_plausible_statistics(article, None)
+           return Response(response.json())
+       except requests.exceptions.RequestException as e:
+           log.error("plausible_statistics_error", exception=e, article_id=article.id)
+           return Response(
+               {"error": "Failed to retrieve statistics"}, 
+               status=status.HTTP_503_SERVICE_UNAVAILABLE
+           )
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
    @action(detail=True, methods=["GET"])
    def statistics(self, request, pk=None, *args, **kwargs) -> Response:
        article = Article.objects.get(id=pk)
        user = request.user

        if not user or not user.is_authenticated:
            return Response(status=status.HTTP_401_UNAUTHORIZED)

        permission_handler = get_permission_handler(Article)
        user_has_edit_perm = permission_handler.has_object_permissions(
            user, "statistics", article,
        )
        if not user_has_edit_perm:
            return Response(status=status.HTTP_403_FORBIDDEN)

        try:
            response = request_plausible_statistics(article, None)
            return Response(response.json())
        except requests.exceptions.RequestException as e:
            log.error("plausible_statistics_error", exception=e, article_id=article.id)
            return Response(
                {"error": "Failed to retrieve statistics"}, 
                status=status.HTTP_503_SERVICE_UNAVAILABLE
            )
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

62-62: Missing return type annotation for public function `statistics`

(ANN201)

---

62-62: Missing type annotation for function argument `request`

(ANN001)

---

62-62: Missing type annotation for function argument `pk`

(ANN001)

---

62-62: Missing type annotation for `*args`

(ANN002)

---

62-62: Unused method argument: `args`

(ARG002)

---

62-62: Missing type annotation for `**kwargs`

(ANN003)

---

62-62: Unused method argument: `kwargs`

(ARG002)

---

71-71: Trailing comma missing

Add trailing comma

(COM812)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-21T15:29:16Z
author_association: NONE
diff_hunk: @@ -305,3 +317,65 @@ def test_only_one_pinned(self):
         self.assertTrue(self.article.pinned)
         self.assertFalse(pinned.pinned)
         self.assertEqual(Article.objects.filter(pinned=True).count(), 1)
+
+
+def generate_plausible_item(i):
+    return {
+        "bounce_rate": str(30),
+        "date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
+        "pageviews": str(i * 2),
+        "visit_duration": str(i * 10),
+        "visitors": str(i),
+    }
+
+
+number_of_plausible_items = 3
+
+
+def mocked_plausible_request(*args, **kwargs):
+    class MockPlausibleResponse:
+        def __init__(self, *args, **kwargs):
+            self.status_code = status.HTTP_200_OK
+            self.json_data = [
+                generate_plausible_item(i) for i in range(number_of_plausible_items)
+            ]
+
+        def json(self):
+            return self.json_data
+
+    return MockPlausibleResponse()
+
path: lego/apps/articles/tests/test_articles_api.py
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**Good mock implementation for testing, but consider using timezone-aware datetime.**

The helper functions for testing are well-implemented, but there's a small improvement for timezone consistency:


```diff
def generate_plausible_item(i):
    return {
        "bounce_rate": str(30),
-       "date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
+       "date": (datetime.now(timezone.utc) - timedelta(days=i)).strftime("%Y-%m-%d"),
        "pageviews": str(i * 2),
        "visit_duration": str(i * 10),
        "visitors": str(i),
    }
```

Also, add type annotations for better code quality:
```python
def generate_plausible_item(i: int) -> dict:
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

322-322: Missing return type annotation for public function `generate_plausible_item`

(ANN201)

---

322-322: Missing type annotation for function argument `i`

(ANN001)

---

325-325: `datetime.datetime.now()` called without a `tz` argument

(DTZ005)

---

335-335: Missing return type annotation for public function `mocked_plausible_request`

(ANN201)

---

335-335: Missing type annotation for `*args`

(ANN002)

---

335-335: Unused function argument: `args`

(ARG001)

---

335-335: Missing type annotation for `**kwargs`

(ANN003)

---

335-335: Unused function argument: `kwargs`

(ARG001)

---

337-337: Missing return type annotation for special method `__init__`

Add return type annotation: `None`

(ANN204)

---

337-337: Missing type annotation for `*args`

(ANN002)

---

337-337: Unused method argument: `args`

(ARG002)

---

337-337: Missing type annotation for `**kwargs`

(ANN003)

---

337-337: Unused method argument: `kwargs`

(ARG002)

---

343-343: Missing return type annotation for private function `json`

(ANN202)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-21T15:29:16Z
author_association: NONE
diff_hunk: @@ -52,3 +56,20 @@ def get_object(self) -> Article:
         except (NotAuthenticated, PermissionDenied) as e:
             raise Http404 from e
         return obj
+
+    @action(detail=True, methods=["GET"])
+    def statistics(self, request, pk=None, *args, **kwargs) -> Response:
+        article = Article.objects.get(id=pk)
+        user = request.user
+
+        if not user or not user.is_authenticated:
+            return Response(status=status.HTTP_401_UNAUTHORIZED)
+
+        has_perm = user.has_perm("statistics", article) or user.has_perm(
+            "edit", article
+        )
+        if not has_perm:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+
+        response = request_plausible_statistics(article)
+        return Response(response.json())
path: lego/apps/articles/views.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add error handling and improve permission checking.**

The statistics endpoint implementation has a few issues that need to be addressed:

1. Missing error handling for Article.DoesNotExist
2. No error handling for potential exceptions from request_plausible_statistics
3. Permission checking could be improved to use the permission handler consistently


```diff
@action(detail=True, methods=["GET"])
def statistics(self, request, pk=None, *args, **kwargs) -> Response:
-    article = Article.objects.get(id=pk)
+    try:
+        article = Article.objects.get(id=pk)
+    except Article.DoesNotExist:
+        return Response(status=status.HTTP_404_NOT_FOUND)
    user = request.user

    if not user or not user.is_authenticated:
        return Response(status=status.HTTP_401_UNAUTHORIZED)

-    has_perm = user.has_perm("statistics", article) or user.has_perm(
-        "edit", article
-    )
-    if not has_perm:
+    permission_handler = get_permission_handler(Article)
+    user_has_edit_perm = permission_handler.has_object_permissions(
+        user, "statistics", article,
+    ) or permission_handler.has_object_permissions(
+        user, "edit", article,
+    )
+    if not user_has_edit_perm:
        return Response(status=status.HTTP_403_FORBIDDEN)

-    response = request_plausible_statistics(article)
-    return Response(response.json())
+    try:
+        response = request_plausible_statistics(article)
+        return Response(response.json())
+    except Exception as e:
+        import logging
+        log = logging.getLogger(__name__)
+        log.error("plausible_statistics_error", exc_info=True, extra={"article_id": article.id})
+        return Response(
+            {"error": "Failed to retrieve statistics"},
+            status=status.HTTP_503_SERVICE_UNAVAILABLE
+        )
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
    @action(detail=True, methods=["GET"])
    def statistics(self, request, pk=None, *args, **kwargs) -> Response:
-        article = Article.objects.get(id=pk)
+        try:
+            article = Article.objects.get(id=pk)
+        except Article.DoesNotExist:
+            return Response(status=status.HTTP_404_NOT_FOUND)
        user = request.user

        if not user or not user.is_authenticated:
            return Response(status=status.HTTP_401_UNAUTHORIZED)

-        has_perm = user.has_perm("statistics", article) or user.has_perm(
-            "edit", article
-        )
-        if not has_perm:
+        permission_handler = get_permission_handler(Article)
+        user_has_edit_perm = permission_handler.has_object_permissions(
+            user, "statistics", article,
+        ) or permission_handler.has_object_permissions(
+            user, "edit", article,
+        )
+        if not user_has_edit_perm:
            return Response(status=status.HTTP_403_FORBIDDEN)

-        response = request_plausible_statistics(article)
-        return Response(response.json())
+        try:
+            response = request_plausible_statistics(article)
+            return Response(response.json())
+        except Exception as e:
+            import logging
+            log = logging.getLogger(__name__)
+            log.error("plausible_statistics_error", exc_info=True, extra={"article_id": article.id})
+            return Response(
+                {"error": "Failed to retrieve statistics"},
+                status=status.HTTP_503_SERVICE_UNAVAILABLE
+            )
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-23T16:37:01Z
author_association: NONE
diff_hunk: @@ -52,3 +56,20 @@ def get_object(self) -> Article:
         except (NotAuthenticated, PermissionDenied) as e:
             raise Http404 from e
         return obj
+
+    @action(detail=True, methods=["GET"])
+    def statistics(self, request, pk=None, *args, **kwargs) -> Response:
+        article = Article.objects.get(id=pk)
+        user = request.user
+
+        if not user or not user.is_authenticated:
+            return Response(status=status.HTTP_401_UNAUTHORIZED)
+
+        has_perm = user.has_perm("statistics", article) or user.has_perm(
+            "edit", article
+        )
+        if not has_perm:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+
+        response = request_plausible_statistics(article)
+        return Response(response.json())
path: lego/apps/articles/views.py
user: login: itsisak
body: actually no

created_at: 2025-03-23T16:38:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -52,3 +56,20 @@ def get_object(self) -> Article:
         except (NotAuthenticated, PermissionDenied) as e:
             raise Http404 from e
         return obj
+
+    @action(detail=True, methods=["GET"])
+    def statistics(self, request, pk=None, *args, **kwargs) -> Response:
+        article = Article.objects.get(id=pk)
+        user = request.user
+
+        if not user or not user.is_authenticated:
+            return Response(status=status.HTTP_401_UNAUTHORIZED)
+
+        has_perm = user.has_perm("statistics", article) or user.has_perm(
+            "edit", article
+        )
+        if not has_perm:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+
+        response = request_plausible_statistics(article)
+        return Response(response.json())
path: lego/apps/articles/views.py
user: login: coderabbitai[bot]
body: @itsisak Understood. I'll respect your decision to keep the implementation as is. If you'd like me to explain the reasoning behind any specific part of those suggestions or if you need help with anything else for this PR, just let me know.

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-23T16:38:54Z
author_association: NONE

PR comments:
user: login: linear[bot]
created_at: 2025-03-21T15:25:09Z
body: <p><a href="https://linear.app/abakus-webkom/issue/ABA-1375/create-statistics-api-endpoint">ABA-1375 Create statistics API endpoint</a></p>
author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-03-21T15:29:12Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough
The changes introduce a new endpoint for retrieving article statistics with appropriate authentication and permission checks in the `ArticlesViewSet`. Testing capabilities for this feature are enhanced with the addition of helper functions and a dedicated test case class. The `statistics` method in the `EventViewSet` is refactored to utilize a centralized utility function, `request_plausible_statistics`, which simplifies the process of fetching statistics data. This utility function is also newly defined to handle API requests for both articles and events.

## Changes
| File(s)                                      | Change Summary |
|----------------------------------------------|----------------|
| lego/.../articles/tests/test_articles_api.py | Added functions (`get_statistics_url`, `mock_plausible_response`) and a test case class (`ArticleStatisticsCase`) to validate the article statistics endpoint under various authentication and permission scenarios. |
| lego/.../articles/views.py                   | Updated `ArticlesViewSet` to change inheritance and add a new `statistics` action method that checks user authentication and permissions, calling `request_plausible_statistics` to retrieve article statistics. |
| lego/.../events/views.py                     | Refactored the `statistics` method in `EventViewSet` by replacing manual API request construction with a call to `request_plausible_statistics`, simplifying the control flow. |
| lego/.../utils/functions.py                  | Introduced the `request_plausible_statistics` function that constructs an API URL based on object attributes, sets up authorization headers, and performs a GET request to fetch statistics data. |

## Assessment against linked issues
| Objective                                          | Addressed | Explanation |
|---------------------------------------------------|-----------|-------------|
| Create statistics API endpoint (ABA-1375)         | ✅        |             |

</details>

<!-- walkthrough_end -->
<!-- internal state start -->


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

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=webkom/lego&utm_content=3798):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-24T09:29:30Z
body: ## [Codecov](https://app.codecov.io/gh/webkom/lego/pull/3798?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom) Report
Attention: Patch coverage is `94.00000%` with `6 lines` in your changes missing coverage. Please review.
> Project coverage is 89.97%. Comparing base [(`94cf036`)](https://app.codecov.io/gh/webkom/lego/commit/94cf036a726bc5c1df69aa1e3adc0ef577ddf011?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom) to head [(`40b944c`)](https://app.codecov.io/gh/webkom/lego/commit/40b944c57bc00199cecb09f8f9fc711768bf8dae?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom).
> Report is 10 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/webkom/lego/pull/3798?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom) | Patch % | Lines |
|---|---|---|
| [lego/utils/functions.py](https://app.codecov.io/gh/webkom/lego/pull/3798?src=pr&el=tree&filepath=lego%2Futils%2Ffunctions.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom#diff-bGVnby91dGlscy9mdW5jdGlvbnMucHk=) | 80.00% | [5 Missing :warning: ](https://app.codecov.io/gh/webkom/lego/pull/3798?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom) |
| [lego/apps/events/views.py](https://app.codecov.io/gh/webkom/lego/pull/3798?src=pr&el=tree&filepath=lego%2Fapps%2Fevents%2Fviews.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom#diff-bGVnby9hcHBzL2V2ZW50cy92aWV3cy5weQ==) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/webkom/lego/pull/3798?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #3798      +/-   ##
==========================================
+ Coverage   89.92%   89.97%   +0.04%     
==========================================
  Files         741      741              
  Lines       23985    24070      +85     
==========================================
+ Hits        21569    21656      +87     
+ Misses       2416     2414       -2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/webkom/lego/pull/3798?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=webkom).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: sentry[bot]
created_at: 2025-03-26T13:33:49Z
body: ## Suspect Issues
This pull request was deployed and Sentry observed the following issues:

- ‼️ **ValueError: Field 'id' expected a number but got '562-weekly-uke-13'.** `/api/v1/articles/{pk}/statistics/` [View Issue](https://abakus.sentry.io/issues/6462724022/?referrer=github-pr-bot)

<sub>Did you find this useful? React with a 👍 or 👎</sub>
author_association: NONE