Skip to content

Commit 7ef5eda

Browse files
committed
Load app templates from yml files
1 parent 2742d16 commit 7ef5eda

File tree

9 files changed

+455
-55
lines changed

9 files changed

+455
-55
lines changed

apps/apis.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@
4242
from apps.integration_configs import WebIntegrationConfig
4343
from emails.sender import EmailSender
4444
from emails.templates.factory import EmailTemplateFactory
45-
from processors.models import ApiBackend
45+
from processors.models import ApiBackend, ApiProvider
4646
from processors.models import Endpoint
4747
from base.models import Profile
48+
from apps.yaml_loader import get_app_template_by_slug, get_app_templates_from_contrib
4849

4950
logger = logging.getLogger(__name__)
5051

@@ -205,15 +206,36 @@ def getCloneableApps(self, request, uid=None):
205206
def getTemplates(self, request, slug=None):
206207
json_data = None
207208
if slug:
208-
object = get_object_or_404(AppTemplate, slug=slug)
209-
serializer = AppTemplateSerializer(
210-
instance=object, context={'hide_details': False},
211-
)
212-
json_data = serializer.data
209+
object = get_app_template_by_slug(slug)
210+
if object:
211+
object_dict = object.dict()
212+
# For backward compatibility with old app templates
213+
for page in object_dict['pages']:
214+
page['schema'] = page['input_schema']
215+
page['ui_schema'] = page['input_ui_schema']
216+
json_data = object_dict
217+
else:
218+
object = get_object_or_404(AppTemplate, slug=slug)
219+
serializer = AppTemplateSerializer(
220+
instance=object, context={'hide_details': False},
221+
)
222+
json_data = serializer.data
213223
else:
214224
queryset = AppTemplate.objects.all().order_by('order')
215225
serializer = AppTemplateSerializer(queryset, many=True)
216226
json_data = serializer.data
227+
228+
# Add app templates from contrib
229+
app_template_slugs = list(map(lambda x: x['slug'], json_data))
230+
app_templates_from_yaml = get_app_templates_from_contrib()
231+
for app_template in app_templates_from_yaml:
232+
if app_template.slug not in app_template_slugs:
233+
# When listing, do not show pages and app
234+
app_template_dict = app_template.dict()
235+
app_template_dict.pop('pages')
236+
app_template_dict.pop('app')
237+
json_data.append(app_template_dict)
238+
217239
return DRFResponse(json_data)
218240

219241
def publish(self, request, uid):
@@ -302,12 +324,12 @@ def delete(self, request, uid):
302324
# Cleanup app run_graph
303325
run_graph_entries = app.run_graph.all()
304326
endpoint_entries = list(filter(lambda x: x != None, set(list(map(lambda x: x.entry_endpoint, run_graph_entries)) +
305-
list(map(lambda x: x.exit_endpoint, run_graph_entries)))))
327+
list(map(lambda x: x.exit_endpoint, run_graph_entries)))))
306328

307-
# Cleanup rungraph
329+
# Cleanup rungraph
308330
# Delete all the run_graph entries
309331
run_graph_entries.delete()
310-
332+
311333
# Delete all the endpoint entries
312334
for entry in endpoint_entries:
313335
EndpointViewSet.delete(self, request, id=str(
@@ -418,7 +440,8 @@ def patch(self, request, uid):
418440

419441
def post(self, request):
420442
owner = request.user
421-
app_type = get_object_or_404(AppType, id=request.data['app_type'])
443+
app_type = get_object_or_404(AppType, id=request.data['app_type']) if 'app_type' in request.data else get_object_or_404(
444+
AppType, slug=request.data['app_type_slug'])
422445
app_name = request.data['name']
423446
app_description = request.data['description'] if 'description' in request.data else ''
424447
app_config = request.data['config'] if 'config' in request.data else {}
@@ -435,9 +458,18 @@ def post(self, request):
435458
# Iterate over processors and create an endpoint for each using api_backend
436459
# and then use that to create AppRunGraphEntry to create the graph
437460
for processor in processors:
438-
api_backend_obj = get_object_or_404(
439-
ApiBackend, id=processor['api_backend'],
440-
)
461+
api_backend_obj = None
462+
if 'processor_slug' in processor and 'provider_slug' in processor:
463+
provider = get_object_or_404(
464+
ApiProvider, slug=processor['provider_slug'],
465+
)
466+
api_backend_obj = get_object_or_404(
467+
ApiBackend, slug=processor['processor_slug'], api_provider=provider,
468+
)
469+
else:
470+
api_backend_obj = get_object_or_404(
471+
ApiBackend, id=processor['api_backend'],
472+
)
441473
endpoint = Endpoint.objects.create(
442474
name=f'{app_name}:{api_backend_obj.name}',
443475
owner=owner,
@@ -485,9 +517,10 @@ def post(self, request):
485517
edge.save()
486518
graph_edges.append(edge)
487519

520+
template_slug = request.data['template_slug'] if 'template_slug' in request.data else None
488521
template = AppTemplate.objects.filter(
489-
slug=request.data['template_slug'],
490-
).first() if 'template_slug' in request.data else None
522+
slug=template_slug,
523+
).first() if template_slug else None
491524

492525
app = App.objects.create(
493526
name=app_name,
@@ -500,6 +533,7 @@ def post(self, request):
500533
output_template=app_output_template,
501534
data_transformer=app_data_transformer,
502535
template=template,
536+
template_slug=template_slug,
503537
)
504538
app.run_graph.set(graph_edges)
505539
app.save()

apps/schemas.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from typing import Any, Dict, List, Optional
2+
from pydantic import BaseModel, Field
3+
4+
"""
5+
This file contains YAML schemas for the apps and app templates.
6+
"""
7+
8+
9+
class InputField(BaseModel):
10+
advanced_parameter: Optional[bool]
11+
default: Optional[Any]
12+
description: Optional[str]
13+
enum: Optional[List[Any]]
14+
enumNames: Optional[List[str]]
15+
format: Optional[str]
16+
items: Optional[Dict[str, Any]]
17+
maximum: Optional[int]
18+
minimum: Optional[int]
19+
multipleOf: Optional[int]
20+
name: str
21+
path: Optional[str]
22+
pattern: Optional[str]
23+
required: Optional[bool]
24+
title: str
25+
type: str = "string"
26+
widget: Optional[str]
27+
28+
29+
class OutputTemplate(BaseModel):
30+
"""
31+
OutputTemplate schema
32+
"""
33+
markdown: str = Field(None, description='Markdown template for output')
34+
35+
36+
class Provider(BaseModel):
37+
"""
38+
Provider schema
39+
"""
40+
name: str = Field(None, description='Name of the provider')
41+
slug: str = Field(
42+
None, description='Unique slug for the provider. Must be @github username for individual contributions')
43+
description: str = Field(None, description='Description of the provider')
44+
45+
46+
class Processor(BaseModel):
47+
"""
48+
Processor schema
49+
"""
50+
id: str = Field(
51+
None, description='Unique identifier for the processor in the app run graph')
52+
provider_slug: str = Field(
53+
None, description='Slug of the processor provider')
54+
processor_slug: str = Field(None, description='Slug of the processor')
55+
# TODO: Validate input and config against backing processor's schemas
56+
input: dict = Field(None, description='Input for the processor')
57+
config: dict = Field(None, description='Configuration for the processor')
58+
59+
60+
class App(BaseModel):
61+
"""
62+
App schema
63+
"""
64+
name: str = Field(None, description='Name of the app')
65+
slug: str = Field(None, description='Unique slug for the app')
66+
type_slug: str = Field(None, description='Slug of the app type')
67+
description: str = Field(None, description='Description of the app')
68+
input_fields: Optional[List[InputField]] = Field(
69+
None, description='Input fields for the app')
70+
input_schema: Optional[dict] = Field(
71+
None, description='Input schema for the app')
72+
input_ui_schema: Optional[dict] = Field(
73+
None, description='Input UI schema for the app')
74+
output_template: OutputTemplate = Field(
75+
None, description='Output template for the app')
76+
processors: List[Processor] = Field(
77+
None, description='Processors for the app')
78+
79+
80+
class AppTemplatePage(BaseModel):
81+
"""
82+
AppTemplatePage schema
83+
"""
84+
title: str = Field(None, description='Title of the page')
85+
description: str = Field(None, description='Description of the page')
86+
input_fields: Optional[List[InputField]] = Field(
87+
None, description='Input fields for the page')
88+
input_schema: Optional[dict] = Field(
89+
None, description='Schema for the page')
90+
input_ui_schema: Optional[dict] = Field(
91+
None, description='UI schema for the page')
92+
93+
94+
class AppTemplate(BaseModel):
95+
"""
96+
AppTemplate schema
97+
"""
98+
name: str = Field(None, description='Name of the app template')
99+
slug: str = Field(None, description='Unique slug for the app template')
100+
description: str = Field(
101+
None, description='Description of the app template')
102+
pages: List[AppTemplatePage] = Field(
103+
None, description='Pages of the app template')
104+
app: App = Field(None, description='App of the app template')

apps/serializers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from rest_framework import serializers
22

3+
from apps.yaml_loader import get_app_template_by_slug
4+
35
from .models import App
46
from .models import AppHub
57
from .models import AppRunGraphEntry
@@ -191,7 +193,13 @@ def get_last_modified_by_email(self, obj):
191193
return obj.last_modified_by.email if (obj.last_modified_by and obj.has_write_permission(self._request_user)) else None
192194

193195
def get_template(self, obj):
194-
return AppTemplateSerializer(instance=obj.template).data if obj.template else None
196+
if obj.template:
197+
return AppTemplateSerializer(instance=obj.template).data
198+
elif obj.template_slug is not None:
199+
app_template = get_app_template_by_slug(obj.template_slug)
200+
if app_template:
201+
return app_template.dict()
202+
return None
195203

196204
def get_web_config(self, obj):
197205
return obj.web_config if obj.has_write_permission(self._request_user) else None

apps/yaml_loader.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""
2+
Utils to convert yaml to App and AppTemplate schema models and vice versa
3+
"""
4+
import yaml
5+
import os
6+
from django.conf import settings
7+
from django.core.cache import cache
8+
from typing import List, Type
9+
from pydantic import BaseModel, Field, create_model
10+
from apps.schemas import AppTemplate
11+
12+
from common.blocks.base.schema import get_ui_schema_from_json_schema
13+
14+
15+
def get_input_model_from_fields(name: str, input_fields: list) -> Type['BaseModel']:
16+
"""
17+
Dynamically creates a Pydantic model from a list of input fields.
18+
19+
Args:
20+
name (str): The name of the model to be created.
21+
input_fields (list): A list of dictionaries representing the input fields of the model.
22+
23+
Returns:
24+
Type['BaseModel']: The dynamically created Pydantic model.
25+
"""
26+
return create_model(
27+
f'{name}', **{field['name']: (int if field['type'] == 'int' else str, Field(**{k: field[k] for k in field})) for field in input_fields})
28+
29+
30+
def get_app_template_from_yaml(yaml_file: str) -> dict:
31+
"""
32+
Reads a YAML file and returns a dictionary containing the app template.
33+
34+
Args:
35+
yaml_file (str): The path to the YAML file.
36+
37+
Returns:
38+
dict: A dictionary containing the app template.
39+
"""
40+
with open(yaml_file, 'r') as f:
41+
yaml_dict = yaml.safe_load(f)
42+
43+
# Construct dynamic models for app template page input and app input
44+
pages = yaml_dict.get('pages', [])
45+
for page in pages:
46+
input_fields = page.get('input_fields', [])
47+
input_model = get_input_model_from_fields(
48+
page["title"], input_fields)
49+
page['input_schema'] = input_model.schema()
50+
page['input_ui_schema'] = get_ui_schema_from_json_schema(
51+
input_model.schema())
52+
page.pop('input_fields')
53+
54+
app = yaml_dict.get('app', {})
55+
input_fields = app.get('input_fields', [])
56+
input_model = get_input_model_from_fields(
57+
app["name"], input_fields)
58+
app['input_schema'] = input_model.schema()
59+
app['input_schema'].pop('title')
60+
app['input_ui_schema'] = get_ui_schema_from_json_schema(
61+
input_model.schema())
62+
app.pop('input_fields')
63+
64+
return AppTemplate(**yaml_dict)
65+
66+
67+
def get_app_templates_from_contrib() -> List[AppTemplate]:
68+
"""
69+
Loads app templates from yaml files in settings.APP_TEMPLATES_DIR and caches them in memory.
70+
"""
71+
app_templates = cache.get('app_templates')
72+
if app_templates:
73+
return app_templates
74+
75+
app_templates = []
76+
if not hasattr(settings, 'APP_TEMPLATES_DIR'):
77+
return app_templates
78+
79+
for file in os.listdir(settings.APP_TEMPLATES_DIR):
80+
if file.endswith('.yml'):
81+
app_template = get_app_template_from_yaml(
82+
os.path.join(settings.APP_TEMPLATES_DIR, file))
83+
if app_template:
84+
app_templates.append(app_template)
85+
cache.set('app_templates', app_templates)
86+
87+
return app_templates
88+
89+
90+
def get_app_template_by_slug(slug: str) -> dict:
91+
"""
92+
Returns an app template by slug.
93+
"""
94+
for app_template in get_app_templates_from_contrib():
95+
if app_template.slug == slug:
96+
return app_template
97+
return None

client/src/components/apps/AppTemplatesList.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,13 @@ export function AppTemplatesList() {
5656
...(appTemplate.app || {}),
5757
name: appName || appTemplate?.name || "Untitled",
5858
app_type: appTemplate.app?.type,
59+
app_type_slug: appTemplate.app?.type_slug,
5960
template_slug: appTemplate.slug,
6061
};
6162
payload.processors = payload.processors.map((processor) => ({
62-
api_backend: processor.api_backend.id,
63+
api_backend: processor.api_backend?.id,
64+
provider_slug: processor.provider_slug,
65+
processor_slug: processor.processor_slug,
6366
config: processor.config,
6467
input: processor.input,
6568
}));

0 commit comments

Comments
 (0)