diff --git a/djangoldp/__init__.py b/djangoldp/__init__.py index efc86af2331f391a7ed00ffbccd8a1c50f0f8651..7f7595fa3486f400aad249cd1e95a12a4a9ce5bc 100644 --- a/djangoldp/__init__.py +++ b/djangoldp/__init__.py @@ -5,4 +5,4 @@ __version__ = '0.0.0' options.DEFAULT_NAMES += ( 'lookup_field', 'rdf_type', 'rdf_context', 'auto_author', 'owner_field', 'owner_urlid_field', 'view_set', 'container_path', 'permission_classes', 'serializer_fields', 'serializer_fields_exclude', 'empty_containers', - 'nested_fields', 'depth', 'permission_roles', 'inherit_permissions', 'public_field') + 'nested_fields', 'depth', 'permission_roles', 'inherit_permissions', 'public_field', 'static_version', 'static_params') diff --git a/djangoldp/management/commands/generate_static_content.py b/djangoldp/management/commands/generate_static_content.py new file mode 100644 index 0000000000000000000000000000000000000000..2cce19e3bc93cac243c6fae6df7e9169d5c84afa --- /dev/null +++ b/djangoldp/management/commands/generate_static_content.py @@ -0,0 +1,107 @@ +import os +import requests +import json +from django.core.management.base import BaseCommand +from django.conf import settings +from django.apps import apps +from urllib.parse import urlparse, urlunparse + +base_uri = getattr(settings, 'BASE_URL', '') +max_depth = getattr(settings, 'MAX_RECURSION_DEPTH', 5) +request_timeout = getattr(settings, 'SSR_REQUEST_TIMEOUT', 10) + +class Command(BaseCommand): + help = 'Generate static content for models having the static_version meta attribute set to 1/true' + + def handle(self, *args, **kwargs): + output_dir = 'ssr' + if not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) + + + for model in apps.get_models(): + if hasattr(model._meta, 'static_version'): + print(f"Generating content for model: {model}") + container_path = model.get_container_path() + url = f'{base_uri}{container_path}' + print(f"Current request url before adding params: {url}") + + if hasattr(model._meta, 'static_params'): + # static_params are added to the url as query parameters + url += '?' + for key, value in model._meta.static_params.items(): + url += f'{key}={value}&' + url = url[:-1] + + print(f"Current request url after adding params: {url}") + response = requests.get(url, timeout=request_timeout) + + if response.status_code == 200: + content = response.text + content = self.update_ids_and_fetch_associated(content, base_uri, output_dir, 0, max_depth) + + filename = container_path[1:-1] + file_path = os.path.join(output_dir, f'{filename}.jsonld') + + print(f"Output file_path: {file_path}") + with open(file_path, 'w') as f: + f.write(content) + self.stdout.write(self.style.SUCCESS(f'Successfully fetched and saved content for {model._meta.model_name} from {url}')) + else: + self.stdout.write(self.style.ERROR(f'Failed to fetch content from {url}: {response.status_code}')) + + def update_ids_and_fetch_associated(self, content, base_uri, output_dir, depth, max_depth): + if depth > max_depth: + return content + + try: + data = json.loads(content) + if isinstance(data, list): + for item in data: + self.update_and_fetch_id(item, base_uri, output_dir, depth, max_depth) + else: + self.update_and_fetch_id(data, base_uri, output_dir, depth, max_depth) + + return json.dumps(data) + except json.JSONDecodeError as e: + self.stdout.write(self.style.ERROR(f'Failed to decode JSON: {e}')) + return content + + def update_and_fetch_id(self, item, base_uri, output_dir, depth, max_depth): + if '@id' in item: + parsed_url = urlparse(item['@id']) + path = f'/ssr{parsed_url.path}' + item['@id'] = urlunparse((parsed_url.scheme, parsed_url.netloc, path, parsed_url.params, parsed_url.query, parsed_url.fragment)) + + associated_url = urlunparse((parsed_url.scheme, parsed_url.netloc, parsed_url.path, parsed_url.params, parsed_url.query, parsed_url.fragment)) + associated_file_path = path[1:-1] + '.jsonld' + associated_file_dir = os.path.dirname(associated_file_path) + + if not os.path.exists(associated_file_dir): + os.makedirs(associated_file_dir) + + try: + response = requests.get(associated_url, timeout=request_timeout) + if response.status_code == 200: + associated_content = self.update_ids_and_fetch_associated(response.text, base_uri, output_dir, depth + 1, max_depth) + associated_file_dir = os.path.dirname(associated_file_path) + + if not os.path.exists(associated_file_dir): + os.makedirs(associated_file_dir) + with open(associated_file_path, 'w') as f: + f.write(associated_content) + self.stdout.write(self.style.SUCCESS(f'Successfully fetched and saved associated content for {associated_url}')) + else: + self.stdout.write(self.style.ERROR(f'Failed to fetch associated content from {associated_url}: {response.status_code}')) + except requests.exceptions.Timeout: + self.stdout.write(self.style.ERROR(f'Request to {associated_url} timed out')) + except requests.exceptions.RequestException as e: + self.stdout.write(self.style.ERROR(f'An error occurred: {e}')) + + for key, value in item.items(): + if isinstance(value, dict): + self.update_and_fetch_id(value, base_uri, output_dir, depth, max_depth) + elif isinstance(value, list): + for sub_item in value: + if isinstance(sub_item, dict): + self.update_and_fetch_id(sub_item, base_uri, output_dir, depth, max_depth) \ No newline at end of file diff --git a/djangoldp/urls.py b/djangoldp/urls.py index 86682c5f7e48d9e58c65cef39835769da43943dd..defa166bed99aac2c0c7a28aa4b2258671703b6a 100644 --- a/djangoldp/urls.py +++ b/djangoldp/urls.py @@ -7,7 +7,8 @@ from django.urls import path, re_path, include from djangoldp.models import LDPSource, Model from djangoldp.permissions import ReadOnly from djangoldp.views import LDPSourceViewSet, WebFingerView, InboxView -from djangoldp.views import LDPViewSet +from djangoldp.views import LDPViewSet, serve_static_content + def __clean_path(path): '''ensures path is Django-friendly''' @@ -35,7 +36,8 @@ urlpatterns = [ re_path(r'^sources/(?P<federation>\w+)/', LDPSourceViewSet.urls(model=LDPSource, fields=['federation', 'urlid'], permission_classes=[ReadOnly], )), re_path(r'^\.well-known/webfinger/?$', WebFingerView.as_view()), - path('inbox/', InboxView.as_view()) + path('inbox/', InboxView.as_view()), + re_path(r'^ssr/(?P<path>.*)$', serve_static_content, name='serve_static_content'), ] if settings.ENABLE_SWAGGER_DOCUMENTATION: diff --git a/djangoldp/views.py b/djangoldp/views.py index 27f0400a4cd62f84ee0ab7614e402d151c172a50..408976900b702d327f5b458878348abd8f52390d 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -5,7 +5,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist from django.db import IntegrityError, transaction -from django.http import JsonResponse, Http404 +from django.http import JsonResponse, Http404, HttpResponseNotFound from django.shortcuts import get_object_or_404 from django.urls import include, re_path, path from django.urls.resolvers import get_resolver @@ -31,6 +31,7 @@ from djangoldp.utils import is_authenticated_user from djangoldp.activities import ActivityQueueService, as_activitystream, ACTIVITY_SAVING_SETTING, ActivityPubService from djangoldp.activities.errors import ActivityStreamDecodeError, ActivityStreamValidationError import logging +import os logger = logging.getLogger('djangoldp') get_user_model()._meta.rdf_context = {"get_full_name": "rdfs:label"} @@ -615,3 +616,23 @@ class WebFingerView(View): def post(self, request, *args, **kwargs): return self.on_request(request) + + +def serve_static_content(request, path): + file_path = os.path.join('ssr', path[:-1]) + if not file_path.endswith('.jsonld'): + file_path += '.jsonld' + + if os.path.exists(file_path): + with open(file_path, 'r') as file: + content = file.read() + + json_content = json.loads(content) + return JsonResponse(json_content, safe=False, status=200, + content_type='application/ld+json', + headers={ + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'public, max-age=3600', + }) + else: + return HttpResponseNotFound('File not found') diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000000000000000000000000000000000000..357305d4de693c8b7256cac6e960f39a029b2b82 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,41 @@ +# Available commands + +## generate_static_content + +You can generate and make available at a /ssr/xxx URI a static copy of the AnonymousUser view of given models. +Those models need to be configured with the `static_version` and `static_params` Meta options like: + +```python +class Location(Model): + name = models.CharField(max_length=255) + address = models.CharField(max_length=255) + zip_code = models.IntegerField() + visible = models.BooleanField(default=False) + + class Meta: + # Allow generating a static version of the container view + static_version = 1 + + # Add some GET parameters to configure the selection of data + static_params = { + "search-fields": "visible", + "search-terms": True, + "search-method": "exact" + } +``` + +You will need additional settings defined either in your settings.yml or settings.py file: + +```yml +BASE_URL: 'http://localhost:8000/' +MAX_RECURSION_DEPTH: 10 # Default value: 5 +SSR_REQUEST_TIME: 20 # Default value 10 (seconds) +``` + +Then you can try it out by executing the following command: + +```sh +python manage.py generate_static_content +``` + +You can also set a cron task or a celery Task to launch this command in a regular basis. \ No newline at end of file diff --git a/docs/create_model.md b/docs/create_model.md index 9297ba5274fcbe142aab297e38b3c3dbdb2a1bac..50fffc9f240ca61dd2c3819962573c14d849084c 100644 --- a/docs/create_model.md +++ b/docs/create_model.md @@ -1,5 +1,5 @@ -### User model requirements +# User model requirements When implementing authentication in your own application, you have two options: @@ -469,3 +469,4 @@ MIDDLEWARE = [ ``` Notice that it will return only HTTP 200 Code. +