Skip to content

Commit

Permalink
Merge pull request #471 from Xpirix/hub_api
Browse files Browse the repository at this point in the history
Resource Hub api
  • Loading branch information
Xpirix authored Nov 21, 2024
2 parents 4d01b9f + a1ab11e commit 86c7758
Show file tree
Hide file tree
Showing 18 changed files with 1,648 additions and 10 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
52 changes: 52 additions & 0 deletions HUB_API.md
Original file line number Diff line number Diff line change
@@ -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/<uuid:uuid>/`
- **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 <my_token>' \
--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/<str:resource_type>/<uuid:uuid>/`
- **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.
14 changes: 14 additions & 0 deletions qgis-app/api/forms.py
Original file line number Diff line number Diff line change
@@ -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",
)
31 changes: 31 additions & 0 deletions qgis-app/api/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 4.2.16 on 2024-11-18 03:02

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')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='Last used at')),
('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)),
],
),
]
37 changes: 36 additions & 1 deletion qgis-app/api/models.py
Original file line number Diff line number Diff line change
@@ -1 +1,36 @@
# 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,
)
created_at = models.DateTimeField(
verbose_name=_("Created at"),
auto_now_add=True,
)
last_used_at = models.DateTimeField(
verbose_name=_("Last used at"),
blank=True,
null=True
)
35 changes: 35 additions & 0 deletions qgis-app/api/permissions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
from rest_framework import permissions
from rest_framework.permissions import BasePermission
from rest_framework_simplejwt.authentication import JWTAuthentication
from django.contrib.auth.models import User
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken
import datetime
from api.models import UserOutstandingToken

MANAGER_GROUP = "Style Managers"

Expand All @@ -21,3 +28,31 @@ def has_object_permission(self, request, view, obj):
is_manager = user.groups.filter(name=MANAGER_GROUP).exists()

return user == obj.creator or user.is_staff or is_manager

class HasValidToken(BasePermission):
def has_permission(self, request, view):
auth_token = request.META.get("HTTP_AUTHORIZATION")
if not str(auth_token).startswith('Bearer'):
return False

# Validate JWT token
authentication = JWTAuthentication()
try:
validated_token = authentication.get_validated_token(auth_token[7:])
user_id = validated_token.payload.get('user_id')
jti = validated_token.payload.get('refresh_jti')
token_id = OutstandingToken.objects.get(jti=jti).pk
is_blacklisted = BlacklistedToken.objects.filter(token_id=token_id).exists()
if not user_id or is_blacklisted:
return False

user = User.objects.get(pk=user_id)
if not user:
return False
user_token = UserOutstandingToken.objects.get(token__pk=token_id, user=user)
user_token.last_used_at = datetime.datetime.now()
user_token.save()
request.user_token = user_token
return True
except (InvalidToken, TokenError):
return False
101 changes: 96 additions & 5 deletions qgis-app/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -97,17 +103,102 @@ 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

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
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
20 changes: 20 additions & 0 deletions qgis-app/api/templates/user_token_base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends BASE_TEMPLATE %}{% load i18n %}
{% block app_title %}
<h2 xmlns="http://www.w3.org/1999/html">{{ title }}</h2>
{% endblock %}

{% block menu %}
{{ block.super }}
<form method="post" action="{% url "user_token_create"%}">{% csrf_token %}
<div>
<h2>
<button type="submit" name="user_token_create" id="user_token_create"
value="{% trans "Generate a New Token" %}" class="btn btn-block btn-primary btn-large" style="padding: 10px">
<i class="icon-plus icon-white icon-2x" style=" vertical-align: middle;"></i>
&nbsp;{% trans "Generate a New Token" %}
</button>
</h2>
</div>
</form>

{% endblock %}
9 changes: 9 additions & 0 deletions qgis-app/api/templates/user_token_delete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends 'user_token_base.html' %}{% load i18n %}
{% block content %}
<h3>Delete token of "{{ username }}"</h3>
<form action="" method="post">{% csrf_token %}
<p class="alert alert-danger">{% trans "You asked to delete a token.<br />It will be permanently deleted and this action cannot be undone.<br />Please confirm." %}</p>
<p><input type="submit" class="btn btn-danger" name="delete_confirm" value="{% trans "Ok" %}" /> <a class="btn btn-default" href="javascript:history.back()">{% trans "Cancel" %}</a></p>
</form>

{% endblock %}
Loading

0 comments on commit 86c7758

Please sign in to comment.