Skip to content

Commit 107f822

Browse files
committed
Adding contribute app to voyages-api.
1 parent bbb68ce commit 107f822

7 files changed

Lines changed: 238 additions & 1 deletion

File tree

src/api/contrib/__init__.py

Whitespace-only changes.

src/api/contrib/samples.http

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
@CurrentHost = http://localhost:8000
2+
3+
# @name location-fetch
4+
POST {{CurrentHost}}/contrib/data
5+
Accept: application/json
6+
Content-Type: application/json
7+
Accept-Language: en-US,en;q=0.5
8+
9+
{
10+
"single": {
11+
"query": {
12+
"model": "geo.Location",
13+
"filter": []
14+
},
15+
"fields": [
16+
"name",
17+
"value"
18+
]
19+
}
20+
}
21+
22+
###
23+
24+
# @name nationality
25+
POST {{CurrentHost}}/contrib/data
26+
Accept: application/json
27+
Content-Type: application/json
28+
Accept-Language: en-US,en;q=0.5
29+
30+
{
31+
"single": {
32+
"query": {
33+
"model": "voyage_nationality",
34+
"filter": []
35+
},
36+
"fields": [
37+
"name"
38+
]
39+
}
40+
}
41+
42+
###
43+
44+
# @name single-voyage
45+
POST {{CurrentHost}}/contrib/data
46+
Accept: application/json
47+
Content-Type: application/json
48+
Accept-Language: en-US,en;q=0.5
49+
50+
{
51+
"q1": {
52+
"query": {
53+
"model": "voyage_voyage",
54+
"filter": [ { "field": "voyage_id", "value": "1" } ]
55+
},
56+
"fields": [ "voyage_id", "dataset" ]
57+
}
58+
}
59+
60+
###
61+
62+
# @name multiple-queries
63+
POST {{CurrentHost}}/contrib/data
64+
Accept: application/json
65+
Content-Type: application/json
66+
Accept-Language: en-US,en;q=0.5
67+
68+
{
69+
"q47": {
70+
"query": {
71+
"model": "past_enslaveralias",
72+
"filter": [
73+
{
74+
"field": "id",
75+
"value": 1035662
76+
}
77+
]
78+
},
79+
"fields": [
80+
"alias",
81+
"identity_id",
82+
"id"
83+
]
84+
},
85+
"q48": {
86+
"query": {
87+
"model": "past_enslaverinrelation_roles",
88+
"filter": [
89+
{
90+
"field": "enslaverinrelation_id",
91+
"value": 1080
92+
}
93+
]
94+
},
95+
"fields": [
96+
"enslaverinrelation_id",
97+
"enslaverrole_id",
98+
"id"
99+
]
100+
}
101+
}
102+
103+
###

src/api/contrib/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.urls import path
2+
from .views import batch_data_api
3+
4+
urlpatterns = [
5+
path('data', batch_data_api, name='contrib_data_api'),
6+
]

src/api/contrib/views.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""
2+
Django implementation of the BatchDataResolver interface.
3+
This module provides a way to execute multiple database queries in a single request.
4+
"""
5+
from typing import Dict, List, Any, Optional, Union, Tuple
6+
import json
7+
from django.http import JsonResponse
8+
from django.views.decorators.http import require_POST
9+
from django.views.decorators.csrf import csrf_exempt
10+
from django.db.models import Q, Model
11+
from django.apps import apps
12+
13+
# Type definitions to match the TypeScript interface
14+
NonNullFieldValue = Union[str, int, float, bool]
15+
BaseFieldValue = Optional[NonNullFieldValue]
16+
ResolvedEntityData = Dict[str, BaseFieldValue]
17+
18+
class DataFilter:
19+
"""Represents a filter condition on a field."""
20+
21+
def __init__(self, field: str, value: Union[NonNullFieldValue, List[NonNullFieldValue]], operator: str = "equals"):
22+
self.field = field
23+
self.value = value
24+
self.operator = operator
25+
26+
def to_q(self) -> Q:
27+
"""Convert the filter to a Django Q object."""
28+
if self.operator == "in":
29+
return Q(**{f"{self.field}__in": self.value})
30+
# Default to equals
31+
return Q(**{self.field: self.value})
32+
33+
34+
class DataQuery:
35+
"""Represents a query on a specific model with filters."""
36+
37+
def __init__(self, model: str, filters: List[DataFilter]):
38+
self.model = model.replace("_", ".", 1) # Convert to Django model path
39+
self.filters = filters
40+
41+
def get_model_class(self) -> Model:
42+
"""Get the Django model class from the model name."""
43+
try:
44+
return apps.get_model(self.model)
45+
except LookupError:
46+
raise ValueError(f"Model {self.model} not found")
47+
48+
def execute(self, fields: List[str]) -> List[ResolvedEntityData]:
49+
"""Execute the query and return the results."""
50+
model_class = self.get_model_class()
51+
52+
# Build the filter query
53+
query = Q()
54+
for filter_obj in self.filters:
55+
query &= filter_obj.to_q()
56+
57+
# Execute the query and return the results
58+
queryset = model_class.objects.filter(query)
59+
# Convert to list of dictionaries with only the requested fields
60+
result = []
61+
for instance in queryset:
62+
entity_data = {}
63+
for field in fields:
64+
try:
65+
value = getattr(instance, field)
66+
# Ensure the value is of a compatible type
67+
if value is not None and not isinstance(value, (str, int, float, bool)):
68+
value = str(value)
69+
entity_data[field] = value
70+
except AttributeError:
71+
entity_data[field] = None
72+
result.append(entity_data)
73+
74+
return result
75+
76+
77+
class DataResolver:
78+
"""Resolver for a single data query."""
79+
80+
@staticmethod
81+
def fetch(input_data: Dict[str, Any]) -> List[ResolvedEntityData]:
82+
"""Fetch data based on the input query."""
83+
query = DataQuery(
84+
model=input_data["query"]["model"],
85+
filters=[
86+
DataFilter(
87+
field=filter_obj["field"],
88+
value=filter_obj["value"],
89+
operator=filter_obj.get("operator", "equals")
90+
)
91+
for filter_obj in input_data["query"]["filter"]
92+
]
93+
)
94+
return query.execute(input_data["fields"])
95+
96+
97+
class BatchDataResolver:
98+
"""Resolver for multiple data queries in one batch."""
99+
100+
@staticmethod
101+
def fetchBatch(batch: Dict[str, Dict[str, Any]]) -> Dict[str, List[ResolvedEntityData]]:
102+
"""Fetch multiple queries serially and return results keyed by query ID."""
103+
results = {}
104+
for key, input_data in batch.items():
105+
results[key] = DataResolver.fetch(input_data)
106+
return results
107+
108+
109+
@csrf_exempt
110+
@require_POST
111+
def batch_data_api(request):
112+
"""API endpoint that handles batch data requests."""
113+
try:
114+
# Parse the request body
115+
batch_data = json.loads(request.body)
116+
# Validate the input
117+
if not isinstance(batch_data, dict):
118+
return JsonResponse({"error": "Input must be a dictionary"}, status=400)
119+
# Execute the batch query
120+
results = BatchDataResolver.fetchBatch(batch_data)
121+
# Return the results
122+
return JsonResponse(results, safe=False)
123+
124+
except json.JSONDecodeError:
125+
return JsonResponse({"error": "Invalid JSON"}, status=400)
126+
except Exception as e:
127+
return JsonResponse({"error": str(e)}, status=500)

src/api/voyages3/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
'blog',
4646
'geo',
4747
'document',
48-
# 'contribute',
48+
'contrib',
4949
'tinymce',
5050
'rest_framework',
5151
'rest_framework.authtoken',

src/api/voyages3/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
path('captcha/', include('captcha.urls')),
3939
path('filebrowser/', site.urls),
4040
path('tinymce/', site.urls),
41+
path('contrib/', include('contrib.urls')),
4142
re_path(r'^_nested_admin/', include('nested_admin.urls')),
4243
path('schema/', SpectacularAPIView.as_view(), name='schema'),
4344
path('', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui')
File renamed without changes.

0 commit comments

Comments
 (0)