2023-05-25 09:50:09 +00:00
|
|
|
from enum import Enum
|
|
|
|
from typing import Any, Optional
|
2022-08-26 02:02:16 +00:00
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
from django.db import IntegrityError, models, transaction
|
2022-11-21 06:33:54 +00:00
|
|
|
from django.template.defaultfilters import title
|
2023-05-25 09:50:09 +00:00
|
|
|
from rest_framework import status
|
|
|
|
from rest_framework.request import Request
|
|
|
|
from rest_framework.response import Response
|
|
|
|
from rest_framework.views import APIView
|
2022-11-21 06:33:54 +00:00
|
|
|
|
2022-11-21 17:22:28 +00:00
|
|
|
from frog_api.cache import (
|
2023-05-25 09:50:09 +00:00
|
|
|
get_entries_cache,
|
2022-11-21 17:22:28 +00:00
|
|
|
invalidate_entries_cache,
|
|
|
|
set_entries_cache,
|
|
|
|
)
|
2023-05-25 16:03:02 +00:00
|
|
|
from frog_api.exceptions import (
|
|
|
|
InvalidDataException,
|
|
|
|
EmptyCategoryException,
|
|
|
|
NonexistentCategoryException,
|
|
|
|
NonexistentProjectException,
|
|
|
|
NonexistentVersionException,
|
|
|
|
)
|
2023-05-25 09:50:09 +00:00
|
|
|
from frog_api.models import Category, Entry, Measure, Project, Version
|
2022-08-27 06:34:59 +00:00
|
|
|
from frog_api.serializers.model_serializers import EntrySerializer
|
2022-08-27 12:28:16 +00:00
|
|
|
from frog_api.serializers.request_serializers import CreateEntriesSerializer
|
2022-08-26 02:02:16 +00:00
|
|
|
from frog_api.views.common import (
|
|
|
|
get_category,
|
|
|
|
get_project,
|
|
|
|
get_version,
|
|
|
|
validate_api_key,
|
|
|
|
)
|
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
EntryT = dict[str, Any]
|
2022-08-28 00:54:05 +00:00
|
|
|
|
2022-08-26 02:02:16 +00:00
|
|
|
|
|
|
|
def get_latest_entry(
|
|
|
|
project_slug: str, version_slug: str, category_slug: str
|
2023-05-25 09:50:09 +00:00
|
|
|
) -> Optional[EntryT]:
|
2022-08-26 02:02:16 +00:00
|
|
|
project = get_project(project_slug)
|
|
|
|
version = get_version(version_slug, project)
|
|
|
|
category = get_category(category_slug, version)
|
|
|
|
|
|
|
|
entry = Entry.objects.filter(category=category).first()
|
|
|
|
|
|
|
|
if entry is None:
|
2023-05-25 09:50:09 +00:00
|
|
|
return None
|
2022-08-26 02:02:16 +00:00
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
return EntrySerializer(entry).data
|
|
|
|
|
|
|
|
|
2022-08-28 03:49:37 +00:00
|
|
|
def get_all_entries(
|
|
|
|
project_slug: str, version_slug: str, category_slug: str
|
2023-05-25 09:50:09 +00:00
|
|
|
) -> list[EntryT]:
|
2022-11-21 17:22:28 +00:00
|
|
|
data = get_entries_cache(project_slug, version_slug, category_slug)
|
|
|
|
if data:
|
|
|
|
return data # type: ignore
|
|
|
|
|
2022-08-28 03:49:37 +00:00
|
|
|
project = get_project(project_slug)
|
|
|
|
version = get_version(version_slug, project)
|
|
|
|
category = get_category(category_slug, version)
|
|
|
|
|
2022-11-21 17:22:28 +00:00
|
|
|
entries = Entry.objects.filter(category=category).prefetch_related("measures")
|
2022-08-28 03:49:37 +00:00
|
|
|
|
2022-11-21 17:22:28 +00:00
|
|
|
data = EntrySerializer(entries, many=True).data
|
|
|
|
set_entries_cache(project_slug, version_slug, category_slug, data)
|
|
|
|
return data # type: ignore
|
2022-08-28 03:49:37 +00:00
|
|
|
|
|
|
|
|
2022-08-26 02:02:16 +00:00
|
|
|
def get_versions_digest_for_project(project: Project) -> dict[Any, Any]:
|
|
|
|
versions = {}
|
|
|
|
for version in Version.objects.filter(project=project):
|
2023-05-25 09:50:09 +00:00
|
|
|
category_entries: dict[str, list[EntryT]] = {}
|
|
|
|
|
|
|
|
for category in Category.objects.filter(version=version):
|
|
|
|
entry = get_latest_entry(project.slug, version.slug, category.slug)
|
|
|
|
if entry is not None:
|
|
|
|
category_entries[category.slug] = [entry]
|
|
|
|
|
|
|
|
if len(category_entries) > 0:
|
|
|
|
versions[version.slug] = category_entries
|
|
|
|
|
2022-08-26 02:02:16 +00:00
|
|
|
return versions
|
|
|
|
|
|
|
|
|
|
|
|
class RootDataView(APIView):
|
|
|
|
"""
|
|
|
|
API endpoint that returns the most recent entry for overall progress of each version of each project.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def get(self, request: Request) -> Response:
|
|
|
|
"""
|
|
|
|
Return the most recent entry for overall progress of each version of each project.
|
|
|
|
"""
|
|
|
|
|
|
|
|
projects = {}
|
|
|
|
for project in Project.objects.all():
|
2022-08-28 00:54:05 +00:00
|
|
|
projects[project.slug] = get_versions_digest_for_project(project)
|
2022-08-26 02:02:16 +00:00
|
|
|
|
2022-08-28 00:54:05 +00:00
|
|
|
return Response(projects)
|
2022-08-26 02:02:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
class ProjectDataView(APIView):
|
|
|
|
"""
|
|
|
|
API endpoint that returns the most recent entry for each version of a project.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def get(self, request: Request, project_slug: str) -> Response:
|
|
|
|
"""
|
|
|
|
Return the most recent entry for overall progress for each version of a project.
|
|
|
|
"""
|
|
|
|
|
|
|
|
project = get_project(project_slug)
|
|
|
|
versions = get_versions_digest_for_project(project)
|
|
|
|
|
2022-08-28 00:54:05 +00:00
|
|
|
return Response({project_slug: versions})
|
2022-08-26 02:02:16 +00:00
|
|
|
|
|
|
|
|
2022-09-22 12:03:46 +00:00
|
|
|
def get_progress_shield(
|
|
|
|
request: Request, project_slug: str, version_slug: str, category_slug: str
|
|
|
|
) -> dict[str, Any]:
|
2023-05-25 16:03:02 +00:00
|
|
|
latest = get_latest_entry(project_slug, version_slug, category_slug)
|
2023-05-25 09:50:09 +00:00
|
|
|
|
2023-05-25 16:03:02 +00:00
|
|
|
if latest is None:
|
|
|
|
raise EmptyCategoryException(project_slug, version_slug, category_slug)
|
2023-05-25 09:50:09 +00:00
|
|
|
|
|
|
|
latest_measures = latest["measures"]
|
2022-09-22 12:03:46 +00:00
|
|
|
|
|
|
|
project = get_project(project_slug)
|
|
|
|
version = get_version(version_slug, project)
|
|
|
|
category = get_category(category_slug, version)
|
|
|
|
|
|
|
|
params = request.query_params
|
|
|
|
if not params:
|
2022-11-21 06:33:54 +00:00
|
|
|
raise InvalidDataException("No measure specified")
|
2022-09-22 12:03:46 +00:00
|
|
|
|
2022-11-21 06:33:54 +00:00
|
|
|
if "measure" in params:
|
2022-09-22 12:03:46 +00:00
|
|
|
measure = params["measure"]
|
2022-11-21 06:33:54 +00:00
|
|
|
else:
|
2022-09-22 12:03:46 +00:00
|
|
|
raise InvalidDataException("No measure specified")
|
2022-11-21 06:33:54 +00:00
|
|
|
if measure not in latest_measures:
|
|
|
|
raise InvalidDataException(f"Measure '{measure}' not found")
|
|
|
|
numerator = latest_measures[measure]
|
2022-09-22 12:03:46 +00:00
|
|
|
|
|
|
|
label = params.get(
|
|
|
|
"label",
|
|
|
|
" ".join(
|
|
|
|
[
|
|
|
|
version.name,
|
|
|
|
category.name,
|
2022-11-21 06:33:54 +00:00
|
|
|
title(str(measure)),
|
2022-09-22 12:03:46 +00:00
|
|
|
]
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
2022-11-21 06:33:54 +00:00
|
|
|
if "total" in params:
|
2022-09-22 12:03:46 +00:00
|
|
|
total = params["total"]
|
2022-11-21 06:33:54 +00:00
|
|
|
elif f"{measure}/total" in latest_measures:
|
|
|
|
total = f"{measure}/total"
|
|
|
|
else:
|
2022-09-22 12:03:46 +00:00
|
|
|
raise InvalidDataException("No total specified")
|
2022-11-21 06:33:54 +00:00
|
|
|
if total not in latest_measures:
|
|
|
|
raise InvalidDataException(f"Measure '{total}' not found")
|
|
|
|
denominator = latest_measures[total]
|
2022-09-22 12:03:46 +00:00
|
|
|
|
|
|
|
fraction = float(numerator) / float(denominator)
|
|
|
|
message = f"{fraction:.2%}"
|
|
|
|
|
2022-11-21 06:33:54 +00:00
|
|
|
color = params.get("color", "informational" if fraction < 1.0 else "success")
|
2022-09-22 12:03:46 +00:00
|
|
|
|
|
|
|
return {"schemaVersion": 1, "label": label, "message": message, "color": color}
|
|
|
|
|
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
class Mode(Enum):
|
|
|
|
LATEST = "latest"
|
|
|
|
ALL = "all"
|
|
|
|
SHIELD = "shield"
|
2022-09-22 12:03:46 +00:00
|
|
|
|
|
|
|
|
2022-08-26 02:02:16 +00:00
|
|
|
class VersionDataView(APIView):
|
|
|
|
"""
|
2022-08-28 12:38:14 +00:00
|
|
|
API endpoint that returns data for overall progress for a version of a project.
|
2022-08-26 02:02:16 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def create_entries(
|
|
|
|
req_data: dict[str, Any], project_slug: str, version_slug: str
|
|
|
|
) -> int:
|
2022-08-27 12:28:16 +00:00
|
|
|
request_ser = CreateEntriesSerializer(data=req_data)
|
|
|
|
request_ser.is_valid(raise_exception=True)
|
|
|
|
data = request_ser.data
|
|
|
|
|
2023-05-25 16:03:02 +00:00
|
|
|
try:
|
|
|
|
project = get_project(project_slug)
|
|
|
|
except NonexistentProjectException:
|
|
|
|
raise InvalidDataException(f"Project '{project_slug}' does not exist")
|
2022-08-26 02:02:16 +00:00
|
|
|
|
2022-08-27 12:28:16 +00:00
|
|
|
validate_api_key(data["api_key"], project)
|
2022-08-26 02:02:16 +00:00
|
|
|
|
2023-05-25 16:03:02 +00:00
|
|
|
try:
|
|
|
|
version = get_version(version_slug, project)
|
|
|
|
except NonexistentVersionException:
|
|
|
|
raise InvalidDataException(f"Version '{version_slug}' does not exist")
|
2022-08-26 02:02:16 +00:00
|
|
|
|
2022-08-27 12:28:16 +00:00
|
|
|
to_save: list[models.Model] = []
|
|
|
|
for entry in data["entries"]:
|
2022-08-26 02:02:16 +00:00
|
|
|
timestamp = entry["timestamp"]
|
|
|
|
git_hash = entry["git_hash"]
|
2022-08-27 12:28:16 +00:00
|
|
|
categories = entry["categories"]
|
|
|
|
for cat in categories:
|
2023-05-25 16:03:02 +00:00
|
|
|
try:
|
|
|
|
category = get_category(cat, version)
|
|
|
|
except NonexistentCategoryException:
|
|
|
|
raise InvalidDataException(f"Category '{cat}' does not exist")
|
2022-08-26 02:02:16 +00:00
|
|
|
|
|
|
|
entry = Entry(category=category, timestamp=timestamp, git_hash=git_hash)
|
|
|
|
|
|
|
|
to_save.append(entry)
|
|
|
|
|
2022-08-27 12:28:16 +00:00
|
|
|
for measure_type in categories[cat]:
|
|
|
|
value = categories[cat][measure_type]
|
2022-08-26 02:02:16 +00:00
|
|
|
if type(value) != int:
|
|
|
|
raise InvalidDataException(
|
2023-05-25 09:50:09 +00:00
|
|
|
f"{cat}:{measure_type} must be an integer, not {type(value): {value}}"
|
2022-08-26 02:02:16 +00:00
|
|
|
)
|
|
|
|
to_save.append(Measure(entry=entry, type=measure_type, value=value))
|
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
try:
|
|
|
|
with transaction.atomic():
|
|
|
|
for s in to_save:
|
|
|
|
s.save()
|
|
|
|
except IntegrityError as e:
|
|
|
|
raise InvalidDataException(f"Integrity error: {e}")
|
2022-08-26 02:02:16 +00:00
|
|
|
|
2022-11-21 17:22:28 +00:00
|
|
|
invalidate_entries_cache(project_slug, version_slug, data)
|
|
|
|
|
2022-08-26 02:02:16 +00:00
|
|
|
return len(to_save)
|
|
|
|
|
|
|
|
def get(self, request: Request, project_slug: str, version_slug: str) -> Response:
|
|
|
|
"""
|
|
|
|
Return the most recent entry for overall progress for a version of a project.
|
|
|
|
"""
|
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
mode_str = self.request.query_params.get("mode", Mode.LATEST.value)
|
2022-08-26 02:02:16 +00:00
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
try:
|
|
|
|
mode: Mode = Mode(mode_str)
|
|
|
|
except ValueError:
|
|
|
|
raise InvalidDataException(f"Invalid mode specified: {mode_str}")
|
2022-09-22 12:03:46 +00:00
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
project = get_project(project_slug)
|
|
|
|
version = get_version(version_slug, project)
|
2022-08-28 00:54:05 +00:00
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
categories_data: dict[str, list[EntryT]]
|
|
|
|
|
|
|
|
match mode:
|
|
|
|
case Mode.LATEST:
|
|
|
|
categories_data = {}
|
|
|
|
for category in Category.objects.filter(version=version):
|
|
|
|
entry = get_latest_entry(project_slug, version_slug, category.slug)
|
|
|
|
if entry is not None:
|
|
|
|
categories_data[category.slug] = [entry]
|
|
|
|
response_json = {project_slug: {version_slug: categories_data}}
|
|
|
|
return Response(response_json)
|
|
|
|
case Mode.ALL:
|
|
|
|
categories_data = {}
|
|
|
|
for category in Category.objects.filter(version=version):
|
|
|
|
entries = get_all_entries(project_slug, version_slug, category.slug)
|
|
|
|
categories_data[category.slug] = entries
|
|
|
|
response_json = {project_slug: {version_slug: categories_data}}
|
|
|
|
return Response(response_json)
|
|
|
|
case Mode.SHIELD:
|
|
|
|
raise InvalidDataException(
|
|
|
|
"Category must be specified for shield output"
|
|
|
|
)
|
2022-08-26 02:02:16 +00:00
|
|
|
|
|
|
|
def post(self, request: Request, project_slug: str, version_slug: str) -> Response:
|
|
|
|
result = VersionDataView.create_entries(
|
|
|
|
request.data, project_slug, version_slug
|
|
|
|
)
|
|
|
|
|
|
|
|
success_data = {
|
|
|
|
"result": "success",
|
|
|
|
"wrote": result,
|
|
|
|
}
|
|
|
|
|
|
|
|
return Response(success_data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
|
|
|
|
|
|
class CategoryDataView(APIView):
|
|
|
|
"""
|
2022-08-28 00:54:05 +00:00
|
|
|
API endpoint that returns data for a specific category and a version of a project.
|
2022-08-26 02:02:16 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
def get(
|
|
|
|
self, request: Request, project_slug: str, version_slug: str, category_slug: str
|
|
|
|
) -> Response:
|
|
|
|
"""
|
2022-08-28 00:54:05 +00:00
|
|
|
Return data for a specific category and a version of a project.
|
2022-08-26 02:02:16 +00:00
|
|
|
"""
|
|
|
|
|
2023-05-25 09:50:09 +00:00
|
|
|
mode_str = self.request.query_params.get("mode", Mode.LATEST.value)
|
|
|
|
|
|
|
|
try:
|
|
|
|
mode: Mode = Mode(mode_str)
|
|
|
|
except ValueError:
|
|
|
|
raise InvalidDataException(f"Invalid mode specified: {mode_str}")
|
|
|
|
|
|
|
|
match mode:
|
|
|
|
case Mode.LATEST:
|
2023-05-25 16:03:02 +00:00
|
|
|
entry = get_latest_entry(project_slug, version_slug, category_slug)
|
|
|
|
entries = [entry] if entry is not None else []
|
2023-05-25 09:50:09 +00:00
|
|
|
response_json = {project_slug: {version_slug: {category_slug: entries}}}
|
|
|
|
return Response(response_json)
|
|
|
|
case Mode.ALL:
|
|
|
|
entries = get_all_entries(project_slug, version_slug, category_slug)
|
|
|
|
response_json = {project_slug: {version_slug: {category_slug: entries}}}
|
|
|
|
return Response(response_json)
|
|
|
|
case Mode.SHIELD:
|
|
|
|
return Response(
|
|
|
|
get_progress_shield(
|
|
|
|
self.request, project_slug, version_slug, category_slug
|
|
|
|
)
|
2022-09-22 12:03:46 +00:00
|
|
|
)
|