parent
b45cb756c9
commit
19e625ad27
|
@ -5,14 +5,14 @@ on:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
jobs:
|
jobs:
|
||||||
full_test_and_build:
|
tests:
|
||||||
name: unit tests
|
name: tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v1
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: '3.10'
|
||||||
- uses: snok/install-poetry@v1
|
- uses: snok/install-poetry@v1
|
||||||
- run: poetry install
|
- run: poetry install
|
||||||
- name: Run backend tests
|
- name: Run backend tests
|
||||||
|
@ -22,10 +22,10 @@ jobs:
|
||||||
name: mypy
|
name: mypy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v1
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: '3.10'
|
||||||
- uses: snok/install-poetry@v1
|
- uses: snok/install-poetry@v1
|
||||||
- run: |-
|
- run: |-
|
||||||
poetry install
|
poetry install
|
||||||
|
@ -34,7 +34,11 @@ jobs:
|
||||||
name: black
|
name: black
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: psf/black@stable
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
src: "."
|
python-version: '3.10'
|
||||||
|
- uses: snok/install-poetry@v1
|
||||||
|
- run: |-
|
||||||
|
poetry install
|
||||||
|
poetry run black . --check
|
|
@ -1,7 +1,7 @@
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- Python >=3.9
|
- Python >=3.10
|
||||||
- [Poetry](https://python-poetry.org/docs/master/#installing-with-the-official-installer)
|
- [Poetry](https://python-poetry.org/docs/master/#installing-with-the-official-installer)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
64
GUIDE.md
64
GUIDE.md
|
@ -1,8 +1,8 @@
|
||||||
# Guide on how to monitor decomp progress using Frogress
|
# Guide on how to monitor decomp progress using frogress
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
It will guide you to onboard your decomp project to Frogress.
|
This guide will provide a flow for how to use frogress for your project.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
|
@ -12,11 +12,11 @@ sequenceDiagram
|
||||||
Contributor->>+CI: Trigger
|
Contributor->>+CI: Trigger
|
||||||
CI->>CI: Build
|
CI->>CI: Build
|
||||||
CI->>CI: Calculate progress
|
CI->>CI: Calculate progress
|
||||||
CI->>-Frogress: Upload progress
|
CI->>-frogress: Upload progress
|
||||||
Note over CI,Frogress: POST /data/project:/version:/
|
Note over CI,frogress: POST /data/project:/version:/
|
||||||
Monitor->>Frogress: Fetch progress
|
Monitor->>frogress: Fetch progress
|
||||||
Note over Monitor,Frogress: GET /data/project:/version:/?mode=all
|
Note over Monitor,frogress: GET /data/project:/version:/?mode=all
|
||||||
Frogress->>Monitor: Return progress
|
frogress->>Monitor: Return progress
|
||||||
Monitor->>Monitor: Render progress
|
Monitor->>Monitor: Render progress
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ sequenceDiagram
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Contact Frogress admin to create project and assign api_key
|
1. Contact frogress admin (Ethan) to add your project to the database and obtain an api_key
|
||||||
|
|
||||||
2. Create schema with `cli.py`
|
2. Create schema with `cli.py`
|
||||||
|
|
||||||
|
@ -45,9 +45,7 @@ sequenceDiagram
|
||||||
./cli.py create version fireemblem8 us
|
./cli.py create version fireemblem8 us
|
||||||
```
|
```
|
||||||
|
|
||||||
2.3 Create category (optional)
|
2.3 Create category
|
||||||
|
|
||||||
Default category: `default`
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Usage
|
# Usage
|
||||||
|
@ -56,7 +54,7 @@ sequenceDiagram
|
||||||
./cli.py create category fireemblem8 us default
|
./cli.py create category fireemblem8 us default
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Upload progress in CI
|
3. Configure CI to upload data on build
|
||||||
|
|
||||||
3.1 API
|
3.1 API
|
||||||
|
|
||||||
|
@ -83,36 +81,44 @@ sequenceDiagram
|
||||||
|
|
||||||
https://github.com/FireEmblemUniverse/fireemblem8u/pull/307
|
https://github.com/FireEmblemUniverse/fireemblem8u/pull/307
|
||||||
|
|
||||||
4. Supplement historical data (optional)
|
4. Supplement historical data (optional, one-time)
|
||||||
|
|
||||||
Calculate progress for historical commits and upload it to Frogress if you would like to draw historical curve.
|
Calculate progress for historical commits and upload it to frogress for the purpose of visualizing and tracking historical data
|
||||||
|
|
||||||
[Example](https://github.com/laqieer/fireemblem8u/blob/master/.github/workflows/supplement-progress.yml)
|
[Example](https://github.com/laqieer/fireemblem8u/blob/master/.github/workflows/supplement-progress.yml)
|
||||||
|
|
||||||
5. Prune duplicated data (optional)
|
5. Fetch project data
|
||||||
|
|
||||||
Background: https://github.com/decompals/frogress/issues/27
|
5.1 API
|
||||||
|
|
||||||
```
|
There are 3 "modes" for returning results: `all`, `latest`, and `shield`. The first two are pretty self explanatory, and `shield` can be used to generate badges for your repo's README.md (shields.io).
|
||||||
# Usage
|
|
||||||
./cli.py prune -h
|
|
||||||
# Example
|
|
||||||
./cli.py prune fireemblem8 us
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Fetch project data
|
|
||||||
|
|
||||||
6.1 API
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET https://progress.deco.mp/data/project:/version:/?mode=all
|
GET https://progress.deco.mp/data/project:/version:/?mode=all
|
||||||
```
|
```
|
||||||
|
```
|
||||||
|
GET https://progress.deco.mp/data/project:/version:/?mode=latest
|
||||||
|
```
|
||||||
|
Note that the category and measure are required for mode `shield`.
|
||||||
|
```
|
||||||
|
GET https://progress.deco.mp/data/project:/version:/category:/?mode=shield&measure=MEASURE
|
||||||
|
```
|
||||||
|
|
||||||
https://progress.deco.mp/data/fireemblem8/us/?mode=all
|
**Note:** Specifying a category in the request URL is optional for `all` and `latest` modes. If the category is not specified, results from all categories will be returned. However, for `shield`, it is necessary to specify the category and measure in the request url, as shown above.
|
||||||
|
|
||||||
6.2 Build a website
|
`all` example:
|
||||||
|
|
||||||
Build a website to render progress graph using a library such as [uPlot](https://github.com/leeoniya/uPlot) and [Chart.js](https://www.chartjs.org).
|
`https://progress.deco.mp/data/fireemblem8/us/?mode=all`
|
||||||
|
|
||||||
|
`shield` example:
|
||||||
|
|
||||||
|
`https://progress.deco.mp/data/dukezh/us/default/?mode=shield&measure=bytes`
|
||||||
|
|
||||||
|
5.2 Build a website
|
||||||
|
|
||||||
|
Build a website to display your progress!
|
||||||
|
|
||||||
|
You can use the "latest" mode to retrieve just the latest datapoint or render full graphs using a library such as [uPlot](https://github.com/leeoniya/uPlot) or [Chart.js](https://www.chartjs.org)
|
||||||
|
|
||||||
- https://pikmin.dev
|
- https://pikmin.dev
|
||||||
- https://axiodl.com
|
- https://axiodl.com
|
||||||
|
|
98
cli.py
98
cli.py
|
@ -83,93 +83,6 @@ def delete_category(args: argparse.Namespace) -> None:
|
||||||
print(response.text)
|
print(response.text)
|
||||||
|
|
||||||
|
|
||||||
def prune_entries(args: argparse.Namespace) -> None:
|
|
||||||
# Get entries
|
|
||||||
|
|
||||||
url = f"{domain}/data/{args.project}/{args.version}?mode=all"
|
|
||||||
|
|
||||||
debug("GET " + url)
|
|
||||||
|
|
||||||
response = requests.get(url)
|
|
||||||
print(response.text)
|
|
||||||
|
|
||||||
categories = response.json().get(args.project, {}).get(args.version, {})
|
|
||||||
|
|
||||||
if len(categories) == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Filter entries
|
|
||||||
|
|
||||||
filtered = {}
|
|
||||||
|
|
||||||
for category, entries in categories.items():
|
|
||||||
if len(entries) == 0:
|
|
||||||
continue
|
|
||||||
for entry in entries:
|
|
||||||
if entry["git_hash"] not in filtered:
|
|
||||||
filtered[entry["git_hash"]] = {
|
|
||||||
"git_hash": entry["git_hash"],
|
|
||||||
"timestamp": entry["timestamp"],
|
|
||||||
"categories": {},
|
|
||||||
}
|
|
||||||
if category not in filtered[entry["git_hash"]]["categories"]:
|
|
||||||
filtered[entry["git_hash"]]["categories"][category] = entry["measures"]
|
|
||||||
else:
|
|
||||||
for measure, value in entry["measures"].items():
|
|
||||||
if (
|
|
||||||
measure
|
|
||||||
not in filtered[entry["git_hash"]]["categories"][category]
|
|
||||||
):
|
|
||||||
filtered[entry["git_hash"]]["categories"][category][
|
|
||||||
measure
|
|
||||||
] = value
|
|
||||||
|
|
||||||
entries = list(filtered.values())
|
|
||||||
|
|
||||||
# Clear entries
|
|
||||||
|
|
||||||
for category in categories.keys():
|
|
||||||
# Delete categories
|
|
||||||
|
|
||||||
url = f"{domain}/projects/{args.project}/{args.version}/{category}/"
|
|
||||||
|
|
||||||
data = {"api_key": api_key}
|
|
||||||
|
|
||||||
debug("DELETE " + url)
|
|
||||||
|
|
||||||
response = requests.delete(url, json=data)
|
|
||||||
print(response.text)
|
|
||||||
|
|
||||||
# Recreate categories
|
|
||||||
|
|
||||||
data["name"] = category
|
|
||||||
|
|
||||||
debug("POST " + url)
|
|
||||||
|
|
||||||
response = requests.post(url, json=data)
|
|
||||||
print(response.text)
|
|
||||||
|
|
||||||
# Upload entries
|
|
||||||
|
|
||||||
url = f"{domain}/data/{args.project}/{args.version}/"
|
|
||||||
|
|
||||||
data = {"api_key": api_key, "entries": entries}
|
|
||||||
|
|
||||||
debug("POST " + url)
|
|
||||||
|
|
||||||
response = requests.post(url, json=data)
|
|
||||||
print(response.status_code, response.text)
|
|
||||||
|
|
||||||
# Check entries
|
|
||||||
|
|
||||||
url = f"{domain}/data/{args.project}/{args.version}?mode=all"
|
|
||||||
|
|
||||||
debug("GET " + url)
|
|
||||||
|
|
||||||
response = requests.get(url)
|
|
||||||
print(response.text)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
|
||||||
|
@ -235,17 +148,6 @@ def main() -> None:
|
||||||
delete_category_parser.add_argument("slug", help="the slug for the category")
|
delete_category_parser.add_argument("slug", help="the slug for the category")
|
||||||
delete_category_parser.set_defaults(func=delete_category)
|
delete_category_parser.set_defaults(func=delete_category)
|
||||||
|
|
||||||
# Prune entries
|
|
||||||
prune_parser = subparsers.add_parser(
|
|
||||||
"prune",
|
|
||||||
help="prune entries to remove duplicates",
|
|
||||||
)
|
|
||||||
prune_parser.add_argument(
|
|
||||||
"project", help="the project for which to prune duplicated entries"
|
|
||||||
)
|
|
||||||
prune_parser.add_argument("version", help="the slug for the version")
|
|
||||||
prune_parser.set_defaults(func=prune_entries)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
args.func(args)
|
args.func(args)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Generated by Django 4.2.1 on 2023-05-25 08:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"frog_api",
|
||||||
|
"0001_squashed_0009_alter_project_discord_alter_project_repository_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="entry",
|
||||||
|
name="category",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="frog_api.category"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="project",
|
||||||
|
name="discord",
|
||||||
|
field=models.URLField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="project",
|
||||||
|
name="repository",
|
||||||
|
field=models.URLField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="project",
|
||||||
|
name="website",
|
||||||
|
field=models.URLField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="entry",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("timestamp", "git_hash", "category"), name="unique entry"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -77,6 +77,11 @@ class Entry(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = "Entries"
|
verbose_name_plural = "Entries"
|
||||||
ordering = ["-timestamp"]
|
ordering = ["-timestamp"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["timestamp", "git_hash", "category"], name="unique entry"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
time_string = datetime.utcfromtimestamp(self.timestamp).strftime(
|
time_string = datetime.utcfromtimestamp(self.timestamp).strftime(
|
||||||
|
|
|
@ -41,12 +41,9 @@ class CreateCategoryTests(APITestCase):
|
||||||
self.assertEqual(Category.objects.count(), 2)
|
self.assertEqual(Category.objects.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class CreateEntriesTests(APITestCase):
|
SAMPLE_PROJECT_SLUG = "oot"
|
||||||
def test_create_entries(self) -> None:
|
SAMPLE_VERSION_SLUG = "us"
|
||||||
"""
|
SAMPLE_DATA = {
|
||||||
Ensure that the entry creation endpoint works
|
|
||||||
"""
|
|
||||||
create_json = {
|
|
||||||
"api_key": "test_key_123",
|
"api_key": "test_key_123",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
|
@ -70,13 +67,18 @@ class CreateEntriesTests(APITestCase):
|
||||||
"git_hash": "e788bfecbfb10afd4182332db99bb562ea75b1de",
|
"git_hash": "e788bfecbfb10afd4182332db99bb562ea75b1de",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateEntriesTests(APITestCase):
|
||||||
|
def create_project_metadata(self, project_slug: str, version_slug: str) -> None:
|
||||||
# Create a test Project, Version, and Categories
|
# Create a test Project, Version, and Categories
|
||||||
project = Project(slug="oot", name="Ocarina of Time", auth_key="test_key_123")
|
project = Project(
|
||||||
|
slug=project_slug, name="Ocarina of Time", auth_key="test_key_123"
|
||||||
|
)
|
||||||
project.save()
|
project.save()
|
||||||
|
|
||||||
version = Version(slug="us", name="US", project=project)
|
version = Version(slug=version_slug, name="US", project=project)
|
||||||
version.save()
|
version.save()
|
||||||
|
|
||||||
category1 = Category(slug="default", name="Default", version=version)
|
category1 = Category(slug="default", name="Default", version=version)
|
||||||
|
@ -85,14 +87,94 @@ class CreateEntriesTests(APITestCase):
|
||||||
category2 = Category(slug="actors", name="Actors", version=version)
|
category2 = Category(slug="actors", name="Actors", version=version)
|
||||||
category2.save()
|
category2.save()
|
||||||
|
|
||||||
|
def test_create_entries(self) -> None:
|
||||||
|
"""
|
||||||
|
Ensure that the entry creation endpoint works
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.create_project_metadata(SAMPLE_PROJECT_SLUG, SAMPLE_VERSION_SLUG)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("version-data", args=[project.slug, version.slug]),
|
reverse("version-data", args=[SAMPLE_PROJECT_SLUG, SAMPLE_VERSION_SLUG]),
|
||||||
create_json,
|
SAMPLE_DATA,
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Confirm we created the entries and that they are in the DB
|
# Confirm we created the entries and that they are in the DB
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
self.assertEqual(Entry.objects.count(), 2)
|
self.assertEqual(Entry.objects.count(), 2)
|
||||||
self.assertEqual(Measure.objects.count(), 10)
|
self.assertEqual(Measure.objects.count(), 10)
|
||||||
|
|
||||||
|
def test_create_duplicate_entries(self) -> None:
|
||||||
|
"""
|
||||||
|
Ensure that it's impossible to make duplicate entries
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.create_project_metadata(SAMPLE_PROJECT_SLUG, SAMPLE_VERSION_SLUG)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("version-data", args=[SAMPLE_PROJECT_SLUG, SAMPLE_VERSION_SLUG]),
|
||||||
|
SAMPLE_DATA,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Confirm we created the entries and that they are in the DB
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(Entry.objects.count(), 2)
|
||||||
|
self.assertEqual(Measure.objects.count(), 10)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("version-data", args=[SAMPLE_PROJECT_SLUG, SAMPLE_VERSION_SLUG]),
|
||||||
|
SAMPLE_DATA,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure we got a bad request code since we shouldn't be able to create duplicate entries
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(Entry.objects.count(), 2)
|
||||||
|
self.assertEqual(Measure.objects.count(), 10)
|
||||||
|
|
||||||
|
def test_atomicity(self) -> None:
|
||||||
|
"""
|
||||||
|
Ensure that if some entries fail to be created, none are created
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.create_project_metadata(SAMPLE_PROJECT_SLUG, SAMPLE_VERSION_SLUG)
|
||||||
|
|
||||||
|
yucky_data = {
|
||||||
|
"api_key": "test_key_123",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"categories": {
|
||||||
|
"actors": {
|
||||||
|
"code_matching": 103860,
|
||||||
|
"code_total": 4747584,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"timestamp": 1615435438,
|
||||||
|
"git_hash": "e788bfecbfb10afd4182332db99bb562ea75b1de",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"categories": {
|
||||||
|
"actors": {
|
||||||
|
"code_matching": 103860,
|
||||||
|
"code_total": 4747584,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"timestamp": 1615435438,
|
||||||
|
"git_hash": "e788bfecbfb10afd4182332db99bb562ea75b1de",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("version-data", args=[SAMPLE_PROJECT_SLUG, SAMPLE_VERSION_SLUG]),
|
||||||
|
yucky_data,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Confirm no entries or measures are created
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
self.assertEqual(Entry.objects.count(), 0)
|
||||||
|
self.assertEqual(Measure.objects.count(), 0)
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
from typing import Any
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from django.db import models
|
from django.db import IntegrityError, models, transaction
|
||||||
from django.template.defaultfilters import title
|
from django.template.defaultfilters import title
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from frog_api.cache import (
|
from frog_api.cache import (
|
||||||
|
get_entries_cache,
|
||||||
invalidate_entries_cache,
|
invalidate_entries_cache,
|
||||||
set_entries_cache,
|
set_entries_cache,
|
||||||
get_entries_cache,
|
|
||||||
)
|
)
|
||||||
from frog_api.exceptions import (
|
from frog_api.exceptions import InvalidDataException, NoEntriesException
|
||||||
InvalidDataException,
|
from frog_api.models import Category, Entry, Measure, Project, Version
|
||||||
NoEntriesException,
|
|
||||||
)
|
|
||||||
from frog_api.models import Entry, Measure, Project, Version
|
|
||||||
from frog_api.serializers.model_serializers import EntrySerializer
|
from frog_api.serializers.model_serializers import EntrySerializer
|
||||||
from frog_api.serializers.request_serializers import CreateEntriesSerializer
|
from frog_api.serializers.request_serializers import CreateEntriesSerializer
|
||||||
from frog_api.views.common import (
|
from frog_api.views.common import (
|
||||||
|
@ -21,18 +23,13 @@ from frog_api.views.common import (
|
||||||
get_version,
|
get_version,
|
||||||
validate_api_key,
|
validate_api_key,
|
||||||
)
|
)
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
|
|
||||||
DEFAULT_CATEGORY_SLUG = "default"
|
EntryT = dict[str, Any]
|
||||||
DEFAULT_CATEGORY_NAME = "Default"
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_entry(
|
def get_latest_entry(
|
||||||
project_slug: str, version_slug: str, category_slug: str
|
project_slug: str, version_slug: str, category_slug: str
|
||||||
) -> list[dict[str, Any]]:
|
) -> Optional[EntryT]:
|
||||||
project = get_project(project_slug)
|
project = get_project(project_slug)
|
||||||
version = get_version(version_slug, project)
|
version = get_version(version_slug, project)
|
||||||
category = get_category(category_slug, version)
|
category = get_category(category_slug, version)
|
||||||
|
@ -40,14 +37,23 @@ def get_latest_entry(
|
||||||
entry = Entry.objects.filter(category=category).first()
|
entry = Entry.objects.filter(category=category).first()
|
||||||
|
|
||||||
if entry is None:
|
if entry is None:
|
||||||
raise NoEntriesException(project_slug, version_slug, category_slug)
|
return None
|
||||||
|
|
||||||
return [EntrySerializer(entry).data]
|
return EntrySerializer(entry).data
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_entry_throw(
|
||||||
|
project_slug: str, version_slug: str, category_slug: str
|
||||||
|
) -> EntryT:
|
||||||
|
entry = get_latest_entry(project_slug, version_slug, category_slug)
|
||||||
|
if entry is None:
|
||||||
|
raise NoEntriesException(project_slug, version_slug, category_slug)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def get_all_entries(
|
def get_all_entries(
|
||||||
project_slug: str, version_slug: str, category_slug: str
|
project_slug: str, version_slug: str, category_slug: str
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[EntryT]:
|
||||||
data = get_entries_cache(project_slug, version_slug, category_slug)
|
data = get_entries_cache(project_slug, version_slug, category_slug)
|
||||||
if data:
|
if data:
|
||||||
return data # type: ignore
|
return data # type: ignore
|
||||||
|
@ -66,10 +72,16 @@ def get_all_entries(
|
||||||
def get_versions_digest_for_project(project: Project) -> dict[Any, Any]:
|
def get_versions_digest_for_project(project: Project) -> dict[Any, Any]:
|
||||||
versions = {}
|
versions = {}
|
||||||
for version in Version.objects.filter(project=project):
|
for version in Version.objects.filter(project=project):
|
||||||
category_slug = DEFAULT_CATEGORY_SLUG
|
category_entries: dict[str, list[EntryT]] = {}
|
||||||
entry = get_latest_entry(project.slug, version.slug, category_slug)
|
|
||||||
|
for category in Category.objects.filter(version=version):
|
||||||
|
entry = get_latest_entry(project.slug, version.slug, category.slug)
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
versions[version.slug] = {"default": entry}
|
category_entries[category.slug] = [entry]
|
||||||
|
|
||||||
|
if len(category_entries) > 0:
|
||||||
|
versions[version.slug] = category_entries
|
||||||
|
|
||||||
return versions
|
return versions
|
||||||
|
|
||||||
|
|
||||||
|
@ -109,8 +121,11 @@ class ProjectDataView(APIView):
|
||||||
def get_progress_shield(
|
def get_progress_shield(
|
||||||
request: Request, project_slug: str, version_slug: str, category_slug: str
|
request: Request, project_slug: str, version_slug: str, category_slug: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
latest = get_latest_entry(project_slug, version_slug, category_slug)
|
latest = get_latest_entry_throw(project_slug, version_slug, category_slug)
|
||||||
latest_measures = latest[0]["measures"]
|
|
||||||
|
assert latest is not None
|
||||||
|
|
||||||
|
latest_measures = latest["measures"]
|
||||||
|
|
||||||
project = get_project(project_slug)
|
project = get_project(project_slug)
|
||||||
version = get_version(version_slug, project)
|
version = get_version(version_slug, project)
|
||||||
|
@ -157,7 +172,10 @@ def get_progress_shield(
|
||||||
return {"schemaVersion": 1, "label": label, "message": message, "color": color}
|
return {"schemaVersion": 1, "label": label, "message": message, "color": color}
|
||||||
|
|
||||||
|
|
||||||
VALID_MODES: set[str] = {"latest", "all", "shield"}
|
class Mode(Enum):
|
||||||
|
LATEST = "latest"
|
||||||
|
ALL = "all"
|
||||||
|
SHIELD = "shield"
|
||||||
|
|
||||||
|
|
||||||
class VersionDataView(APIView):
|
class VersionDataView(APIView):
|
||||||
|
@ -195,12 +213,16 @@ class VersionDataView(APIView):
|
||||||
value = categories[cat][measure_type]
|
value = categories[cat][measure_type]
|
||||||
if type(value) != int:
|
if type(value) != int:
|
||||||
raise InvalidDataException(
|
raise InvalidDataException(
|
||||||
f"{cat}:{measure_type} must be an integer"
|
f"{cat}:{measure_type} must be an integer, not {type(value): {value}}"
|
||||||
)
|
)
|
||||||
to_save.append(Measure(entry=entry, type=measure_type, value=value))
|
to_save.append(Measure(entry=entry, type=measure_type, value=value))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
for s in to_save:
|
for s in to_save:
|
||||||
s.save()
|
s.save()
|
||||||
|
except IntegrityError as e:
|
||||||
|
raise InvalidDataException(f"Integrity error: {e}")
|
||||||
|
|
||||||
invalidate_entries_cache(project_slug, version_slug, data)
|
invalidate_entries_cache(project_slug, version_slug, data)
|
||||||
|
|
||||||
|
@ -211,26 +233,38 @@ class VersionDataView(APIView):
|
||||||
Return the most recent entry for overall progress for a version of a project.
|
Return the most recent entry for overall progress for a version of a project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
category_slug = DEFAULT_CATEGORY_SLUG
|
mode_str = self.request.query_params.get("mode", Mode.LATEST.value)
|
||||||
|
|
||||||
mode = self.request.query_params.get("mode", "latest")
|
try:
|
||||||
if mode not in VALID_MODES:
|
mode: Mode = Mode(mode_str)
|
||||||
raise InvalidDataException(f"Invalid mode specified: {mode}")
|
except ValueError:
|
||||||
|
raise InvalidDataException(f"Invalid mode specified: {mode_str}")
|
||||||
|
|
||||||
if mode == "latest":
|
project = get_project(project_slug)
|
||||||
entries = get_latest_entry(project_slug, version_slug, category_slug)
|
version = get_version(version_slug, project)
|
||||||
elif mode == "all":
|
|
||||||
entries = get_all_entries(project_slug, version_slug, category_slug)
|
|
||||||
elif mode == "shield":
|
|
||||||
return Response(
|
|
||||||
get_progress_shield(
|
|
||||||
self.request, project_slug, version_slug, category_slug
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
response_json = {project_slug: {version_slug: {category_slug: entries}}}
|
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)
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
def post(self, request: Request, project_slug: str, version_slug: str) -> Response:
|
def post(self, request: Request, project_slug: str, version_slug: str) -> Response:
|
||||||
result = VersionDataView.create_entries(
|
result = VersionDataView.create_entries(
|
||||||
|
@ -257,21 +291,27 @@ class CategoryDataView(APIView):
|
||||||
Return data for a specific category and a version of a project.
|
Return data for a specific category and a version of a project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mode = self.request.query_params.get("mode", "latest")
|
mode_str = self.request.query_params.get("mode", Mode.LATEST.value)
|
||||||
if mode not in VALID_MODES:
|
|
||||||
raise InvalidDataException(f"Invalid mode specified: {mode}")
|
|
||||||
|
|
||||||
if mode == "latest":
|
try:
|
||||||
entries = get_latest_entry(project_slug, version_slug, category_slug)
|
mode: Mode = Mode(mode_str)
|
||||||
elif mode == "all":
|
except ValueError:
|
||||||
|
raise InvalidDataException(f"Invalid mode specified: {mode_str}")
|
||||||
|
|
||||||
|
match mode:
|
||||||
|
case Mode.LATEST:
|
||||||
|
entries = [
|
||||||
|
get_latest_entry_throw(project_slug, version_slug, category_slug)
|
||||||
|
]
|
||||||
|
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)
|
entries = get_all_entries(project_slug, version_slug, category_slug)
|
||||||
elif mode == "shield":
|
response_json = {project_slug: {version_slug: {category_slug: entries}}}
|
||||||
|
return Response(response_json)
|
||||||
|
case Mode.SHIELD:
|
||||||
return Response(
|
return Response(
|
||||||
get_progress_shield(
|
get_progress_shield(
|
||||||
self.request, project_slug, version_slug, category_slug
|
self.request, project_slug, version_slug, category_slug
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
response_json = {project_slug: {version_slug: {category_slug: entries}}}
|
|
||||||
|
|
||||||
return Response(response_json)
|
|
||||||
|
|
|
@ -19,8 +19,6 @@ from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from frog_api.views.data import DEFAULT_CATEGORY_NAME, DEFAULT_CATEGORY_SLUG
|
|
||||||
|
|
||||||
|
|
||||||
class RootStructureView(APIView):
|
class RootStructureView(APIView):
|
||||||
def get(self, request: Request, format: Any = None) -> Response:
|
def get(self, request: Request, format: Any = None) -> Response:
|
||||||
|
@ -76,14 +74,6 @@ class VersionStructureView(APIView):
|
||||||
name=request_ser.data["name"],
|
name=request_ser.data["name"],
|
||||||
)
|
)
|
||||||
version.save()
|
version.save()
|
||||||
|
|
||||||
# Create the default category
|
|
||||||
default_cat = Category(
|
|
||||||
version=version,
|
|
||||||
slug=DEFAULT_CATEGORY_SLUG,
|
|
||||||
name=DEFAULT_CATEGORY_NAME,
|
|
||||||
)
|
|
||||||
default_cat.save()
|
|
||||||
return Response(status=status.HTTP_201_CREATED)
|
return Response(status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
|
|
2
mypy.ini
2
mypy.ini
|
@ -1,6 +1,6 @@
|
||||||
[mypy]
|
[mypy]
|
||||||
# The mypy configurations: https://mypy.readthedocs.io/en/latest/config_file.html
|
# The mypy configurations: https://mypy.readthedocs.io/en/latest/config_file.html
|
||||||
python_version = 3.9
|
python_version = 3.10
|
||||||
|
|
||||||
check_untyped_defs = True
|
check_untyped_defs = True
|
||||||
disallow_any_generics = True
|
disallow_any_generics = True
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "frogress"
|
name = "frogress"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "Progress API for decompilation projects"
|
description = "Progress API for decompilation projects"
|
||||||
authors = ["Ethan Roseman <ethteck@gmail.com>"]
|
authors = ["Ethan Roseman <ethteck@gmail.com>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
Loading…
Reference in New Issue