diff --git a/.gitignore b/.gitignore index 77cf85ee..09e9d99f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ dockerize/webroot # test cache qgis-app/*/tests/*/cache qgis-app/api/tests/*/ +!qgis-app/wavefronts/tests/wavefrontfiles/*.zip # whoosh_index qgis-app/whoosh_index/ diff --git a/HUB_API.md b/HUB_API.md new file mode 100644 index 00000000..72b727d6 --- /dev/null +++ b/HUB_API.md @@ -0,0 +1,52 @@ +API URL Configuration +# QGIS Resources Hub API Documentation + +The `urlpatterns` list routes URLs to views. For more information please see: +[https://docs.djangoproject.com/en/3.2/topics/http/urls/](https://docs.djangoproject.com/en/3.2/topics/http/urls/) + +## Endpoints + +### Resources +- **URL:** `/resources/` +- **Method:** `GET` +- **View:** `ResourceAPIList.as_view()` +- **Name:** `resource-list` +- **Description:** Retrieves a list of all resources. + +### Resource by UUID +- **URL:** `/resource//` +- **Method:** `GET` +- **View:** `ResourceAPIDownload.as_view()` +- **Name:** `resource-download` +- **Description:** Downloads a specific resource identified by UUID. + +### Create Resource +- **URL:** `/resource/create` +- **Method:** `POST` +- **View:** `ResourceCreateView.as_view()` +- **Name:** `resource-create` +- **Description:** Creates a new resource. +- **Request example with cURL:** + ```sh + curl --location 'http://localhost:62202/api/v1/resource/create' \ + --header 'Authorization: Bearer ' \ + --form 'file=@"path/to/the/file.zip"' \ + --form 'thumbnail_full=@"path/to/the/thumbnail.png"' \ + --form 'name="My model"' \ + --form 'description="Little description"' \ + --form 'tags="notag"' \ + --form 'resource_type="model"' + ``` + +### Resource Detail +- **URL:** `/resource///` +- **Methods:** `GET`, `PUT`, `DELETE` +- **View:** `ResourceDetailView.as_view()` +- **Name:** `resource-detail` +- **Description:** Handles the detailed display, update, and deletion of a specific resource based on its type and UUID. +- **Example:** + To access the details of a resource with type 'style' and UUID '123e4567-e89b-12d3-a456-426614174000': + ```sh + GET /resource/style/123e4567-e89b-12d3-a456-426614174000/ + ``` +- **Permissions:** Ensure that the user has the necessary permissions (staff or creator) to view, update, or delete the resource details. diff --git a/qgis-app/api/forms.py b/qgis-app/api/forms.py new file mode 100644 index 00000000..1431e358 --- /dev/null +++ b/qgis-app/api/forms.py @@ -0,0 +1,14 @@ +from django.forms import CharField, ModelForm +from api.models import UserOutstandingToken + + +class UserTokenForm(ModelForm): + """ + Form for token description editing + """ + + class Meta: + model = UserOutstandingToken + fields = ( + "description", + ) \ No newline at end of file diff --git a/qgis-app/api/migrations/0001_initial.py b/qgis-app/api/migrations/0001_initial.py new file mode 100644 index 00000000..950f36bf --- /dev/null +++ b/qgis-app/api/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.16 on 2024-09-12 07:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('token_blacklist', '0012_alter_outstandingtoken_user'), + ] + + operations = [ + migrations.CreateModel( + name='UserOutstandingToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_blacklisted', models.BooleanField(default=False)), + ('is_newly_created', models.BooleanField(default=False)), + ('description', models.CharField(blank=True, help_text="Describe this token so that it's easier to remember where you're using it.", max_length=512, null=True, verbose_name='Description')), + ('last_used_on', models.DateTimeField(blank=True, null=True, verbose_name='Last used on')), + ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='token_blacklist.outstandingtoken')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/qgis-app/api/models.py b/qgis-app/api/models.py index 6b202199..6db86a94 100644 --- a/qgis-app/api/models.py +++ b/qgis-app/api/models.py @@ -1 +1,32 @@ -# Create your models here. +from base.models.processing_models import Resource +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken +from django.contrib.auth.models import User + +class UserOutstandingToken(models.Model): + """ + Hub outstanding token + """ + user = models.ForeignKey( + User, + on_delete=models.CASCADE + ) + token = models.ForeignKey( + OutstandingToken, + on_delete=models.CASCADE + ) + is_blacklisted = models.BooleanField(default=False) + is_newly_created = models.BooleanField(default=False) + description = models.CharField( + verbose_name=_("Description"), + help_text=_("Describe this token so that it's easier to remember where you're using it."), + max_length=512, + blank=True, + null=True, + ) + last_used_on = models.DateTimeField( + verbose_name=_("Last used on"), + blank=True, + null=True + ) \ No newline at end of file diff --git a/qgis-app/api/serializers.py b/qgis-app/api/serializers.py index 367f87c0..116efea4 100644 --- a/qgis-app/api/serializers.py +++ b/qgis-app/api/serializers.py @@ -2,14 +2,20 @@ from geopackages.models import Geopackage from models.models import Model from rest_framework import serializers -from sorl_thumbnail_serializer.fields import HyperlinkedSorlImageField -from styles.models import Style +from styles.models import Style, StyleType from layerdefinitions.models import LayerDefinition -from wavefronts.models import Wavefront +from wavefronts.models import WAVEFRONTS_STORAGE_PATH, Wavefront from sorl.thumbnail import get_thumbnail from django.conf import settings -from os.path import exists +from os.path import exists, join from django.templatetags.static import static +from wavefronts.validator import WavefrontValidator + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from styles.file_handler import read_xml_style, validator as style_validator +from layerdefinitions.file_handler import get_provider, get_url_datasource, validator as layer_validator +import tempfile class ResourceBaseSerializer(serializers.ModelSerializer): creator = serializers.ReadOnlyField(source="get_creator_name") @@ -97,6 +103,51 @@ class StyleSerializer(ResourceBaseSerializer): class Meta(ResourceBaseSerializer.Meta): model = Style + def validate(self, attrs): + """ + Validate a style file. + We need to check if the uploaded file is a valid XML file. + Then, we upload the file to a temporary file, validate it + and check if the style type is defined. + """ + attrs = super().validate(attrs) + file = attrs.get("file") + + if not file: + raise ValidationError(_("File is required.")) + + if file.size == 0: + raise ValidationError(_("Uploaded file is empty.")) + try: + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + for chunk in file.chunks(): + temp_file.write(chunk) + temp_file.flush() + + with open(temp_file.name, 'rb') as xml_file: + style = style_validator(xml_file) + xml_parse = read_xml_style(xml_file) + if xml_parse: + self.style_type, created = StyleType.objects.get_or_create( + symbol_type=xml_parse["type"], + defaults={ + "name": xml_parse["type"].title(), + "description": "Automatically created from '" + "'an uploaded Style file", + } + ) + + if not style: + raise ValidationError( + _("Undefined style type. Please register your style type.") + ) + finally: + import os + if temp_file and os.path.exists(temp_file.name): + os.remove(temp_file.name) + + return attrs + class LayerDefinitionSerializer(ResourceBaseSerializer): class Meta(ResourceBaseSerializer.Meta): model = LayerDefinition @@ -104,10 +155,50 @@ class Meta(ResourceBaseSerializer.Meta): def get_resource_subtype(self, obj): return None + def validate(self, attrs): + """ + Validate a qlr file. + We need to check if the uploaded file is a valid QLR file. + Then, we upload the file to a temporary file and validate it + """ + attrs = super().validate(attrs) + file = attrs.get("file") + + if not file: + raise ValidationError(_("File is required.")) + + if file.size == 0: + raise ValidationError(_("Uploaded file is empty.")) + try: + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + for chunk in file.chunks(): + temp_file.write(chunk) + temp_file.flush() + + with open(temp_file.name, 'rb') as qlr_file: + layer_validator(qlr_file) + self.url_datasource = get_url_datasource(qlr_file) + self.provider = get_provider(qlr_file) + + + finally: + import os + if temp_file and os.path.exists(temp_file.name): + os.remove(temp_file.name) + + return attrs class WavefrontSerializer(ResourceBaseSerializer): class Meta(ResourceBaseSerializer.Meta): model = Wavefront def get_resource_subtype(self, obj): - return None \ No newline at end of file + return None + + def validate(self, attrs): + attrs = super().validate(attrs) + file = attrs.get("file") + if file and file.name.endswith('.zip'): + valid_3dmodel = WavefrontValidator(file).validate_wavefront() + self.new_filepath = join(WAVEFRONTS_STORAGE_PATH, valid_3dmodel) + return attrs \ No newline at end of file diff --git a/qgis-app/api/templates/user_token_base.html b/qgis-app/api/templates/user_token_base.html new file mode 100644 index 00000000..cc14ae21 --- /dev/null +++ b/qgis-app/api/templates/user_token_base.html @@ -0,0 +1,22 @@ +{% extends BASE_TEMPLATE %}{% load i18n %} +{% block app_title %} +

{{ title }}

+{% endblock %} + +{% block menu %} +{{ block.super }} +
{% csrf_token %} +
+

+ +

+
+
+ +{% endblock %} diff --git a/qgis-app/api/templates/user_token_delete.html b/qgis-app/api/templates/user_token_delete.html new file mode 100644 index 00000000..d1dc54e7 --- /dev/null +++ b/qgis-app/api/templates/user_token_delete.html @@ -0,0 +1,19 @@ +{% extends 'user_token_base.html' %}{% load i18n %} +{% block content %} +

Delete token of "{{ username }}"

+
{% csrf_token %} +
+

{% trans "You asked to delete a token.
It will be permanently deleted and this action cannot be undone.
Please confirm." %}

+
+
+ + + + {% trans "Cancel" %} + +
+
+{% endblock %} diff --git a/qgis-app/api/templates/user_token_detail.html b/qgis-app/api/templates/user_token_detail.html new file mode 100644 index 00000000..00d941cd --- /dev/null +++ b/qgis-app/api/templates/user_token_detail.html @@ -0,0 +1,115 @@ +{% extends 'user_token_base.html' %}{% load i18n %} +{% block content %} +

{% trans "New Token" %}

+
+ + {% trans "To enhance the security of the plugin token, it will be displayed only once. Please ensure to save it in a secure location. If the token is lost, you can generate a new one at any time." %} +
+ +
+ + + + + + + + +
{% trans "Access token" %} + +
+ + {% trans "Copy token to clipboard" %} +
+
+ +
+ + +{% endblock %} + +{% block extracss %} +{{ block.super }} + +{% endblock %} + +{% block extrajs %} + +{% endblock %} \ No newline at end of file diff --git a/qgis-app/api/templates/user_token_form.html b/qgis-app/api/templates/user_token_form.html new file mode 100644 index 00000000..81fb45de --- /dev/null +++ b/qgis-app/api/templates/user_token_form.html @@ -0,0 +1,32 @@ +{% extends 'user_token_base.html' %}{% load i18n %} +{% block content %} +

{% trans "Edit token description " %} {{ token.jti }}

+ +{% if form.errors %} +
+ +

{% trans "The form contains errors and cannot be submitted, please check the fields highlighted in red." %}

+
+{% endif %} +{% if form.non_field_errors %} +
+ + {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+{% endif %} +
{% csrf_token %} + {% include "base/form_snippet.html" %} +
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/qgis-app/api/templates/user_token_list.html b/qgis-app/api/templates/user_token_list.html new file mode 100644 index 00000000..d94cd0ff --- /dev/null +++ b/qgis-app/api/templates/user_token_list.html @@ -0,0 +1,60 @@ +{% extends 'user_token_base.html' %}{% load i18n %} +{% block content %} +

{% trans "My Tokens" %}

+

+ The token allows access to the Resources API, enabling you to create, view, update, or delete resources. + For more details, refer to the HUB_API.md file. +

+{% if object_list.count %} +
+ + + + + + {% comment %} {% endcomment %} + + + + + + {% for user_token in object_list %} + + + + + + + {% endfor %} + +
{% trans "Description" %}{% trans "Created at" %}{% trans "Expires at" %}{% trans "Last used at" %}{% trans "Manage" %}
{{ user_token.description|default:"-" }}{{ user_token.token.created_at }} UTC{{ user_token.last_used_on|default:"-" }}{% if user_token.last_used_on %} UTC{% endif %} +   + +
+
+{% else %} +
+ + {% trans "This list is empty!" %} +
+{% endif %} + +{% endblock %} + +{% block extracss %} +{{ block.super }} + +{% endblock %} diff --git a/qgis-app/api/tests/test_manage_token.py b/qgis-app/api/tests/test_manage_token.py new file mode 100644 index 00000000..f3714b6d --- /dev/null +++ b/qgis-app/api/tests/test_manage_token.py @@ -0,0 +1,156 @@ +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken, api_settings +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken +from api.models import UserOutstandingToken +from django.contrib.auth.models import User +from django.test import TestCase + + +class UserTokenDetailViewTests(TestCase): + fixtures = ["fixtures/simplemenu.json"] + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user(username='testuser', password='password') + self.client.login(username='testuser', password='password') + self.refresh = RefreshToken.for_user(self.user) + self.outstanding_token = OutstandingToken.objects.get(jti=self.refresh['jti']) + self.user_token = UserOutstandingToken.objects.create( + user=self.user, + token=self.outstanding_token, + is_blacklisted=False, + is_newly_created=True + ) + self.url = reverse('user_token_detail', args=[self.user_token.pk]) + + def test_user_token_detail_view(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + # self.assertContains(response, self.user.username) + + def test_user_token_detail_view_invalid_token(self): + self.user_token.is_blacklisted = True + self.user_token.save() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + +class TestUserTokenListView(TestCase): + fixtures = ["fixtures/simplemenu.json"] + + def setUp(self): + super(TestUserTokenListView, self).setUp() + self.user = User.objects.create_user( + username="testuser", email="testuser@example.com", password="password" + ) + self.client.login(username="testuser", password="password") + refresh = RefreshToken.for_user(self.user) + jti = refresh[api_settings.JTI_CLAIM] + outstanding_token = OutstandingToken.objects.get(jti=jti) + UserOutstandingToken.objects.create( + user=self.user, + token=outstanding_token, + is_blacklisted=False, + is_newly_created=True + ) + + def test_user_token_list_view(self): + url = reverse("user_token_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Description") + self.assertTemplateUsed(response, "user_token_list.html") + + def test_user_token_list_view_no_tokens(self): + UserOutstandingToken.objects.all().delete() + url = reverse("user_token_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This list is empty!") + self.assertTemplateUsed(response, "user_token_list.html") + + +class TestUserTokenCreate(TestCase): + fixtures = ["fixtures/simplemenu.json"] + + def setUp(self): + super(TestUserTokenCreate, self).setUp() + self.user = User.objects.create_user( + username="testuser", email="testuser@example.com", password="password" + ) + self.client.login(username="testuser", password="password") + + def test_user_token_create(self): + url = reverse("user_token_create") + response = self.client.post(url, follow=True) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "user_token_detail.html") + self.assertContains(response, "access_token") + + def test_user_token_create_redirect(self): + url = reverse("user_token_create") + response = self.client.post(url) + self.assertRedirects(response, reverse("user_token_detail", args=[2])) + + +class TestUserTokenUpdate(TestCase): + fixtures = ["fixtures/simplemenu.json"] + + def setUp(self): + super(TestUserTokenUpdate, self).setUp() + self.user = User.objects.create_user( + username="testuser", email="testuser@example.com", password="password" + ) + self.client.login(username="testuser", password="password") + refresh = RefreshToken.for_user(self.user) + jti = refresh[api_settings.JTI_CLAIM] + self.outstanding_token = OutstandingToken.objects.get(jti=jti) + self.user_token = UserOutstandingToken.objects.create( + user=self.user, + token=self.outstanding_token, + is_blacklisted=False, + is_newly_created=True, + ) + self.url = reverse("user_token_update", args=[self.user_token.pk]) + + def test_user_token_update_get(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "user_token_form.html") + + def test_user_token_update_post_valid(self): + data = {"description": "Updated description"} + response = self.client.post(self.url, data) + self.assertRedirects(response, reverse("user_token_list")) + self.user_token.refresh_from_db() + self.assertEqual(self.user_token.description, "Updated description") + + +class TestUserTokenDelete(TestCase): + fixtures = ["fixtures/simplemenu.json"] + + def setUp(self): + super(TestUserTokenDelete, self).setUp() + self.user = User.objects.create_user( + username="testuser", email="testuser@example.com", password="password" + ) + self.client.login(username="testuser", password="password") + refresh = RefreshToken.for_user(self.user) + jti = refresh[api_settings.JTI_CLAIM] + self.outstanding_token = OutstandingToken.objects.get(jti=jti) + self.user_token = UserOutstandingToken.objects.create( + user=self.user, + token=self.outstanding_token, + is_blacklisted=False, + is_newly_created=True + ) + + def test_user_token_delete_view(self): + url = reverse("user_token_delete", args=[self.user_token.pk]) + response = self.client.post(url, {"delete_confirm": True}, follow=True) + self.assertRedirects(response, reverse("user_token_list")) + self.user_token.refresh_from_db() + self.assertTrue(self.user_token.is_blacklisted) + self.outstanding_token.refresh_from_db() + self.assertTrue(self.outstanding_token.blacklistedtoken) + self.assertContains(response, "The token has been successfully deleted.") \ No newline at end of file diff --git a/qgis-app/api/tests/test_resources_api.py b/qgis-app/api/tests/test_resources_api.py new file mode 100644 index 00000000..45c6e5d5 --- /dev/null +++ b/qgis-app/api/tests/test_resources_api.py @@ -0,0 +1,409 @@ + +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken +from django.contrib.auth.models import User +from geopackages.models import Geopackage +from layerdefinitions.models import LayerDefinition +from django.urls import reverse +from models.models import Model +from styles.models import Style +import os +import tempfile +from django.test import override_settings +from os.path import dirname, join +from django.core.files.uploadedfile import SimpleUploadedFile +from wavefronts.models import Wavefront + +GPKG_DIR = join(dirname(dirname(dirname(__file__))), "geopackages", "tests", "gpkgfiles") +LAYERDEFINITION_DIR = join(dirname(dirname(dirname(__file__))), "layerdefinitions", "tests", "testfiles") +MODELS_DIR = join(dirname(dirname(dirname(__file__))), "models", "tests", "modelfiles") +STYLES_DIR = join(dirname(dirname(dirname(__file__))), "styles", "tests", "stylefiles") +WAVEFRONT_DIR = join(dirname(dirname(dirname(__file__))), "wavefronts", "tests", "wavefrontfiles") + + +class SetUpTest: + """ + SetUp for all Test Class + """ + + fixtures = ["fixtures/simplemenu.json"] + + def setUp(self): + self.thumbnail = join(GPKG_DIR, "thumbnail.png") + self.thumbnail_content = open(self.thumbnail, "rb") + self.gpkg_file = join(GPKG_DIR, "spiky-polygons.gpkg") + self.gpkg_file_content = open(self.gpkg_file, "rb") + self.qlr_file = join(LAYERDEFINITION_DIR, "my-vapour-pressure.qlr") + self.qlr_file_content = open(self.qlr_file, "rb") + self.modelzip_file = join(MODELS_DIR, "example.zip") + self.modelzip_file_content = open(self.modelzip_file, "rb") + self.stylexml_file = join(STYLES_DIR, "cattrail.xml") + self.stylexml_file_content = open(self.stylexml_file, "rb") + self.zip3dfile = os.path.join(WAVEFRONT_DIR, "apple-tree.zip") + self.zip3dfile_content = open(self.zip3dfile, "rb") + +@override_settings(MEDIA_ROOT=tempfile.mkdtemp()) +class TestResourceCreateView(SetUpTest, TestCase): + + def setUp(self): + super().setUp() + self.client = APIClient() + self.user = User.objects.create_user(username='testuser', password='testpass') + self.token = RefreshToken.for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.token.access_token}') + + def test_create_geopackage(self): + url = reverse('resource-create') + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_gpkg = SimpleUploadedFile( + self.gpkg_file_content.name, self.gpkg_file_content.read() + ) + data = { + "resource_type": "geopackage", + "name": "Test Geopackage", + "description": "A test geopackage", + "thumbnail_full": uploaded_thumbnail, + "file": uploaded_gpkg, + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 201) + self.assertEqual(Geopackage.objects.count(), 1) + self.assertEqual(Geopackage.objects.first().name, "Test Geopackage") + + def test_create_layerdefinition(self): + url = reverse('resource-create') + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_qlr = SimpleUploadedFile( + self.qlr_file_content.name, self.qlr_file_content.read() + ) + data = { + "resource_type": "layerdefinition", + "name": "Test Layer Definition", + "description": "A test layer definition", + "thumbnail_full": uploaded_thumbnail, + "file": uploaded_qlr, + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 201) + self.assertEqual(LayerDefinition.objects.count(), 1) + self.assertEqual(LayerDefinition.objects.first().name, "Test Layer Definition") + + def test_create_model(self): + url = reverse('resource-create') + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_model = SimpleUploadedFile( + self.modelzip_file_content.name, self.modelzip_file_content.read() + ) + data = { + "resource_type": "model", + "name": "Test Model", + "description": "A test model", + "thumbnail_full": uploaded_thumbnail, + "file": uploaded_model, + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 201) + self.assertEqual(Model.objects.count(), 1) + self.assertEqual(Model.objects.first().name, "Test Model") + + def test_create_style(self): + url = reverse('resource-create') + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_style = SimpleUploadedFile( + self.stylexml_file_content.name, self.stylexml_file_content.read() + ) + data = { + "resource_type": "style", + "name": "Test Style", + "description": "A test style", + "thumbnail_full": uploaded_thumbnail, + "file": uploaded_style, + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 201) + self.assertEqual(Style.objects.count(), 1) + self.assertEqual(Style.objects.first().name, "Test Style") + + def test_create_wavefront(self): + url = reverse('resource-create') + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_wavefront = SimpleUploadedFile( + self.zip3dfile_content.name, self.zip3dfile_content.read() + ) + data = { + "resource_type": "3dmodel", + "name": "Test 3D Model", + "description": "A test 3D Model", + "thumbnail_full": uploaded_thumbnail, + "file": uploaded_wavefront, + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 201) + self.assertEqual(Wavefront.objects.count(), 1) + self.assertEqual(Wavefront.objects.first().name, "Test 3D Model") + + def test_create_geopackage_invalid_data(self): + url = reverse('resource-create') + data = { + "resource_type": "geopackage", + "name": "", + "description": "", + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 400) + self.assertEqual(Geopackage.objects.count(), 0) + + def test_create_style_invalid_data(self): + url = reverse('resource-create') + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_wavefront = SimpleUploadedFile( + self.zip3dfile_content.name, self.zip3dfile_content.read() + ) + data = { + "resource_type": "style", + "name": "Test Style", + "description": "A test style", + "thumbnail_full": uploaded_thumbnail, + "file": uploaded_wavefront, + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 400) + self.assertEqual(Style.objects.count(), 0) + + def test_create_unsupported_resource_type(self): + url = reverse('resource-create') + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_gpkg = SimpleUploadedFile( + self.gpkg_file_content.name, self.gpkg_file_content.read() + ) + data = { + "resource_type": "unsupported", + "name": "Test Unsupported", + "description": "A test unsupported resource", + "thumbnail_full": uploaded_thumbnail, + "file": uploaded_gpkg, + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, {"resource_type": "Resource type not supported"}) + + +@override_settings(MEDIA_ROOT=tempfile.mkdtemp()) +class TestResourceDetailView(SetUpTest, TestCase): + + def setUp(self): + super().setUp() + self.client = APIClient() + self.user = User.objects.create_user(username='testuser', password='testpass') + self.token = RefreshToken.for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.token.access_token}') + + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_gpkg = SimpleUploadedFile( + self.gpkg_file_content.name, self.gpkg_file_content.read() + ) + uploaded_qlr = SimpleUploadedFile( + self.qlr_file_content.name, self.qlr_file_content.read() + ) + uploaded_model = SimpleUploadedFile( + self.modelzip_file_content.name, self.modelzip_file_content.read() + ) + uploaded_style = SimpleUploadedFile( + self.stylexml_file_content.name, self.stylexml_file_content.read() + ) + uploaded_wavefront = SimpleUploadedFile( + self.zip3dfile_content.name, self.zip3dfile_content.read() + ) + + self.geopackage = Geopackage.objects.create( + creator=self.user, + name="Test Geopackage", + description="A test geopackage", + thumbnail_image=uploaded_thumbnail, + file=uploaded_gpkg, + ) + self.geopackage.approved = True + self.geopackage.save() + + self.layerdefinition = LayerDefinition.objects.create( + creator=self.user, + name="Test Layer Definition", + description="A test layer definition", + thumbnail_image=uploaded_thumbnail, + file=uploaded_qlr, + ) + self.layerdefinition.approved = True + self.layerdefinition.save() + + self.model = Model.objects.create( + creator=self.user, + name="Test Model", + description="A test model", + thumbnail_image=uploaded_thumbnail, + file=uploaded_model, + ) + self.model.approved = True + self.model.save() + + self.style = Style.objects.create( + creator=self.user, + name="Test Style", + description="A test style", + thumbnail_image=uploaded_thumbnail, + file=uploaded_style, + ) + self.style.approved = True + self.style.save() + + self.wavefront = Wavefront.objects.create( + creator=self.user, + name="Test 3D Model", + description="A test 3D model", + thumbnail_image=uploaded_thumbnail, + file=uploaded_wavefront, + ) + self.wavefront.approved = True + self.wavefront.save() + + + def test_get_geopackage(self): + url = reverse("resource-detail", kwargs={"uuid": self.geopackage.uuid, "resource_type": "geopackage"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], "Test Geopackage") + + def test_get_layerdefinition(self): + url = reverse("resource-detail", kwargs={"uuid": self.layerdefinition.uuid, "resource_type": "layerdefinition"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], "Test Layer Definition") + + def test_get_model(self): + url = reverse("resource-detail", kwargs={"uuid": self.model.uuid, "resource_type": "model"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], "Test Model") + + def test_get_style(self): + url = reverse("resource-detail", kwargs={"uuid": self.style.uuid, "resource_type": "style"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], "Test Style") + + def test_get_wavefront(self): + url = reverse("resource-detail", kwargs={"uuid": self.wavefront.uuid, "resource_type": "3dmodel"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], "Test 3D Model") + + def test_update_geopackage(self): + url = reverse("resource-detail", kwargs={"uuid": self.geopackage.uuid, "resource_type": "geopackage"}) + data = { + "name": "Updated Geopackage", + "description": "Updated description", + "thumbnail_full": self.geopackage.thumbnail_image, + "file": self.geopackage.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 200) + self.geopackage.refresh_from_db() + self.assertEqual(self.geopackage.name, "Updated Geopackage") + + def test_update_layerdefinition(self): + url = reverse("resource-detail", kwargs={"uuid": self.layerdefinition.uuid, "resource_type": "layerdefinition"}) + data = { + "name": "Updated Layer Definition", + "description": "Updated description", + "thumbnail_full": self.layerdefinition.thumbnail_image, + "file": self.layerdefinition.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 200) + self.layerdefinition.refresh_from_db() + self.assertEqual(self.layerdefinition.name, "Updated Layer Definition") + + def test_update_model(self): + url = reverse("resource-detail", kwargs={"uuid": self.model.uuid, "resource_type": "model"}) + data = { + "name": "Updated Model", + "description": "Updated description", + "thumbnail_full": self.model.thumbnail_image, + "file": self.model.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 200) + self.model.refresh_from_db() + self.assertEqual(self.model.name, "Updated Model") + + def test_update_style(self): + url = reverse("resource-detail", kwargs={"uuid": self.style.uuid, "resource_type": "style"}) + data = { + "name": "Updated Style", + "description": "Updated description", + "thumbnail_full": self.style.thumbnail_image, + "file": self.style.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 200) + self.style.refresh_from_db() + self.assertEqual(self.style.name, "Updated Style") + + def test_update_wavefront(self): + url = reverse("resource-detail", kwargs={"uuid": self.wavefront.uuid, "resource_type": "3dmodel"}) + data = { + "name": "Updated 3D Model", + "description": "Updated description", + "thumbnail_full": self.wavefront.thumbnail_image, + "file": self.wavefront.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 200) + self.wavefront.refresh_from_db() + self.assertEqual(self.wavefront.name, "Updated 3D Model") + + def test_delete_geopackage(self): + url = reverse("resource-detail", kwargs={"uuid": self.geopackage.uuid, "resource_type": "geopackage"}) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + self.assertFalse(Geopackage.objects.filter(uuid=self.geopackage.uuid).exists()) + + def test_delete_layerdefinition(self): + url = reverse("resource-detail", kwargs={"uuid": self.layerdefinition.uuid, "resource_type": "layerdefinition"}) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + self.assertFalse(LayerDefinition.objects.filter(uuid=self.layerdefinition.uuid).exists()) + + def test_delete_model(self): + url = reverse("resource-detail", kwargs={"uuid": self.model.uuid, "resource_type": "model"}) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + self.assertFalse(Model.objects.filter(uuid=self.model.uuid).exists()) + + def test_delete_style(self): + url = reverse("resource-detail", kwargs={"uuid": self.style.uuid, "resource_type": "style"}) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + self.assertFalse(Style.objects.filter(uuid=self.style.uuid).exists()) + + def test_delete_wavefront(self): + url = reverse("resource-detail", kwargs={"uuid": self.wavefront.uuid, "resource_type": "3dmodel"}) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + self.assertFalse(Wavefront.objects.filter(uuid=self.wavefront.uuid).exists()) \ No newline at end of file diff --git a/qgis-app/api/urls.py b/qgis-app/api/urls.py index c2048c34..98792329 100644 --- a/qgis-app/api/urls.py +++ b/qgis-app/api/urls.py @@ -1,9 +1,53 @@ from api.views import ResourceAPIDownload, ResourceAPIList from django.urls import path - +from django.urls import re_path as url +from api.views import ( + UserTokenDetailView, + UserTokenListView, + user_token_create, + user_token_update, + user_token_delete, + ResourceCreateView, + ResourceDetailView, +) urlpatterns = [ path("resources/", ResourceAPIList.as_view(), name="resource-list"), path( "resource//", ResourceAPIDownload.as_view(), name="resource-download" ), + path( + "resource/create", ResourceCreateView.as_view(), name="resource-create" + ), + path( + "resource///", ResourceDetailView.as_view(), name="resource-detail" + ), + url( + r"^tokens/$", + UserTokenListView.as_view(), + name="user_token_list", + ), + url( + r"^tokens/(?P\d+)/$", + UserTokenDetailView.as_view(), + name="user_token_detail", + ), + url( + r"^tokens/create/$", + user_token_create, + {}, + name="user_token_create", + ), + url( + r"^tokens/(?P\d+)/update$", + user_token_update, + {}, + name="user_token_update", + ), + url( + r"^tokens/(?P[^\/]+)/delete/$", + user_token_delete, + {}, + name="user_token_delete", + ), + ] diff --git a/qgis-app/api/views.py b/qgis-app/api/views.py index f62375d2..829c6f94 100644 --- a/qgis-app/api/views.py +++ b/qgis-app/api/views.py @@ -3,22 +3,41 @@ from api.serializers import GeopackageSerializer, ModelSerializer, StyleSerializer, LayerDefinitionSerializer, WavefrontSerializer from base.license import zip_a_file_if_not_zipfile from django.contrib.postgres.search import SearchVector -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, HttpResponseRedirect from django.utils.decorators import method_decorator from django.utils.text import slugify from django.views.decorators.cache import cache_page from drf_multiple_model.pagination import MultipleModelLimitOffsetPagination from drf_multiple_model.views import FlatMultipleModelAPIView +from django.views.decorators.csrf import ensure_csrf_cookie +from django.shortcuts import get_object_or_404, render +from django.contrib.auth.decorators import login_required +from django.db import transaction + +from django.views.generic import ListView, DetailView +from rest_framework_simplejwt.tokens import RefreshToken, api_settings +from rest_framework_simplejwt.authentication import JWTAuthentication +from django.urls import reverse +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +import time +from django.utils.translation import gettext_lazy as _ +from api.forms import UserTokenForm +from django.contrib import messages # models from geopackages.models import Geopackage from models.models import Model from rest_framework import filters, permissions +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from styles.models import Style from layerdefinitions.models import LayerDefinition from wavefronts.models import Wavefront - +from api.models import UserOutstandingToken +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework import status def filter_resource_type(queryset, request, *args, **kwargs): resource_type = request.query_params["resource_type"] @@ -168,3 +187,274 @@ def get(self, request, *args, **kwargs): slugify(object.name, allow_unicode=True) ) return response + + +class UserTokenDetailView(DetailView): + """ + Hub token detail + """ + model = OutstandingToken + queryset = OutstandingToken.objects.all() + template_name = "user_token_detail.html" + + @method_decorator(ensure_csrf_cookie) + def dispatch(self, *args, **kwargs): + return super(UserTokenDetailView, self).dispatch(*args, **kwargs) + + def get_context_data(self, **kwargs): + context = super(UserTokenDetailView, self).get_context_data(**kwargs) + token_id = self.kwargs.get('pk') + user_token = get_object_or_404( + UserOutstandingToken, + pk=token_id, + is_blacklisted=False, + is_newly_created=True + ) + outstanding_token = get_object_or_404( + OutstandingToken, + pk=user_token.token.pk, + user=self.request.user + ) + try: + token = RefreshToken(outstanding_token.token) + token['refresh_jti'] = token[api_settings.JTI_CLAIM] + except (InvalidToken, TokenError) as e: + context = {} + self.template_name = "user_token_invalid_or_expired.html" + return context + timestamp_from_last_edit = int(time.time()) + context.update( + { + "access_token": str(token.access_token), + "object": user_token, + 'timestamp_from_last_edit': timestamp_from_last_edit + } + ) + user_token.is_newly_created = False + user_token.save() + return context + + +class UserTokenListView(ListView): + """ + Hub token list + """ + model = UserOutstandingToken + queryset = UserOutstandingToken.objects.all().order_by("-token__created_at") + template_name = "user_token_list.html" + + @method_decorator(ensure_csrf_cookie) + def dispatch(self, *args, **kwargs): + return super(UserTokenListView, self).dispatch(*args, **kwargs) + + def get_filtered_queryset(self, qs): + return qs.filter( + token__user=self.request.user, + is_blacklisted=False + ) + + def get_queryset(self): + qs = super(UserTokenListView, self).get_queryset() + qs = self.get_filtered_queryset(qs) + return qs + + + +@login_required +@transaction.atomic +def user_token_create(request): + if request.method == "POST": + user = request.user + refresh = RefreshToken.for_user(user) + jti = refresh[api_settings.JTI_CLAIM] + outstanding_token = OutstandingToken.objects.get(jti=jti) + user_token = UserOutstandingToken.objects.create( + user=user, + token=outstanding_token, + is_blacklisted=False, + is_newly_created=True + ) + + return HttpResponseRedirect( + reverse("user_token_detail", args=[user_token.pk]) + ) + + +@login_required +@transaction.atomic +def user_token_update(request, token_id): + print(token_id) + user_token = get_object_or_404( + UserOutstandingToken, + pk=token_id, + is_blacklisted=False + ) + outstanding_token = get_object_or_404( + OutstandingToken, + pk=user_token.token.pk, + user=request.user + ) + if request.method == "POST": + form = UserTokenForm(request.POST, instance=user_token) + if form.is_valid(): + form.save() + msg = _("The token description has been successfully updated.") + messages.success(request, msg, fail_silently=True) + return HttpResponseRedirect( + reverse("user_token_list") + ) + else: + form = UserTokenForm(instance=user_token) + + return render( + request, + "user_token_form.html", + {"form": form, "token": user_token} + ) + + +@login_required +@transaction.atomic +def user_token_delete(request, token_id): + user_token = get_object_or_404( + UserOutstandingToken, + pk=token_id, + is_blacklisted=False + ) + outstanding_token = get_object_or_404( + OutstandingToken, + pk=user_token.token.pk, + user=request.user + ) + if "delete_confirm" in request.POST: + try: + token = RefreshToken(outstanding_token.token) + token.blacklist() + user_token.is_blacklisted = True + except (InvalidToken, TokenError) as e: + user_token.is_blacklisted = True + user_token.save() + + msg = _("The token has been successfully deleted.") + messages.success(request, msg, fail_silently=True) + return HttpResponseRedirect( + reverse("user_token_list") + ) + return render( + request, + "user_token_delete.html", + {"description": user_token.description, "username": outstanding_token.user}, + ) + +def _get_resource_serializer(resource_type): + if resource_type.lower() == "geopackage": + return GeopackageSerializer + elif resource_type.lower() == "3dmodel": + return WavefrontSerializer + elif resource_type.lower() == "style": + return StyleSerializer + elif resource_type.lower() == "layerdefinition": + return LayerDefinitionSerializer + elif resource_type.lower() == "model": + return ModelSerializer + else: + return None + +def _get_resource_object(uuid, resource_type): + if resource_type.lower() == "geopackage": + return get_object_or_404(Geopackage.approved_objects, uuid=uuid) + elif resource_type.lower() == "3dmodel": + return get_object_or_404(Wavefront.approved_objects, uuid=uuid) + elif resource_type.lower() == "style": + return get_object_or_404(Style.approved_objects, uuid=uuid) + elif resource_type.lower() == "layerdefinition": + return get_object_or_404(LayerDefinition.approved_objects, uuid=uuid) + elif resource_type.lower() == "model": + return get_object_or_404(Model.approved_objects, uuid=uuid) + else: + return None + +class ResourceCreateView(APIView): + """ + Create a new Resource + """ + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def post(self, request, *args, **kwargs): + serializer_class = _get_resource_serializer(request.data.get("resource_type")) + if serializer_class is None: + return Response( + {"resource_type": "Resource type not supported"}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = serializer_class(data=request.data) + if serializer.is_valid(): + serializer.save(creator=request.user) + if hasattr(serializer, 'new_filepath'): + serializer.instance.file.name = serializer.new_filepath + serializer.instance.save() + if hasattr(serializer, 'style_type'): + serializer.instance.style_type = serializer.style_type + serializer.instance.save() + if hasattr(serializer, 'url_datasource'): + serializer.instance.url_datasource = serializer.url_datasource + serializer.instance.save() + if hasattr(serializer, 'provider'): + serializer.instance.provider = serializer.provider + serializer.instance.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class ResourceDetailView(APIView): + """ + Retrieve or update a Resource + """ + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + uuid = kwargs.get("uuid") + resource_type = kwargs.get("resource_type") + object = _get_resource_object(uuid, resource_type) + if object is None: + raise Http404 + if not object.creator.is_staff and object.creator != request.user: + return Response( + {"detail": "You do not have permission to perform this action."}, + status=status.HTTP_403_FORBIDDEN, + ) + serializer = _get_resource_serializer(resource_type)(object) + return Response(serializer.data) + + def put(self, request, *args, **kwargs): + uuid = kwargs.get("uuid") + resource_type = kwargs.get("resource_type") + object = _get_resource_object(uuid, resource_type) + if object is None: + raise Http404 + if not object.creator.is_staff and object.creator != request.user: + return Response( + {"detail": "You do not have permission to perform this action."}, + status=status.HTTP_403_FORBIDDEN, + ) + serializer = _get_resource_serializer(resource_type)(object, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, *args, **kwargs): + uuid = kwargs.get("uuid") + resource_type = kwargs.get("resource_type") + object = _get_resource_object(uuid, resource_type) + if object is None: + raise Http404 + if not object.creator.is_staff and object.creator != request.user: + return Response( + {"detail": "You do not have permission to perform this action."}, + status=status.HTTP_403_FORBIDDEN, + ) + object.delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/qgis-app/settings_docker.py b/qgis-app/settings_docker.py index 9f545500..aa29517d 100644 --- a/qgis-app/settings_docker.py +++ b/qgis-app/settings_docker.py @@ -205,6 +205,21 @@ } ] +API_SUBMENU = [ + { + 'name': 'Resources RAW', + 'url': '/api/v1/resources/', + 'icon': 'fa-database', + 'order': 0, + }, + { + 'name': 'Tokens', + 'url': '/api/v1/tokens/', + 'icon': 'fa-key', + 'order': 1, + }, +] + # Set the navigation menu NAVIGATION_MENU = [ { @@ -222,14 +237,15 @@ }, { 'name': 'API', - 'url': '/api/v1/resources/', + 'url': '/api/v1/tokens/', 'icon': 'fa-code', 'order': 2, + 'submenu': API_SUBMENU }, { 'name': 'Metrics', 'url': METABASE_DOWNLOAD_STATS_URL, - 'icon': 'fa-chart-bar', # Changed to a more accurate icon + 'icon': 'fa-chart-bar', 'order': 4, } ] diff --git a/qgis-app/wavefronts/tests/wavefrontfiles/apple-tree.zip b/qgis-app/wavefronts/tests/wavefrontfiles/apple-tree.zip new file mode 100644 index 00000000..ca0ab0a5 Binary files /dev/null and b/qgis-app/wavefronts/tests/wavefrontfiles/apple-tree.zip differ