diff --git a/backend/tests/README.md b/backend/tests/README.md new file mode 100644 index 00000000..662d3f44 --- /dev/null +++ b/backend/tests/README.md @@ -0,0 +1,126 @@ +# Backend Tests + +This directory contains Django tests for the backend app, specifically for testing the backend configuration API. + +## Structure + +- `test_api_config.py` - Main test file for the backend configuration API endpoints +- `utils.py` - Utility functions for loading test fixtures and creating test data +- `fixtures/` - Directory containing test fixture files with sample configurations + +## Test Files + +### `test_api_config.py` +Contains comprehensive tests for the `/api/config/` endpoint: + +- GET requests (no authentication required) +- POST requests (authentication required) +- Error handling (invalid YAML, missing fields, etc.) +- Backend creation and updating +- Examples management + +### `utils.py` +Utility functions for tests: + +- `load_test_fixture(filename)` - Load fixture files +- `load_invalid_config_example(example_name)` - Load specific invalid config examples +- `create_test_backend_config()` - Generate test configurations programmatically +- `create_test_example()` - Create test example queries + +## Fixtures + +### `fixtures/sample_backend_config.yaml` +Complete backend configuration with all available options, including: +- Complex SPARQL queries for suggestions +- Multiple example queries +- Full configuration options + +### `fixtures/minimal_backend_config.yaml` +Minimal valid backend configuration for basic testing. + +### `fixtures/wikidata_example_config.yaml` +Realistic Wikidata-based configuration with: +- Wikidata-specific prefixes +- Real-world SPARQL queries +- Service-based label resolution + +### `fixtures/invalid_config_examples.yaml` +Collection of invalid configurations for testing error handling: +- Missing required fields +- Invalid YAML syntax +- Type mismatches +- Structural errors + +## Running Tests + +### Run all backend tests: +```bash +python manage.py test backend.tests +``` + +### Run specific test class: +```bash +python manage.py test backend.tests.test_api_config.BackendConfigAPITestCase +``` + +### Run specific test method: +```bash +python manage.py test backend.tests.test_api_config.BackendConfigAPITestCase.test_post_config_create_new_backend +``` + +### Run with verbose output: +```bash +python manage.py test backend.tests -v 2 +``` + +## Test Coverage + +The tests cover: + +1. **Authentication and Authorization** + - GET requests work without authentication + - POST requests require authentication + - Proper 403 responses for unauthenticated POST requests + +2. **Backend Creation** + - Creating new backends via POST + - Auto-creation of minimal backend if it doesn't exist + - Proper validation of required fields + +3. **Backend Updates** + - Updating existing backends via POST + - Overwriting existing examples + - Preserving backend ID during updates + +4. **Error Handling** + - Invalid YAML syntax + - Missing required fields + - Slug mismatches between URL and YAML + - Empty request bodies + - Unsupported HTTP methods (PUT, DELETE) + +5. **Data Integrity** + - Proper creation of Backend and Example objects + - Correct field mappings from YAML to database + - Round-trip testing (POST then GET) + +6. **Response Formats** + - JSON responses for POST requests + - YAML responses for GET requests + - Proper HTTP status codes + - Informative error messages + +## Adding New Tests + +When adding new tests: + +1. Follow the existing naming convention (`test_`) +2. Include comprehensive docstrings +3. Use the utility functions for creating test data +4. Add new fixture files for complex test scenarios +5. Test both success and error cases +6. Verify database state changes where applicable + +## Test Data Cleanup + +Django's TestCase automatically handles database cleanup between tests, so no manual cleanup is required. Test databases are created and destroyed for each test run. \ No newline at end of file diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..74dace9c --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# Django tests for backend app \ No newline at end of file diff --git a/backend/tests/fixtures/invalid_config_examples.yaml b/backend/tests/fixtures/invalid_config_examples.yaml new file mode 100644 index 00000000..54e3a89b --- /dev/null +++ b/backend/tests/fixtures/invalid_config_examples.yaml @@ -0,0 +1,77 @@ +# This file contains various invalid YAML configurations for testing error handling + +# Missing required fields +missing_name: | + config: + backend: + slug: test-missing-name + baseUrl: https://example.com + examples: [] + +# Invalid YAML syntax +invalid_yaml_syntax: | + config: + backend: + name: "Unclosed quote + slug: invalid-yaml + baseUrl: https://example.com + examples: [] + +# Missing config key +missing_config_key: | + backend: + name: Missing Config Key + slug: missing-config + baseUrl: https://example.com + examples: [] + +# Invalid boolean value +invalid_boolean: | + config: + backend: + name: Invalid Boolean + slug: invalid-bool + baseUrl: https://example.com + isDefault: "maybe" + examples: [] + +# Slug mismatch +slug_mismatch: | + config: + backend: + name: Slug Mismatch Test + slug: different-slug-than-url + baseUrl: https://example.com + examples: [] + +# Missing examples key +missing_examples: | + config: + backend: + name: Missing Examples + slug: missing-examples + baseUrl: https://example.com + +# Invalid example structure +invalid_example_structure: | + config: + backend: + name: Invalid Example Structure + slug: invalid-example + baseUrl: https://example.com + examples: + - name: "Missing sort_key and query" + - sort_key: "1" + query: "Missing name" + - name: "Missing query" + sort_key: "2" + +# Non-string values where strings expected +non_string_values: | + config: + backend: + name: 12345 # Should be string but is number + slug: non-string-test + baseUrl: https://example.com + maxDefault: "not a number" # Should be int but is string + examples: [] \ No newline at end of file diff --git a/backend/tests/fixtures/minimal_backend_config.yaml b/backend/tests/fixtures/minimal_backend_config.yaml new file mode 100644 index 00000000..a99d48f5 --- /dev/null +++ b/backend/tests/fixtures/minimal_backend_config.yaml @@ -0,0 +1,11 @@ +config: + backend: + name: Minimal Test Backend + slug: minimal-test + baseUrl: https://minimal.example.com/sparql + isDefault: false + isNoSlugMode: false + examples: + - name: Simple SELECT + sort_key: "1" + query: "SELECT * WHERE { ?s ?p ?o } LIMIT 5" \ No newline at end of file diff --git a/backend/tests/fixtures/sample_backend_config.yaml b/backend/tests/fixtures/sample_backend_config.yaml new file mode 100644 index 00000000..42fe23a4 --- /dev/null +++ b/backend/tests/fixtures/sample_backend_config.yaml @@ -0,0 +1,105 @@ +config: + backend: + name: Sample Backend + slug: sample-backend + baseUrl: https://example.com/sparql + apiToken: sample-token-123 + isDefault: false + isNoSlugMode: false + maxDefault: 100 + filteredLanguage: en,de,fr + dynamicSuggestions: 2 + defaultModeTimeout: 1.5 + mixedModeTimeout: 0.5 + suggestSubjects: | + SELECT DISTINCT ?qleverui_entity ?qleverui_name WHERE { + ?qleverui_entity rdfs:label ?qleverui_name . + FILTER(LANG(?qleverui_name) = "en") + } ORDER BY ?qleverui_name LIMIT 20 + suggestPredicates: | + SELECT DISTINCT ?qleverui_entity ?qleverui_name WHERE { + [] ?qleverui_entity [] . + OPTIONAL { ?qleverui_entity rdfs:label ?qleverui_name } + } ORDER BY ?qleverui_name LIMIT 20 + suggestObjects: | + SELECT DISTINCT ?qleverui_entity ?qleverui_name WHERE { + [] [] ?qleverui_entity . + OPTIONAL { ?qleverui_entity rdfs:label ?qleverui_name } + } ORDER BY ?qleverui_name LIMIT 20 + subjectName: | + ?qleverui_entity rdfs:label ?qleverui_name . + FILTER(LANG(?qleverui_name) = "en") + predicateName: | + ?qleverui_entity rdfs:label ?qleverui_name . + FILTER(LANG(?qleverui_name) = "en") + objectName: | + ?qleverui_entity rdfs:label ?qleverui_name . + FILTER(LANG(?qleverui_name) = "en") + supportedKeywords: select, where, filter, optional, union, minus, limit, offset, order, group, having, distinct, reduced, construct, describe, ask + supportedFunctions: str, lang, datatype, bound, isiri, isblank, isliteral, isnumeric, regex, strlen, substr, ucase, lcase, concat, contains, strstarts, strends, strbefore, strafter + supportedPredicateSuggestions: rdfs:label, rdf:type, rdfs:comment + suggestPrefixnamesForPredicates: true + fillPrefixes: true + filterEntities: false + suggestedPrefixes: | + PREFIX rdf: + PREFIX rdfs: + PREFIX owl: + PREFIX foaf: + PREFIX dc: + PREFIX dcterms: + PREFIX skos: + suggestionEntityVariable: entity + suggestionNameVariable: name + suggestionAltNameVariable: altname + suggestionReversedVariable: reversed + examples: + - name: Basic Query + sort_key: "01" + query: | + SELECT ?subject ?predicate ?object WHERE { + ?subject ?predicate ?object . + } LIMIT 10 + - name: Find All Classes + sort_key: "02" + query: | + PREFIX rdf: + PREFIX rdfs: + + SELECT DISTINCT ?class ?label WHERE { + ?class rdf:type rdfs:Class . + OPTIONAL { ?class rdfs:label ?label } + } ORDER BY ?label LIMIT 50 + - name: Count Entities by Type + sort_key: "03" + query: | + PREFIX rdf: + + SELECT ?type (COUNT(?entity) as ?count) WHERE { + ?entity rdf:type ?type . + } GROUP BY ?type + ORDER BY DESC(?count) + LIMIT 20 + - name: Find Properties with Labels + sort_key: "04" + query: | + PREFIX rdf: + PREFIX rdfs: + + SELECT ?property ?label WHERE { + ?property rdf:type rdf:Property . + OPTIONAL { ?property rdfs:label ?label } + } ORDER BY ?label LIMIT 30 + - name: Complex Query with OPTIONAL and FILTER + sort_key: "05" + query: | + PREFIX rdf: + PREFIX rdfs: + PREFIX foaf: + + SELECT ?person ?name ?email WHERE { + ?person rdf:type foaf:Person . + ?person foaf:name ?name . + OPTIONAL { ?person foaf:email ?email } + FILTER(LANG(?name) = "en" || LANG(?name) = "") + } ORDER BY ?name LIMIT 25 \ No newline at end of file diff --git a/backend/tests/fixtures/wikidata_example_config.yaml b/backend/tests/fixtures/wikidata_example_config.yaml new file mode 100644 index 00000000..f01aa8b4 --- /dev/null +++ b/backend/tests/fixtures/wikidata_example_config.yaml @@ -0,0 +1,89 @@ +config: + backend: + name: Wikidata Example + slug: wikidata-example + baseUrl: https://query.wikidata.org/sparql + isDefault: false + isNoSlugMode: false + maxDefault: 50 + filteredLanguage: en + dynamicSuggestions: 3 + defaultModeTimeout: 2.0 + mixedModeTimeout: 1.0 + supportedKeywords: select, where, filter, optional, union, minus, limit, offset, order, group, having, distinct, reduced, construct, describe, ask, service, bind, values + supportedFunctions: str, lang, datatype, bound, isiri, isblank, isliteral, isnumeric, regex, strlen, substr, ucase, lcase, concat, contains, strstarts, strends, strbefore, strafter, now, year, month, day, hours, minutes, seconds + supportedPredicateSuggestions: wdt:P31, wdt:P279, rdfs:label, skos:altLabel + suggestPrefixnamesForPredicates: true + fillPrefixes: true + filterEntities: false + suggestedPrefixes: | + PREFIX wd: + PREFIX wdt: + PREFIX wikibase: + PREFIX p: + PREFIX ps: + PREFIX pq: + PREFIX pr: + PREFIX rdfs: + PREFIX skos: + PREFIX schema: + suggestionEntityVariable: item + suggestionNameVariable: itemLabel + suggestionAltNameVariable: itemAltLabel + examples: + - name: Countries with Population + sort_key: "A1" + query: | + PREFIX wd: + PREFIX wdt: + + SELECT ?country ?countryLabel ?population WHERE { + ?country wdt:P31 wd:Q6256 . # instance of country + ?country wdt:P1082 ?population . # population + SERVICE wikibase:label { bd:serviceParam wikibase:language "en" . } + } + ORDER BY DESC(?population) + LIMIT 20 + - name: Scientists Born After 1950 + sort_key: "A2" + query: | + PREFIX wd: + PREFIX wdt: + + SELECT ?scientist ?scientistLabel ?birthDate WHERE { + ?scientist wdt:P31 wd:Q5 . # instance of human + ?scientist wdt:P106 wd:Q901 . # occupation: scientist + ?scientist wdt:P569 ?birthDate . # date of birth + FILTER(YEAR(?birthDate) > 1950) + SERVICE wikibase:label { bd:serviceParam wikibase:language "en" . } + } + ORDER BY DESC(?birthDate) + LIMIT 15 + - name: Cities in Germany + sort_key: "A3" + query: | + PREFIX wd: + PREFIX wdt: + + SELECT ?city ?cityLabel ?population WHERE { + ?city wdt:P31/wdt:P279* wd:Q515 . # instance/subclass of city + ?city wdt:P17 wd:Q183 . # country: Germany + OPTIONAL { ?city wdt:P1082 ?population } + SERVICE wikibase:label { bd:serviceParam wikibase:language "en" . } + } + ORDER BY DESC(?population) + LIMIT 25 + - name: Programming Languages with Creation Date + sort_key: "A4" + query: | + PREFIX wd: + PREFIX wdt: + + SELECT ?language ?languageLabel ?inception ?creator ?creatorLabel WHERE { + ?language wdt:P31 wd:Q9143 . # instance of programming language + OPTIONAL { ?language wdt:P571 ?inception } # inception date + OPTIONAL { ?language wdt:P178 ?creator } # developer/creator + SERVICE wikibase:label { bd:serviceParam wikibase:language "en" . } + } + ORDER BY ?inception + LIMIT 30 \ No newline at end of file diff --git a/backend/tests/test_api_config.py b/backend/tests/test_api_config.py new file mode 100644 index 00000000..70159510 --- /dev/null +++ b/backend/tests/test_api_config.py @@ -0,0 +1,258 @@ +from django.test import TestCase, Client +from django.contrib.auth import get_user_model +from django.http import JsonResponse +from backend.models import Backend, Example +import json + + +class BackendConfigAPITestCase(TestCase): + """Test cases for the backend config API endpoints.""" + + def setUp(self): + """Set up test fixtures.""" + self.client = Client() + self.User = get_user_model() + + # Create test user + self.user = self.User.objects.create_superuser( + username='testuser', + email='test@example.com', + password='testpass123' + ) + + # Create existing backend for testing updates + self.existing_backend = Backend.objects.create( + name='Existing Backend', + slug='existing-backend', + baseUrl='http://existing.com', + isDefault=False + ) + + def test_get_config_no_auth_required(self): + """Test that GET requests don't require authentication.""" + response = self.client.get('/api/config/existing-backend') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['content-type'], 'text/yaml') + self.assertIn('config:', response.content.decode()) + self.assertIn('backend:', response.content.decode()) + self.assertIn('Existing Backend', response.content.decode()) + + def test_get_config_nonexistent_backend(self): + """Test GET request for non-existent backend returns error.""" + response = self.client.get('/api/config/nonexistent') + + self.assertEqual(response.status_code, 500) + self.assertIn('Error:', response.content.decode()) + + def test_post_config_requires_auth(self): + """Test that POST requests require authentication.""" + config_yaml = """config: + backend: + name: Test Backend + slug: test-backend + baseUrl: http://test.com + examples: []""" + + response = self.client.post( + '/api/config/test-backend', + data=config_yaml, + content_type='text/yaml' + ) + + self.assertEqual(response.status_code, 403) + self.assertIn('Authentication required', response.content.decode()) + + def test_post_config_create_new_backend(self): + """Test creating a new backend via POST.""" + config_yaml = """config: + backend: + name: New Test Backend + slug: new-test-backend + baseUrl: http://newtest.com + isDefault: false + isNoSlugMode: false + maxDefault: 50 + examples: + - name: Test Query 1 + sort_key: "1" + query: "SELECT * WHERE { ?s ?p ?o } LIMIT 10" + - name: Test Query 2 + sort_key: "2" + query: "SELECT ?subject WHERE { ?subject rdf:type ?type } LIMIT 5" +""" + + # Login user + self.client.force_login(self.user) + + response = self.client.post( + '/api/config/new-test-backend', + data=config_yaml, + content_type='text/yaml' + ) + + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode()) + self.assertEqual(response_data['status'], 'success') + self.assertIn('created successfully', response_data['message']) + + # Verify backend was created + backend = Backend.objects.get(slug='new-test-backend') + self.assertEqual(backend.name, 'New Test Backend') + self.assertEqual(backend.baseUrl, 'http://newtest.com') + self.assertEqual(backend.maxDefault, 50) + + # Verify examples were created + examples = Example.objects.filter(backend=backend) + self.assertEqual(examples.count(), 2) + self.assertEqual(examples.first().name, 'Test Query 1') + + def test_post_config_update_existing_backend(self): + """Test updating an existing backend via POST.""" + config_yaml = """config: + backend: + name: Updated Existing Backend + slug: existing-backend + baseUrl: http://updated.com + isDefault: false + maxDefault: 75 + examples: + - name: Updated Query + sort_key: "1" + query: "SELECT * WHERE { ?updated ?query ?example }" +""" + + # Login user + self.client.force_login(self.user) + + response = self.client.post( + '/api/config/existing-backend', + data=config_yaml, + content_type='text/yaml' + ) + + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content.decode()) + self.assertEqual(response_data['status'], 'success') + self.assertIn('updated successfully', response_data['message']) + + # Verify backend was updated + backend = Backend.objects.get(slug='existing-backend') + self.assertEqual(backend.name, 'Updated Existing Backend') + self.assertEqual(backend.baseUrl, 'http://updated.com') + self.assertEqual(backend.maxDefault, 75) + + # Verify examples were updated + examples = Example.objects.filter(backend=backend) + self.assertEqual(examples.count(), 1) + self.assertEqual(examples.first().name, 'Updated Query') + + def test_post_config_empty_body(self): + """Test POST with empty body returns error.""" + self.client.force_login(self.user) + + response = self.client.post( + '/api/config/empty-test', + data='', + content_type='text/yaml' + ) + + self.assertEqual(response.status_code, 400) + self.assertIn('Empty request body', response.content.decode()) + + def test_post_config_invalid_yaml(self): + """Test POST with invalid YAML returns error.""" + invalid_yaml = """config: + backend: + name: "Invalid YAML + slug: invalid +""" + + self.client.force_login(self.user) + + response = self.client.post( + '/api/config/invalid-test', + data=invalid_yaml, + content_type='text/yaml' + ) + + self.assertEqual(response.status_code, 400) + response_data = json.loads(response.content.decode()) + self.assertEqual(response_data['status'], 'error') + self.assertIn('message', response_data) + + def test_post_config_slug_mismatch(self): + """Test POST with mismatched slug in YAML returns error.""" + config_yaml = """config: + backend: + name: Mismatched Slug Test + slug: different-slug + baseUrl: http://test.com + examples: []""" + + self.client.force_login(self.user) + + response = self.client.post( + '/api/config/test-slug', + data=config_yaml, + content_type='text/yaml' + ) + + self.assertEqual(response.status_code, 400) + response_data = json.loads(response.content.decode()) + self.assertEqual(response_data['status'], 'error') + self.assertIn('slugs must match', response_data['message']) + + def test_unsupported_http_method(self): + """Test unsupported HTTP methods return 405.""" + response = self.client.put('/api/config/test-put') + self.assertEqual(response.status_code, 405) + + response_data = json.loads(response.content.decode()) + self.assertEqual(response_data['status'], 'error') + self.assertIn('Method PUT not allowed', response_data['message']) + + response = self.client.delete('/api/config/test-delete') + self.assertEqual(response.status_code, 405) + + response_data = json.loads(response.content.decode()) + self.assertEqual(response_data['status'], 'error') + self.assertIn('Method DELETE not allowed', response_data['message']) + + def test_config_roundtrip(self): + """Test creating a backend via POST and retrieving it via GET.""" + config_yaml = """config: + backend: + name: Roundtrip Test Backend + slug: roundtrip-test + baseUrl: http://roundtrip.com + isDefault: false + isNoSlugMode: false + filteredLanguage: en,de + dynamicSuggestions: 2 + examples: + - name: Example 1 + sort_key: "A" + query: "PREFIX ex: SELECT * WHERE { ?s ex:name ?name }" +""" + + # Create backend via POST + self.client.force_login(self.user) + post_response = self.client.post( + '/api/config/roundtrip-test', + data=config_yaml, + content_type='text/yaml' + ) + self.assertEqual(post_response.status_code, 200) + + # Retrieve backend via GET + self.client.logout() # Test that GET doesn't require auth + get_response = self.client.get('/api/config/roundtrip-test') + self.assertEqual(get_response.status_code, 200) + + # Verify content + yaml_content = get_response.content.decode() + self.assertIn('Roundtrip Test Backend', yaml_content) + self.assertIn('http://roundtrip.com', yaml_content) + self.assertIn('Example 1', yaml_content) + self.assertIn('PREFIX ex:', yaml_content) \ No newline at end of file diff --git a/backend/tests/utils.py b/backend/tests/utils.py new file mode 100644 index 00000000..00a60f33 --- /dev/null +++ b/backend/tests/utils.py @@ -0,0 +1,109 @@ +""" +Utility functions for backend tests. +""" +import os +import yaml + + +def load_test_fixture(filename): + """ + Load a test fixture file from the fixtures directory. + + Args: + filename (str): Name of the fixture file + + Returns: + str: Content of the fixture file + """ + fixtures_dir = os.path.join(os.path.dirname(__file__), 'fixtures') + filepath = os.path.join(fixtures_dir, filename) + + with open(filepath, 'r', encoding='utf-8') as f: + return f.read() + + +def load_invalid_config_example(example_name): + """ + Load a specific invalid config example from the invalid_config_examples.yaml file. + + Args: + example_name (str): Name of the example to load + + Returns: + str: The invalid config YAML string + """ + fixtures_dir = os.path.join(os.path.dirname(__file__), 'fixtures') + filepath = os.path.join(fixtures_dir, 'invalid_config_examples.yaml') + + with open(filepath, 'r', encoding='utf-8') as f: + examples = yaml.safe_load(f) + + if example_name not in examples: + raise ValueError(f"Invalid config example '{example_name}' not found") + + return examples[example_name] + + +def create_test_backend_config(name, slug, base_url="https://example.com/sparql", **kwargs): + """ + Create a test backend configuration YAML string with custom values. + + Args: + name (str): Backend name + slug (str): Backend slug + base_url (str): Backend base URL + **kwargs: Additional backend configuration parameters + + Returns: + str: YAML configuration string + """ + config = { + 'config': { + 'backend': { + 'name': name, + 'slug': slug, + 'baseUrl': base_url, + 'isDefault': kwargs.get('isDefault', False), + 'isNoSlugMode': kwargs.get('isNoSlugMode', False), + }, + 'examples': kwargs.get('examples', []) + } + } + + # Add optional parameters + optional_fields = [ + 'maxDefault', 'filteredLanguage', 'dynamicSuggestions', + 'defaultModeTimeout', 'mixedModeTimeout', 'apiToken', + 'supportedKeywords', 'supportedFunctions', 'supportedPredicateSuggestions', + 'suggestPrefixnamesForPredicates', 'fillPrefixes', 'filterEntities', + 'suggestedPrefixes', 'suggestionEntityVariable', 'suggestionNameVariable', + 'suggestionAltNameVariable', 'suggestionReversedVariable', + 'suggestSubjects', 'suggestPredicates', 'suggestObjects', + 'subjectName', 'predicateName', 'objectName', + 'alternativeSubjectName', 'alternativePredicateName', 'alternativeObjectName' + ] + + for field in optional_fields: + if field in kwargs: + config['config']['backend'][field] = kwargs[field] + + return yaml.dump(config, default_flow_style=False, sort_keys=False) + + +def create_test_example(name, sort_key, query): + """ + Create a test example dictionary. + + Args: + name (str): Example name + sort_key (str): Example sort key + query (str): SPARQL query + + Returns: + dict: Example dictionary + """ + return { + 'name': name, + 'sort_key': sort_key, + 'query': query + } \ No newline at end of file diff --git a/backend/views.py b/backend/views.py index 4ed7d994..c4511140 100644 --- a/backend/views.py +++ b/backend/views.py @@ -8,6 +8,9 @@ from django.http.response import HttpResponse, HttpResponseForbidden from django.shortcuts import redirect, render from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import render +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required from rest_framework import generics, mixins, viewsets from backend.management.commands.config import Command as ConfigCommand @@ -250,20 +253,65 @@ def prefixes(request, backend): # Handle API request to /api/config/ # -# NOTE: This used to require a login, but none of the information in the config -# is really secret + it is practical for users who want to set up their own -# instance of the QLever UI to be able to easily access the config of existing -# instances of the QLever UI. -# -# @login_required +# GET: Returns the backend config as YAML (no authentication required) +# POST: Creates/updates backend config from YAML (authentication required) +@csrf_exempt def config(request, backend): - print_to_log(f"API call to `config` with backend `{backend}`") + print_to_log(f"API call to `config` with backend `{backend}` method `{request.method}`") command = ConfigCommand() - try: - config_yaml = command.handle(backend_slug=backend, returnOutput=True) - except Exception as e: - return HttpResponse("Error: " + str(e), status=500) - return HttpResponse(config_yaml, content_type="text/yaml") + + if request.method == "GET": + # GET request - return config (no auth required) + try: + config_yaml = command.handle(backend_slug=backend, returnOutput=True) + except Exception as e: + return HttpResponse("Error: " + str(e), status=500) + return HttpResponse(config_yaml, content_type="text/yaml") + + elif request.method == "POST": + # POST request - create/update config (auth required) + if not request.user.is_authenticated: + return HttpResponseForbidden("Authentication required for creating/updating backend config") + + try: + # Get YAML content from request body + config_yaml = request.body.decode('utf-8') + + if not config_yaml.strip(): + return HttpResponse("Error: Empty request body", status=400) + + # Check if backend exists, if not create it first + backend_exists = Backend.objects.filter(slug=backend).exists() + + if not backend_exists: + # Create a minimal backend first (required fields only) + Backend.objects.create( + name=f"Backend {backend}", # Default name, will be overwritten + slug=backend, + baseUrl="http://localhost:7001" # Default URL, will be overwritten + ) + print_to_log(f"Created new backend with slug '{backend}'") + + # Use the set_backend_config method to update the backend + command.set_backend_config(backend, config_yaml) + + action = "updated" if backend_exists else "created" + return JsonResponse({ + "status": "success", + "message": f"Backend config for '{backend}' {action} successfully" + }) + + except Exception as e: + return JsonResponse({ + "status": "error", + "message": str(e) + }, status=400) + + else: + return JsonResponse({ + "status": "error", + "message": f"Method {request.method} not allowed" + }, status=405) # Helpers