Skip to content

Commit e225dc7

Browse files
[IMPROVEMENT] Initialize v2 API #731
2 parents d10af02 + c7992ba commit e225dc7

20 files changed

Lines changed: 4437 additions & 12 deletions

File tree

docker/requirements.txt

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ async-timeout==4.0.2
1212
# via redis
1313
atomicwrites==1.4.1
1414
# via promgen (pyproject.toml)
15+
attrs==26.1.0
16+
# via
17+
# jsonschema
18+
# referencing
1519
billiard==3.6.4.0
1620
# via celery
1721
celery[redis]==5.2.7
@@ -49,6 +53,7 @@ django==4.2.26
4953
# django-filter
5054
# django-guardian
5155
# djangorestframework
56+
# drf-spectacular
5257
# promgen (pyproject.toml)
5358
# social-auth-app-django
5459
django-environ==0.10.0
@@ -58,11 +63,21 @@ django-filter==23.2
5863
django-guardian==3.0.0
5964
# via promgen (pyproject.toml)
6065
djangorestframework==3.14.0
66+
# via
67+
# drf-spectacular
68+
# promgen (pyproject.toml)
69+
drf-spectacular==0.29.0
6170
# via promgen (pyproject.toml)
6271
gunicorn==22.0.0
6372
# via -r docker/requirements.in
6473
idna==3.7
6574
# via requests
75+
inflection==0.5.1
76+
# via drf-spectacular
77+
jsonschema==4.25.1
78+
# via drf-spectacular
79+
jsonschema-specifications==2025.9.1
80+
# via jsonschema
6681
kombu==5.3.7
6782
# via
6883
# celery
@@ -90,16 +105,26 @@ pytz==2023.3
90105
# celery
91106
# djangorestframework
92107
pyyaml==6.0.1
93-
# via promgen (pyproject.toml)
108+
# via
109+
# drf-spectacular
110+
# promgen (pyproject.toml)
94111
redis==4.6.0
95112
# via celery
113+
referencing==0.36.2
114+
# via
115+
# jsonschema
116+
# jsonschema-specifications
96117
requests==2.32.2
97118
# via
98119
# promgen (pyproject.toml)
99120
# requests-oauthlib
100121
# social-auth-core
101122
requests-oauthlib==1.3.1
102123
# via social-auth-core
124+
rpds-py==0.27.1
125+
# via
126+
# jsonschema
127+
# referencing
103128
sentry-sdk==1.19.1
104129
# via -r docker/requirements.in
105130
six==1.16.0
@@ -113,7 +138,11 @@ sqlparse==0.5.0
113138
typing-extensions==4.7.1
114139
# via
115140
# asgiref
141+
# drf-spectacular
116142
# kombu
143+
# referencing
144+
uritemplate==4.2.0
145+
# via drf-spectacular
117146
urllib3==2.2.0
118147
# via
119148
# requests

promgen/filters.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import django_filters
2+
from django.contrib.contenttypes.models import ContentType
23

34

45
class ShardFilter(django_filters.rest_framework.FilterSet):
@@ -24,3 +25,62 @@ class RuleFilter(django_filters.rest_framework.FilterSet):
2425
class FarmFilter(django_filters.rest_framework.FilterSet):
2526
name = django_filters.CharFilter(field_name="name", lookup_expr="contains")
2627
source = django_filters.CharFilter(field_name="source", lookup_expr="exact")
28+
29+
30+
def filter_content_type(queryset, name, value):
31+
try:
32+
if value == "group":
33+
content_type_id = ContentType.objects.get(model=value, app_label="auth").id
34+
else:
35+
content_type_id = ContentType.objects.get(model=value, app_label="promgen").id
36+
37+
field_name = (
38+
"parent_content_type_id" if name == "parent_content_type" else "content_type_id"
39+
)
40+
return queryset.filter(**{field_name: content_type_id})
41+
except ContentType.DoesNotExist:
42+
return queryset.none()
43+
44+
45+
class AuditFilter(django_filters.rest_framework.FilterSet):
46+
object_id = django_filters.NumberFilter(
47+
field_name="object_id",
48+
lookup_expr="exact",
49+
help_text="Filter by exact object ID. Example: object_id=123",
50+
)
51+
content_type = django_filters.ChoiceFilter(
52+
field_name="content_type",
53+
choices=[
54+
("exporter", "Exporter"),
55+
("farm", "Farm"),
56+
("group", "Group"),
57+
("host", "Host"),
58+
("project", "Project"),
59+
("rule", "Rule"),
60+
("sender", "Notifier"),
61+
("service", "Service"),
62+
("url", "URL"),
63+
],
64+
method=filter_content_type,
65+
help_text="Filter by content type model name. Example: content_type=service",
66+
)
67+
user = django_filters.CharFilter(
68+
field_name="user__username",
69+
lookup_expr="exact",
70+
help_text="Filter by exact owner username. Example: owner=Example Owner",
71+
)
72+
parent_object_id = django_filters.NumberFilter(
73+
field_name="parent_object_id",
74+
lookup_expr="exact",
75+
help_text="Filter by exact parent object ID. Example: parent_object_id=123",
76+
)
77+
parent_content_type = django_filters.ChoiceFilter(
78+
field_name="parent_content_type",
79+
choices=[
80+
("group", "Group"),
81+
("project", "Project"),
82+
("service", "Service"),
83+
],
84+
method=filter_content_type,
85+
help_text="Filter by parent content type model name. Example: parent_content_type=service",
86+
)

promgen/fixtures/testcases.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@
1919
password: demo
2020
email: demo@example.com
2121
is_active: true
22+
- model: authtoken.token
23+
pk: 1
24+
fields:
25+
user: 1
26+
key: admin_token
27+
created: 2024-03-18T00:00:00Z
28+
- model: authtoken.token
29+
pk: 2
30+
fields:
31+
user: 2
32+
key: demo_token
33+
created: 2024-03-18T00:00:00Z
2234
- model: promgen.shard
2335
pk: 1
2436
fields:
@@ -79,3 +91,19 @@
7991
project: 1
8092
probe: 1
8193
url: probe.example.com
94+
- model: promgen.audit
95+
pk: 1
96+
fields:
97+
content_type: ["promgen", "service"]
98+
object_id: 1
99+
body: "Created test-service"
100+
user: 1
101+
created: 2024-03-19T00:00:00Z
102+
- model: promgen.audit
103+
pk: 2
104+
fields:
105+
content_type: ["promgen", "project"]
106+
object_id: 1
107+
body: "Updated test-project"
108+
user: 2
109+
created: 2024-03-19T01:00:00Z

promgen/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,10 @@ def highlight(self):
625625
return "danger"
626626
return ""
627627

628+
@property
629+
def parent_content_type(self) -> ContentType:
630+
return ContentType.objects.get_for_id(self.parent_content_type_id)
631+
628632
@staticmethod
629633
def get_parent(obj):
630634
# The variable's name of the parent object is different depending on the model.

promgen/permissions.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.utils.itercompat import is_iterable
66
from guardian.shortcuts import get_objects_for_user
77
from rest_framework import permissions
8-
from rest_framework.permissions import BasePermission
8+
from rest_framework.permissions import SAFE_METHODS, BasePermission
99

1010
from promgen import models
1111

@@ -197,3 +197,39 @@ def get_highest_role(user: User, obj):
197197
return role
198198

199199
return None
200+
201+
202+
class PromgenGuardianRestPermission(BasePermission):
203+
PERMISSION_MANAGEMENT_ACTIONS = ["assign_user", "remove_user", "assign_group", "remove_group"]
204+
205+
def has_permission(self, request, view):
206+
return bool(request.user and request.user.is_authenticated)
207+
208+
def has_object_permission(self, request, view, obj):
209+
if view.action in self.PERMISSION_MANAGEMENT_ACTIONS:
210+
perms = ["project_admin", "service_admin"]
211+
elif request.method == "DELETE" and isinstance(obj, (models.Project, models.Service)):
212+
perms = ["project_admin", "service_admin"]
213+
elif request.method not in SAFE_METHODS:
214+
perms = [
215+
"project_editor",
216+
"project_admin",
217+
"service_editor",
218+
"service_admin",
219+
"group_admin",
220+
]
221+
else:
222+
# Always allow user to view the site rule
223+
if isinstance(obj, models.Rule) and isinstance(obj.content_object, models.Site):
224+
return True
225+
perms = [
226+
"project_viewer",
227+
"project_editor",
228+
"project_admin",
229+
"service_viewer",
230+
"service_editor",
231+
"service_admin",
232+
"group_member",
233+
"group_admin",
234+
]
235+
return has_perm(request.user, perms, obj)

promgen/rest_v2.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Copyright (c) 2026 LINE Corporation
2+
# These sources are released under the terms of the MIT license: see LICENSE
3+
4+
from django.contrib.contenttypes.models import ContentType
5+
from django.db.models import Q
6+
from django.urls import re_path
7+
from drf_spectacular.utils import extend_schema, extend_schema_view
8+
from drf_spectacular.views import SpectacularAPIView
9+
from rest_framework import mixins, pagination, routers, viewsets
10+
from rest_framework.authtoken.models import Token
11+
from rest_framework.renderers import TemplateHTMLRenderer
12+
from rest_framework.response import Response
13+
from rest_framework.views import APIView
14+
15+
from promgen import filters, models, permissions, serializers
16+
17+
18+
class SpectacularRapiDocView(APIView):
19+
renderer_classes = [TemplateHTMLRenderer]
20+
template_name = "rest_framework/api_v2.html"
21+
22+
@extend_schema(exclude=True)
23+
def get(self, request):
24+
api_token = Token.objects.filter(user=self.request.user).first()
25+
return Response(
26+
data={"api_token": api_token},
27+
template_name=self.template_name,
28+
)
29+
30+
31+
class Router(routers.DefaultRouter):
32+
include_root_view = False
33+
34+
def get_urls(self):
35+
urls = super().get_urls()
36+
37+
urls.append(
38+
re_path(
39+
rf"^schema{self.trailing_slash}$",
40+
SpectacularAPIView.as_view(),
41+
name="schema",
42+
)
43+
)
44+
45+
urls.append(
46+
re_path(
47+
rf"^docs{self.trailing_slash}$",
48+
SpectacularRapiDocView.as_view(),
49+
name="docs",
50+
)
51+
)
52+
53+
return urls
54+
55+
56+
class PromgenPagination(pagination.PageNumberPagination):
57+
page_query_param = "page_number"
58+
page_size_query_param = "page_size"
59+
page_size = 10
60+
max_page_size = 1000
61+
62+
def __init__(self):
63+
super().__init__()
64+
self.page_query_description = self.page_query_description + " Starts from 1."
65+
self.page_size_query_description = self.page_size_query_description + str.format(
66+
" Defaults to {}.", self.page_size
67+
)
68+
69+
70+
@extend_schema_view(
71+
list=extend_schema(summary="List Audit Logs", description="Retrieve a list of all audit logs."),
72+
)
73+
@extend_schema(tags=["Log"])
74+
class AuditViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
75+
queryset = models.Audit.objects.all().order_by("-created")
76+
filterset_class = filters.AuditFilter
77+
serializer_class = serializers.AuditSerializer
78+
lookup_value_regex = "[^/]+"
79+
lookup_field = "id"
80+
pagination_class = PromgenPagination
81+
permission_classes = [permissions.PromgenGuardianRestPermission]
82+
83+
def get_queryset(self):
84+
if self.request.user.is_superuser or self.action != "list":
85+
return self.queryset
86+
services = permissions.get_accessible_services_for_user(self.request.user)
87+
projects = permissions.get_accessible_projects_for_user(self.request.user)
88+
groups = permissions.get_accessible_groups_for_user(self.request.user)
89+
service_ct = ContentType.objects.get_for_model(models.Service)
90+
project_ct = ContentType.objects.get_for_model(models.Project)
91+
group_ct = ContentType.objects.get_for_model(models.Group)
92+
return self.queryset.filter(
93+
Q(
94+
content_type__model="service",
95+
content_type__app_label="promgen",
96+
object_id__in=services,
97+
)
98+
| Q(
99+
content_type__model="project",
100+
content_type__app_label="promgen",
101+
object_id__in=projects,
102+
)
103+
| Q(
104+
content_type__model="group",
105+
content_type__app_label="auth",
106+
object_id__in=groups,
107+
)
108+
| Q(parent_content_type_id=service_ct.id, parent_object_id__in=services)
109+
| Q(parent_content_type_id=project_ct.id, parent_object_id__in=projects)
110+
| Q(parent_content_type_id=group_ct.id, parent_object_id__in=groups)
111+
)

promgen/schemas.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from drf_spectacular.openapi import AutoSchema
2+
3+
4+
class CustomSchema(AutoSchema):
5+
def is_excluded(self):
6+
return not self.path.startswith("/rest/v2/") or self.path.startswith("/rest/v2/schema/")

promgen/serializers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,26 @@ def validate(self, data):
162162
raise errors.SilenceError.STARTENDMISMATCH.error()
163163

164164
return data
165+
166+
167+
class AuditSerializer(serializers.ModelSerializer):
168+
user = serializers.ReadOnlyField(source="user.username")
169+
log = serializers.ReadOnlyField(source="body")
170+
content_type = serializers.ReadOnlyField(source="content_type.model")
171+
new = serializers.ReadOnlyField(source="data")
172+
parent_content_type = serializers.ReadOnlyField(source="parent_content_type.model")
173+
174+
class Meta:
175+
model = models.Audit
176+
fields = (
177+
"id",
178+
"user",
179+
"content_type",
180+
"object_id",
181+
"log",
182+
"created",
183+
"new",
184+
"old",
185+
"parent_content_type",
186+
"parent_object_id",
187+
)

0 commit comments

Comments
 (0)