netboxlabs-netbox-custom-objects 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_custom_objects/__init__.py +42 -0
- netbox_custom_objects/api/__init__.py +0 -0
- netbox_custom_objects/api/serializers.py +244 -0
- netbox_custom_objects/api/urls.py +83 -0
- netbox_custom_objects/api/views.py +49 -0
- netbox_custom_objects/choices.py +20 -0
- netbox_custom_objects/constants.py +8 -0
- netbox_custom_objects/field_types.py +796 -0
- netbox_custom_objects/fields.py +86 -0
- netbox_custom_objects/filtersets.py +21 -0
- netbox_custom_objects/forms.py +144 -0
- netbox_custom_objects/migrations/0001_initial.py +199 -0
- netbox_custom_objects/migrations/__init__.py +0 -0
- netbox_custom_objects/models.py +1056 -0
- netbox_custom_objects/navigation.py +70 -0
- netbox_custom_objects/tables.py +183 -0
- netbox_custom_objects/template_content.py +82 -0
- netbox_custom_objects/templates/buttons/custom_objects_delete.html +4 -0
- netbox_custom_objects/templates/netbox_custom_objects/custom_object_list.html +30 -0
- netbox_custom_objects/templates/netbox_custom_objects/customobject.html +134 -0
- netbox_custom_objects/templates/netbox_custom_objects/customobject_edit.html +12 -0
- netbox_custom_objects/templates/netbox_custom_objects/customobjecttype.html +157 -0
- netbox_custom_objects/templates/netbox_custom_objects/field_delete.html +30 -0
- netbox_custom_objects/templates/netbox_custom_objects/htmx/delete_form.html +30 -0
- netbox_custom_objects/templatetags/__init__.py +0 -0
- netbox_custom_objects/templatetags/custom_object_buttons.py +246 -0
- netbox_custom_objects/templatetags/custom_object_utils.py +49 -0
- netbox_custom_objects/urls.py +63 -0
- netbox_custom_objects/utilities.py +85 -0
- netbox_custom_objects/views.py +453 -0
- netboxlabs_netbox_custom_objects-0.1.0.dist-info/METADATA +61 -0
- netboxlabs_netbox_custom_objects-0.1.0.dist-info/RECORD +35 -0
- netboxlabs_netbox_custom_objects-0.1.0.dist-info/WHEEL +5 -0
- netboxlabs_netbox_custom_objects-0.1.0.dist-info/licenses/LICENSE.md +85 -0
- netboxlabs_netbox_custom_objects-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Plugin Configuration
|
|
5
|
+
class CustomObjectsPluginConfig(PluginConfig):
|
|
6
|
+
name = "netbox_custom_objects"
|
|
7
|
+
verbose_name = "Custom Objects"
|
|
8
|
+
description = "A plugin to manage custom objects in NetBox"
|
|
9
|
+
version = "0.1.0"
|
|
10
|
+
base_url = "custom-objects"
|
|
11
|
+
min_version = "4.2.0"
|
|
12
|
+
default_settings = {}
|
|
13
|
+
required_settings = []
|
|
14
|
+
template_extensions = "template_content.template_extensions"
|
|
15
|
+
|
|
16
|
+
# def get_model(self, model_name, require_ready=True):
|
|
17
|
+
# if require_ready:
|
|
18
|
+
# self.apps.check_models_ready()
|
|
19
|
+
# else:
|
|
20
|
+
# self.apps.check_apps_ready()
|
|
21
|
+
#
|
|
22
|
+
# if model_name.lower() in self.models:
|
|
23
|
+
# return self.models[model_name.lower()]
|
|
24
|
+
#
|
|
25
|
+
# from .models import CustomObjectType
|
|
26
|
+
# if "table" not in model_name.lower() or "model" not in model_name.lower():
|
|
27
|
+
# raise LookupError(
|
|
28
|
+
# "App '%s' doesn't have a '%s' model." % (self.label, model_name)
|
|
29
|
+
# )
|
|
30
|
+
#
|
|
31
|
+
# custom_object_type_id = int(model_name.replace("table", "").replace("model", ""))
|
|
32
|
+
#
|
|
33
|
+
# try:
|
|
34
|
+
# obj = CustomObjectType.objects.get(pk=custom_object_type_id)
|
|
35
|
+
# except CustomObjectType.DoesNotExist:
|
|
36
|
+
# raise LookupError(
|
|
37
|
+
# "App '%s' doesn't have a '%s' model." % (self.label, model_name)
|
|
38
|
+
# )
|
|
39
|
+
# return obj.get_model()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
config = CustomObjectsPluginConfig
|
|
File without changes
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from django.contrib.contenttypes.models import ContentType
|
|
2
|
+
from extras.choices import CustomFieldTypeChoices
|
|
3
|
+
from netbox.api.serializers import NetBoxModelSerializer
|
|
4
|
+
from rest_framework import serializers
|
|
5
|
+
from rest_framework.exceptions import ValidationError
|
|
6
|
+
from rest_framework.reverse import reverse
|
|
7
|
+
|
|
8
|
+
from netbox_custom_objects import field_types
|
|
9
|
+
from netbox_custom_objects.models import CustomObject, CustomObjectType, CustomObjectTypeField
|
|
10
|
+
|
|
11
|
+
__all__ = (
|
|
12
|
+
"CustomObjectTypeSerializer",
|
|
13
|
+
"CustomObjectSerializer",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContentTypeSerializer(NetBoxModelSerializer):
|
|
18
|
+
class Meta:
|
|
19
|
+
model = ContentType
|
|
20
|
+
fields = (
|
|
21
|
+
"id",
|
|
22
|
+
"app_label",
|
|
23
|
+
"model",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CustomObjectTypeFieldSerializer(NetBoxModelSerializer):
|
|
28
|
+
url = serializers.HyperlinkedIdentityField(
|
|
29
|
+
view_name="plugins-api:netbox_custom_objects-api:customobjecttypefield-detail"
|
|
30
|
+
)
|
|
31
|
+
app_label = serializers.CharField(required=False)
|
|
32
|
+
model = serializers.CharField(required=False)
|
|
33
|
+
|
|
34
|
+
class Meta:
|
|
35
|
+
model = CustomObjectTypeField
|
|
36
|
+
fields = (
|
|
37
|
+
# 'id', 'url', 'name', 'label', 'custom_object_type', 'field_type', 'content_type', 'many', 'options',
|
|
38
|
+
"id",
|
|
39
|
+
"name",
|
|
40
|
+
"label",
|
|
41
|
+
"custom_object_type",
|
|
42
|
+
"type",
|
|
43
|
+
"primary",
|
|
44
|
+
"default",
|
|
45
|
+
"choice_set",
|
|
46
|
+
"validation_regex",
|
|
47
|
+
"validation_minimum",
|
|
48
|
+
"validation_maximum",
|
|
49
|
+
"related_object_type",
|
|
50
|
+
"app_label",
|
|
51
|
+
"model",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def validate(self, attrs):
|
|
55
|
+
app_label = attrs.pop("app_label", None)
|
|
56
|
+
model = attrs.pop("model", None)
|
|
57
|
+
if attrs["type"] in [
|
|
58
|
+
CustomFieldTypeChoices.TYPE_OBJECT,
|
|
59
|
+
CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
|
60
|
+
]:
|
|
61
|
+
try:
|
|
62
|
+
attrs["related_object_type"] = ContentType.objects.get(
|
|
63
|
+
app_label=app_label, model=model
|
|
64
|
+
)
|
|
65
|
+
except ContentType.DoesNotExist:
|
|
66
|
+
raise ValidationError(
|
|
67
|
+
"Must provide valid app_label and model for object field type."
|
|
68
|
+
)
|
|
69
|
+
if attrs["type"] in [
|
|
70
|
+
CustomFieldTypeChoices.TYPE_SELECT,
|
|
71
|
+
CustomFieldTypeChoices.TYPE_MULTISELECT,
|
|
72
|
+
]:
|
|
73
|
+
if not attrs.get("choice_set", None):
|
|
74
|
+
raise ValidationError(
|
|
75
|
+
"Must provide choice_set with valid PK for select field type."
|
|
76
|
+
)
|
|
77
|
+
return super().validate(attrs)
|
|
78
|
+
|
|
79
|
+
def create(self, validated_data):
|
|
80
|
+
"""
|
|
81
|
+
Record the user who created the Custom Object as its owner.
|
|
82
|
+
"""
|
|
83
|
+
return super().create(validated_data)
|
|
84
|
+
|
|
85
|
+
def get_related_object_type(self, obj):
|
|
86
|
+
if obj.related_object_type:
|
|
87
|
+
return dict(
|
|
88
|
+
id=obj.related_object_type.id,
|
|
89
|
+
app_label=obj.related_object_type.app_label,
|
|
90
|
+
model=obj.related_object_type.model,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class CustomObjectTypeSerializer(NetBoxModelSerializer):
|
|
95
|
+
url = serializers.HyperlinkedIdentityField(
|
|
96
|
+
view_name="plugins-api:netbox_custom_objects-api:customobjecttype-detail"
|
|
97
|
+
)
|
|
98
|
+
fields = CustomObjectTypeFieldSerializer(
|
|
99
|
+
nested=True,
|
|
100
|
+
read_only=True,
|
|
101
|
+
many=True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
class Meta:
|
|
105
|
+
model = CustomObjectType
|
|
106
|
+
fields = [
|
|
107
|
+
"id",
|
|
108
|
+
"url",
|
|
109
|
+
"name",
|
|
110
|
+
"description",
|
|
111
|
+
"tags",
|
|
112
|
+
"created",
|
|
113
|
+
"last_updated",
|
|
114
|
+
"fields",
|
|
115
|
+
]
|
|
116
|
+
brief_fields = ("id", "url", "name", "description")
|
|
117
|
+
|
|
118
|
+
def create(self, validated_data):
|
|
119
|
+
return super().create(validated_data)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# TODO: Remove or reduce to a stub (not needed as all custom object serializers are generated via get_serializer_class)
|
|
123
|
+
class CustomObjectSerializer(NetBoxModelSerializer):
|
|
124
|
+
relation_fields = None
|
|
125
|
+
|
|
126
|
+
url = serializers.SerializerMethodField()
|
|
127
|
+
field_data = serializers.SerializerMethodField()
|
|
128
|
+
custom_object_type = CustomObjectTypeSerializer(nested=True)
|
|
129
|
+
|
|
130
|
+
class Meta:
|
|
131
|
+
model = CustomObject
|
|
132
|
+
fields = [
|
|
133
|
+
"id",
|
|
134
|
+
"url",
|
|
135
|
+
"name",
|
|
136
|
+
"display",
|
|
137
|
+
"custom_object_type",
|
|
138
|
+
"tags",
|
|
139
|
+
"created",
|
|
140
|
+
"last_updated",
|
|
141
|
+
"data",
|
|
142
|
+
"field_data",
|
|
143
|
+
]
|
|
144
|
+
brief_fields = (
|
|
145
|
+
"id",
|
|
146
|
+
"url",
|
|
147
|
+
"name",
|
|
148
|
+
"custom_object_type",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def get_display(self, obj):
|
|
152
|
+
return f"{obj.custom_object_type}: {obj.name}"
|
|
153
|
+
|
|
154
|
+
def validate(self, attrs):
|
|
155
|
+
return super().validate(attrs)
|
|
156
|
+
|
|
157
|
+
def update_relation_fields(self, instance):
|
|
158
|
+
# TODO: Implement this
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
def create(self, validated_data):
|
|
162
|
+
model = validated_data["custom_object_type"].get_model()
|
|
163
|
+
instance = model.objects.create(**validated_data)
|
|
164
|
+
|
|
165
|
+
return instance
|
|
166
|
+
|
|
167
|
+
def update(self, instance, validated_data):
|
|
168
|
+
instance = super().update(instance, validated_data)
|
|
169
|
+
# self.update_relation_fields(instance)
|
|
170
|
+
return instance
|
|
171
|
+
|
|
172
|
+
def get_url(self, obj):
|
|
173
|
+
"""
|
|
174
|
+
Given an object, return the URL that hyperlinks to the object.
|
|
175
|
+
|
|
176
|
+
May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
|
|
177
|
+
attributes are not configured to correctly match the URL conf.
|
|
178
|
+
"""
|
|
179
|
+
# Unsaved objects will not yet have a valid URL.
|
|
180
|
+
if hasattr(obj, "pk") and obj.pk in (None, ""):
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
view_name = "plugins-api:netbox_custom_objects-api:customobject-detail"
|
|
184
|
+
lookup_value = getattr(obj, "pk")
|
|
185
|
+
kwargs = {
|
|
186
|
+
"pk": lookup_value,
|
|
187
|
+
"custom_object_type": obj.custom_object_type.name.lower(),
|
|
188
|
+
}
|
|
189
|
+
request = self.context["request"]
|
|
190
|
+
format = self.context.get("format")
|
|
191
|
+
return reverse(view_name, kwargs=kwargs, request=request, format=format)
|
|
192
|
+
|
|
193
|
+
def get_field_data(self, obj):
|
|
194
|
+
result = {}
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_serializer_class(model):
|
|
199
|
+
model_fields = model.custom_object_type.fields.all()
|
|
200
|
+
meta = type(
|
|
201
|
+
"Meta",
|
|
202
|
+
(),
|
|
203
|
+
{
|
|
204
|
+
"model": model,
|
|
205
|
+
"fields": "__all__",
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def get_url(self, obj):
|
|
210
|
+
# Unsaved objects will not yet have a valid URL.
|
|
211
|
+
if hasattr(obj, "pk") and obj.pk in (None, ""):
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
view_name = "plugins-api:netbox_custom_objects-api:customobject-detail"
|
|
215
|
+
lookup_value = getattr(obj, "pk")
|
|
216
|
+
kwargs = {
|
|
217
|
+
"pk": lookup_value,
|
|
218
|
+
"custom_object_type": obj.custom_object_type.name.lower(),
|
|
219
|
+
}
|
|
220
|
+
request = self.context["request"]
|
|
221
|
+
format = self.context.get("format")
|
|
222
|
+
return reverse(view_name, kwargs=kwargs, request=request, format=format)
|
|
223
|
+
|
|
224
|
+
attrs = {
|
|
225
|
+
"Meta": meta,
|
|
226
|
+
"__module__": "database.serializers",
|
|
227
|
+
"url": serializers.SerializerMethodField(),
|
|
228
|
+
"get_url": get_url,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for field in model_fields:
|
|
232
|
+
field_type = field_types.FIELD_TYPE_CLASS[field.type]()
|
|
233
|
+
try:
|
|
234
|
+
attrs[field.name] = field_type.get_serializer_field(field)
|
|
235
|
+
except NotImplementedError:
|
|
236
|
+
print(f"serializer: {field.name} field is not implemented; using a default serializer field")
|
|
237
|
+
|
|
238
|
+
serializer = type(
|
|
239
|
+
f"{model._meta.object_name}Serializer",
|
|
240
|
+
(serializers.ModelSerializer,),
|
|
241
|
+
attrs,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return serializer
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from copy import deepcopy
|
|
2
|
+
from django.urls import include, path, NoReverseMatch
|
|
3
|
+
from rest_framework.response import Response
|
|
4
|
+
from rest_framework.reverse import reverse
|
|
5
|
+
from rest_framework.views import APIView
|
|
6
|
+
from netbox.api.routers import NetBoxRouter
|
|
7
|
+
from netbox_custom_objects.models import CustomObjectType
|
|
8
|
+
|
|
9
|
+
from . import views
|
|
10
|
+
|
|
11
|
+
custom_object_list = views.CustomObjectViewSet.as_view(
|
|
12
|
+
{"get": "list", "post": "create"}
|
|
13
|
+
)
|
|
14
|
+
custom_object_detail = views.CustomObjectViewSet.as_view(
|
|
15
|
+
{"get": "retrieve", "put": "update", "patch": "partial_update", "delete": "destroy"}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CustomObjectsAPIRootView(APIView):
|
|
20
|
+
"""
|
|
21
|
+
This is the root of the NetBox Custom Objects plugin API. Custom Object Types defined at application startup
|
|
22
|
+
are listed by lowercased name; e.g. `/api/plugins/custom-objects/cat/`.
|
|
23
|
+
"""
|
|
24
|
+
def get_view_name(self):
|
|
25
|
+
return "Custom Objects API Root"
|
|
26
|
+
|
|
27
|
+
_ignore_model_permissions = True
|
|
28
|
+
schema = None # exclude from schema
|
|
29
|
+
api_root_dict = None
|
|
30
|
+
|
|
31
|
+
# This logic is copied from stock DRF APIRootView
|
|
32
|
+
def get(self, request, *args, **kwargs):
|
|
33
|
+
# Return a plain {"name": "hyperlink"} response.
|
|
34
|
+
ret = {}
|
|
35
|
+
namespace = request.resolver_match.namespace
|
|
36
|
+
for key, url_name in self.api_root_dict.items():
|
|
37
|
+
if namespace:
|
|
38
|
+
url_name = namespace + ':' + url_name
|
|
39
|
+
try:
|
|
40
|
+
ret[key] = reverse(
|
|
41
|
+
url_name,
|
|
42
|
+
args=args,
|
|
43
|
+
kwargs=kwargs,
|
|
44
|
+
request=request,
|
|
45
|
+
format=kwargs.get('format')
|
|
46
|
+
)
|
|
47
|
+
except NoReverseMatch:
|
|
48
|
+
# Don't bail out if eg. no list routes exist, only detail routes.
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
# Extra logic to populate roots for custom object type lists
|
|
52
|
+
for custom_object_type in CustomObjectType.objects.all():
|
|
53
|
+
local_kwargs = deepcopy(kwargs)
|
|
54
|
+
cot_name = custom_object_type.name.lower()
|
|
55
|
+
url_name = 'customobject-list'
|
|
56
|
+
local_kwargs['custom_object_type'] = cot_name
|
|
57
|
+
if namespace:
|
|
58
|
+
url_name = namespace + ':' + url_name
|
|
59
|
+
ret[cot_name] = reverse(
|
|
60
|
+
url_name,
|
|
61
|
+
args=args,
|
|
62
|
+
kwargs=local_kwargs,
|
|
63
|
+
request=request,
|
|
64
|
+
format=local_kwargs.get('format')
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return Response(ret)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
router = NetBoxRouter()
|
|
71
|
+
router.APIRootView = CustomObjectsAPIRootView
|
|
72
|
+
router.register("custom-object-types", views.CustomObjectTypeViewSet)
|
|
73
|
+
router.register("custom-object-type-fields", views.CustomObjectTypeFieldViewSet)
|
|
74
|
+
|
|
75
|
+
urlpatterns = [
|
|
76
|
+
path("", include(router.urls)),
|
|
77
|
+
path("<str:custom_object_type>/", custom_object_list, name="customobject-list"),
|
|
78
|
+
path(
|
|
79
|
+
"<str:custom_object_type>/<int:pk>/",
|
|
80
|
+
custom_object_detail,
|
|
81
|
+
name="customobject-detail",
|
|
82
|
+
),
|
|
83
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from django.http import Http404
|
|
2
|
+
from rest_framework.routers import APIRootView
|
|
3
|
+
from rest_framework.viewsets import ModelViewSet
|
|
4
|
+
|
|
5
|
+
from netbox_custom_objects.models import CustomObject, CustomObjectType, CustomObjectTypeField
|
|
6
|
+
|
|
7
|
+
from . import serializers
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RootView(APIRootView):
|
|
11
|
+
def get_view_name(self):
|
|
12
|
+
return "CustomObjects"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CustomObjectTypeViewSet(ModelViewSet):
|
|
16
|
+
queryset = CustomObjectType.objects.all()
|
|
17
|
+
serializer_class = serializers.CustomObjectTypeSerializer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CustomObjectViewSet(ModelViewSet):
|
|
21
|
+
queryset = CustomObject.objects.all()
|
|
22
|
+
serializer_class = serializers.CustomObjectSerializer
|
|
23
|
+
model = None
|
|
24
|
+
|
|
25
|
+
def get_view_name(self):
|
|
26
|
+
if self.model:
|
|
27
|
+
return self.model.custom_object_type.name
|
|
28
|
+
return super().get_view_name()
|
|
29
|
+
|
|
30
|
+
def get_serializer_class(self):
|
|
31
|
+
return serializers.get_serializer_class(self.model)
|
|
32
|
+
|
|
33
|
+
def get_queryset(self):
|
|
34
|
+
try:
|
|
35
|
+
custom_object_type = CustomObjectType.objects.get(
|
|
36
|
+
name__iexact=self.kwargs["custom_object_type"]
|
|
37
|
+
)
|
|
38
|
+
except CustomObjectType.DoesNotExist:
|
|
39
|
+
raise Http404
|
|
40
|
+
self.model = custom_object_type.get_model()
|
|
41
|
+
return self.model.objects.all()
|
|
42
|
+
|
|
43
|
+
def list(self, request, *args, **kwargs):
|
|
44
|
+
return super().list(request, *args, **kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CustomObjectTypeFieldViewSet(ModelViewSet):
|
|
48
|
+
queryset = CustomObjectTypeField.objects.all()
|
|
49
|
+
serializer_class = serializers.CustomObjectTypeFieldSerializer
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from django.utils.translation import gettext_lazy as _
|
|
2
|
+
from utilities.choices import ChoiceSet
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MappingFieldTypeChoices(ChoiceSet):
|
|
6
|
+
CHAR = "char"
|
|
7
|
+
INTEGER = "integer"
|
|
8
|
+
BOOLEAN = "boolean"
|
|
9
|
+
DATE = "date"
|
|
10
|
+
DATETIME = "datetime"
|
|
11
|
+
OBJECT = "object"
|
|
12
|
+
|
|
13
|
+
CHOICES = (
|
|
14
|
+
(CHAR, _("String"), "cyan"),
|
|
15
|
+
(INTEGER, _("Integer"), "orange"),
|
|
16
|
+
(BOOLEAN, _("Boolean"), "green"),
|
|
17
|
+
(DATE, _("Date"), "red"),
|
|
18
|
+
(DATETIME, _("DateTime"), "blue"),
|
|
19
|
+
(OBJECT, _("Object"), "orange"),
|
|
20
|
+
)
|