Skip to content

Commit 4c0917b

Browse files
committed
Add API endpoints for project and repository events
This commit introduces two new API endpoints to fetch the latest events for a specific project and repository. These endpoints allow filtering by event type and date, and support pagination. Signed-off-by: Jose Javier Merchante <jjmerchante@bitergia.com>
1 parent c68b2e6 commit 4c0917b

8 files changed

Lines changed: 391 additions & 27 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
title: API endpoint for project and repository events
3+
category: added
4+
author: Jose Javier Merchante <jjmerchante@bitergia.com>
5+
issue: null
6+
notes: >
7+
Introduce two new API endpoints to fetch the latest events
8+
for a specific project and repository. These endpoints allow
9+
filtering by event type and date, and support pagination.

src/grimoirelab/core/datasources/api.py

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
response,
2525
serializers,
2626
status,
27+
views,
2728
)
2829
from drf_spectacular.utils import (
2930
extend_schema,
@@ -35,6 +36,8 @@
3536
from django.db.models import Q
3637
from django.conf import settings
3738
from django.shortcuts import get_object_or_404
39+
from grimoirelab_toolkit.datetime import str_to_datetime, InvalidDateError
40+
from rest_framework.exceptions import ValidationError
3841

3942
from .models import (
4043
DataSet,
@@ -43,6 +46,7 @@
4346
Project,
4447
)
4548
from .utils import generate_uuid
49+
from .events import get_events
4650
from ..scheduler.api import EventizerTaskSerializer
4751
from ..scheduler.scheduler import schedule_task, cancel_task
4852

@@ -287,7 +291,7 @@ def get_queryset(self):
287291
name=self.kwargs.get("project_name"),
288292
ecosystem__name=self.kwargs.get("ecosystem_name"),
289293
)
290-
queryset = Repository.objects.filter(dataset__project=project).distinct()
294+
queryset = Repository.objects.filter(dataset__project=project).distinct().order_by("pk")
291295

292296
datasource = self.request.query_params.get("datasource_type")
293297
category = self.request.query_params.get("category")
@@ -512,3 +516,135 @@ def get_serializer_context(self):
512516
context = super().get_serializer_context()
513517
context.update({"project_id": self.project.id})
514518
return context
519+
520+
521+
class ProjectEventList(views.APIView):
522+
"""API endpoint that allows to get the latest events for a given project."""
523+
524+
def get(self, request, ecosystem_name, project_name):
525+
event_type = request.query_params.get("type", None)
526+
from_date = request.query_params.get("from_date", None)
527+
to_date = request.query_params.get("to_date", None)
528+
page = request.query_params.get("page", 1)
529+
size = request.query_params.get("size", 25)
530+
531+
# Validate page and size parameters
532+
try:
533+
page = int(page)
534+
size = int(size)
535+
except ValueError:
536+
raise ValidationError("Page must be an integer.")
537+
if page < 1:
538+
raise ValidationError("Page must be greater than 0.")
539+
if size < 1 or size > 100:
540+
raise ValidationError("Size must be between 1 and 100.")
541+
542+
# Parse from_date and to_date
543+
from_date_parsed = None
544+
to_date_parsed = None
545+
try:
546+
if from_date:
547+
from_date_parsed = str_to_datetime(from_date)
548+
if to_date:
549+
to_date_parsed = str_to_datetime(to_date)
550+
except InvalidDateError:
551+
raise ValidationError("from_date and to_date must be in a valid datetime format.")
552+
553+
# Obtain the repository sources for the given project
554+
project = get_object_or_404(
555+
Project,
556+
name=project_name,
557+
ecosystem__name=ecosystem_name,
558+
)
559+
queryset = (
560+
Repository.objects.filter(dataset__project=project)
561+
.distinct()
562+
.values_list("uri", flat=True)
563+
)
564+
sources = list(queryset)
565+
566+
events = get_events(
567+
sources=sources,
568+
event_type=event_type,
569+
from_date=from_date_parsed,
570+
to_date=to_date_parsed,
571+
page=page,
572+
size=size,
573+
)
574+
total = events.hits.total.value
575+
576+
return response.Response(
577+
{
578+
"count": total,
579+
"page": page,
580+
"total_pages": (total + size - 1) // size,
581+
"results": [hit.to_dict() for hit in events],
582+
},
583+
status=status.HTTP_200_OK,
584+
)
585+
586+
587+
class RepoEventList(views.APIView):
588+
"""API endpoint that allows to get the latest events for a given repository."""
589+
590+
def get(self, request, ecosystem_name, project_name, uuid):
591+
event_type = request.query_params.get("type", None)
592+
from_date = request.query_params.get("from_date", None)
593+
to_date = request.query_params.get("to_date", None)
594+
page = request.query_params.get("page", 1)
595+
size = request.query_params.get("size", 25)
596+
597+
# Validate page and size parameters
598+
try:
599+
page = int(page)
600+
size = int(size)
601+
except ValueError:
602+
raise ValidationError("Page must be an integer.")
603+
if page < 1:
604+
raise ValidationError("Page must be greater than 0.")
605+
if size < 1 or size > 100:
606+
raise ValidationError("Size must be between 1 and 100.")
607+
608+
# Parse from_date and to_date
609+
from_date_parsed = None
610+
to_date_parsed = None
611+
try:
612+
if from_date:
613+
from_date_parsed = str_to_datetime(from_date)
614+
if to_date:
615+
to_date_parsed = str_to_datetime(to_date)
616+
except InvalidDateError:
617+
raise ValidationError("from_date and to_date must be in a valid datetime format.")
618+
619+
# Obtain the repository source for the given repository
620+
project = get_object_or_404(
621+
Project,
622+
name=project_name,
623+
ecosystem__name=ecosystem_name,
624+
)
625+
repository = get_object_or_404(
626+
Repository,
627+
uuid=uuid,
628+
dataset__project=project,
629+
)
630+
source = repository.uri
631+
632+
events = get_events(
633+
sources=[source],
634+
event_type=event_type,
635+
from_date=from_date_parsed,
636+
to_date=to_date_parsed,
637+
page=page,
638+
size=size,
639+
)
640+
total = events.hits.total.value
641+
642+
return response.Response(
643+
{
644+
"count": total,
645+
"page": page,
646+
"total_pages": (total + size - 1) // size,
647+
"results": [hit.to_dict() for hit in events],
648+
},
649+
status=status.HTTP_200_OK,
650+
)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import datetime
2+
3+
from opensearchpy import Search
4+
5+
from ..config import settings
6+
from ..utils.opensearch import get_opensearch_client
7+
8+
9+
def get_events(
10+
sources: list = None,
11+
event_type: str = None,
12+
from_date: datetime.datetime = None,
13+
to_date: datetime.datetime = None,
14+
page: int = 1,
15+
size: int = 25,
16+
) -> list:
17+
"""
18+
Retrieve events from OpenSearch with optional filtering and pagination.
19+
20+
:param sources: List of repository sources to filter by.
21+
:param event_type: Type of event to filter by.
22+
:param from_date: Start date for filtering events.
23+
:param to_date: End date for filtering events.
24+
:param page: Page number for pagination.
25+
:param size: Number of events per page.
26+
:return: OpenSearch response object containing the events.
27+
"""
28+
opensearch = get_opensearch_client()
29+
30+
index = settings.GRIMOIRELAB_ARCHIVIST["STORAGE_INDEX"]
31+
s = Search(using=opensearch, index=index)
32+
s = s.sort({"time": {"order": "asc"}}, {"id": {"order": "asc"}})
33+
34+
if sources:
35+
s = s.filter("terms", source=sources)
36+
37+
if event_type:
38+
s = s.filter("term", type=event_type)
39+
40+
if from_date or to_date:
41+
range_filter = {}
42+
if from_date:
43+
range_filter["gte"] = from_date
44+
if to_date:
45+
range_filter["lte"] = to_date
46+
s = s.filter("range", time=range_filter)
47+
48+
s = s[(page - 1) * size : page * size]
49+
50+
response = s.execute()
51+
52+
return response

src/grimoirelab/core/datasources/urls.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
api.ProjectDetail.as_view(),
3131
name="projects-detail",
3232
),
33+
path(
34+
"<str:ecosystem_name>/projects/<str:project_name>/events/",
35+
api.ProjectEventList.as_view(),
36+
name="project-events",
37+
),
3338
path(
3439
"<str:ecosystem_name>/projects/<str:project_name>/children/",
3540
api.ProjectChildrenList.as_view(),
@@ -45,6 +50,11 @@
4550
api.RepoDetail.as_view(),
4651
name="repo-detail",
4752
),
53+
path(
54+
"<str:ecosystem_name>/projects/<str:project_name>/repos/<str:uuid>/events/",
55+
api.RepoEventList.as_view(),
56+
name="repo-events",
57+
),
4858
path(
4959
"<str:ecosystem_name>/projects/<str:project_name>/repos/<str:uuid>/categories/<str:category>/",
5060
api.CategoryDetail.as_view(),

src/grimoirelab/core/runner/commands/run.py

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import time
2525
import typing
2626

27-
import certifi
2827
import click
2928
import django.core
3029
import django.core.wsgi
@@ -35,7 +34,8 @@
3534

3635
from django.conf import settings
3736
from django.db import connections, OperationalError
38-
from urllib3.util import create_urllib3_context
37+
38+
from grimoirelab.core.utils.opensearch import get_opensearch_client
3939

4040
if typing.TYPE_CHECKING:
4141
from click import Context
@@ -216,9 +216,7 @@ def _sleep_backoff(attempt: int) -> None:
216216
time.sleep(backoff)
217217

218218

219-
def _wait_opensearch_ready(
220-
url: str, username: str | None, password: str | None, index: str, verify_certs: bool
221-
) -> None:
219+
def _wait_opensearch_ready(index: str) -> None:
222220
"""Wait for OpenSearch to be available before starting"""
223221

224222
# The 'opensearch' library writes logs with the exceptions while
@@ -228,25 +226,10 @@ def _wait_opensearch_ready(
228226
os_logger = logging.getLogger("opensearch")
229227
os_logger.disabled = True
230228

231-
context = None
232-
if verify_certs:
233-
# Use certificates from the local system and certifi
234-
context = create_urllib3_context()
235-
context.load_default_certs()
236-
context.load_verify_locations(certifi.where())
237-
238-
auth = (username, password) if username and password else None
229+
client = get_opensearch_client()
239230

240231
for attempt in range(DEFAULT_MAX_RETRIES):
241232
try:
242-
client = opensearchpy.OpenSearch(
243-
hosts=[url],
244-
http_auth=auth,
245-
http_compress=True,
246-
verify_certs=verify_certs,
247-
ssl_context=context,
248-
ssl_show_warn=False,
249-
)
250233
client.search(index=index, size=0)
251234
break
252235
except opensearchpy.exceptions.NotFoundError:
@@ -341,11 +324,7 @@ def archivists(workers: int, verbose: bool, burst: bool):
341324
from grimoirelab.core.consumers.archivist import OpenSearchArchivistPool
342325

343326
_wait_opensearch_ready(
344-
settings.GRIMOIRELAB_ARCHIVIST["STORAGE_URL"],
345-
settings.GRIMOIRELAB_ARCHIVIST["STORAGE_USERNAME"],
346-
settings.GRIMOIRELAB_ARCHIVIST["STORAGE_PASSWORD"],
347327
settings.GRIMOIRELAB_ARCHIVIST["STORAGE_INDEX"],
348-
settings.GRIMOIRELAB_ARCHIVIST["STORAGE_VERIFY_CERT"],
349328
)
350329
_wait_redis_ready()
351330

src/grimoirelab/core/utils/__init__.py

Whitespace-only changes.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) GrimoireLab Contributors
4+
#
5+
# This program is free software; you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation; either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
#
18+
19+
import warnings
20+
21+
import certifi
22+
import urllib3
23+
from opensearchpy import OpenSearch
24+
from django.conf import settings
25+
from urllib3.util import create_urllib3_context
26+
27+
28+
def get_opensearch_client():
29+
url = settings.GRIMOIRELAB_ARCHIVIST["STORAGE_URL"]
30+
username = settings.GRIMOIRELAB_ARCHIVIST["STORAGE_USERNAME"]
31+
password = settings.GRIMOIRELAB_ARCHIVIST["STORAGE_PASSWORD"]
32+
verify_certs = settings.GRIMOIRELAB_ARCHIVIST["STORAGE_VERIFY_CERT"]
33+
34+
context = None
35+
if verify_certs:
36+
# Use certificates from the local system and certifi
37+
context = create_urllib3_context()
38+
context.load_default_certs()
39+
context.load_verify_locations(certifi.where())
40+
else:
41+
# Ignore SSL warnings if not verifying certificates
42+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
43+
warnings.filterwarnings("ignore", message=".*verify_certs.*")
44+
45+
auth = (username, password) if username and password else None
46+
47+
client = OpenSearch(
48+
hosts=[url],
49+
http_auth=auth,
50+
http_compress=True,
51+
verify_certs=verify_certs,
52+
ssl_context=context,
53+
ssl_show_warn=False,
54+
max_retries=3,
55+
retry_on_timeout=True,
56+
)
57+
58+
return client

0 commit comments

Comments
 (0)