Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resource Hub api #471

Merged
merged 19 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -83,17 +89,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