Skip to content
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
9 changes: 5 additions & 4 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
'django.contrib.staticfiles',
'django.contrib.sitemaps',
# isimip_data apps
'isimip_data.access',
'isimip_data.annotations',
'isimip_data.caveats',
'isimip_data.core',
Expand Down Expand Up @@ -191,6 +192,10 @@
FILES_BASE_URL = 'https://files.isimip.org'
FILES_API_URL = 'https://files.isimip.org/api/v2'

ACCESS_REPLY_TO = (
'ISIMIP data <isimip-data@pik-potsdam.de>',
)

CAVEATS_REPLY_TO = (
'ISIMIP data <isimip-data@pik-potsdam.de>',
)
Expand Down Expand Up @@ -526,7 +531,3 @@
}
]
]

RESTRICTED_MESSAGES = {}
RESTRICTED_DEFAULT_MESSAGE = 'Please contact <a href="mailto:info@isimip.org">info@isimip.org</a>' \
' if you need access to the dataset.'
7 changes: 6 additions & 1 deletion config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from rest_framework import routers

from isimip_data.access.views import access, token
from isimip_data.access.viewsets import AccessViewSet
from isimip_data.caveats.sitemaps import CaveatSitemap
from isimip_data.caveats.views import caveat, caveats
from isimip_data.caveats.viewsets import CategoryViewSet, CaveatViewSet, SeverityViewSet, StatusViewSet
Expand Down Expand Up @@ -52,7 +54,7 @@
router.register(r'status', StatusViewSet, basename='status')
router.register(r'severities', SeverityViewSet, basename='severity')
router.register(r'settings', SettingsViewSet, basename='setting')

router.register(r'access', AccessViewSet, basename='access')

class StaticSitemap(Sitemap):

Expand Down Expand Up @@ -111,6 +113,9 @@ def location(self, item):
path('notes/<int:pk>/', caveat, name='note'),
path('caveats/<int:pk>/', caveat, name='caveat'), # legacy

path('access/token/<str:jwt>/', token, name='token'),
path('access/<path:path>/', access, name='access'),

path('', home, name='home'),
path('robots.txt', TemplateView.as_view(template_name='core/robots.txt'), name='robots.txt'),

Expand Down
Empty file added isimip_data/access/__init__.py
Empty file.
55 changes: 55 additions & 0 deletions isimip_data/access/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from django import forms
from django.contrib import admin
from django.utils.safestring import mark_safe

from .models import Resource, Token


class PathsField(forms.Field):
widget = forms.Textarea

def __init__(self, *args, **kwargs):
kwargs.setdefault('required', False)
super().__init__(*args, **kwargs)

def to_python(self, value):
if not value:
return []
return [item.strip() for item in value.strip().split('\n') if item.strip()]

def prepare_value(self, value):
if isinstance(value, list):
return '\n'.join(value)
return value


class ResourceForm(forms.ModelForm):
paths = PathsField()

class Meta:
model = Resource
fields = '__all__'


@admin.register(Resource)
class ResourceAdmin(admin.ModelAdmin):
form = ResourceForm
list_display_links = ('id', 'title')
list_display = ('id', 'title', 'paths', 'created')


@admin.register(Token)
class TokenAdmin(admin.ModelAdmin):
list_display_links = ('id', 'subject')
list_display = ('id', 'subject', 'resource', 'created')
readonly_fields = ('created', 'updated', 'as_json_pre', 'as_jwt_pre')

def as_json_pre(self, obj):
return mark_safe(f'<pre>{obj.as_json}</pre>')

as_json_pre.short_description = 'JSON'

def as_jwt_pre(self, obj):
return mark_safe(f'<pre style="width: 600px;">{obj.as_jwt}</pre>')

as_jwt_pre.short_description = 'JWT'
13 changes: 13 additions & 0 deletions isimip_data/access/assets/js/api/AccessApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class AccessApi {

static fetchAccess() {
return fetch('/api/v1/access/').then(response => {
return response.json()
}).catch(error => {
return error
})
}

}

export default AccessApi
10 changes: 10 additions & 0 deletions isimip_data/access/assets/js/hooks/queries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query'

import AccessApi from '../api/AccessApi'

export const useAccessQuery = () => {
return useQuery({
queryKey: ['access'],
queryFn: () => AccessApi.fetchAccess()
})
}
12 changes: 12 additions & 0 deletions isimip_data/access/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django import forms

from .models import Token


class AccessForm(forms.ModelForm):

consent = forms.BooleanField(required=True)

class Meta:
model = Token
fields = ["subject"]
10 changes: 10 additions & 0 deletions isimip_data/access/managers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.db import models


class ResourceManager(models.Manager):

def find_by_path(self, path):
for resource in self.all():
for resource_path in resource.paths:
if path.startswith(resource_path):
return resource
43 changes: 43 additions & 0 deletions isimip_data/access/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 4.2.17 on 2025-08-05 15:52

import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='Resource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=512)),
('description', models.TextField()),
('paths', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), size=None)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ('title',),
},
),
migrations.CreateModel(
name='Token',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.EmailField(max_length=254)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('resource', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tokens', to='access.resource')),
],
options={
'ordering': ('subject',),
},
),
]
Empty file.
77 changes: 77 additions & 0 deletions isimip_data/access/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import json
import secrets
from datetime import timedelta

from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.urls import reverse

from .managers import ResourceManager
from .utils import encode_token


def generate_token():
return secrets.token_urlsafe(24)


class Resource(models.Model):

objects = ResourceManager()

title = models.CharField(max_length=512)
description = models.TextField()
paths = ArrayField(models.TextField())

created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)

class Meta:
ordering = ('title', )

def __str__(self):
return self.title


class Token(models.Model):

resource = models.ForeignKey('Resource', null=True, on_delete=models.SET_NULL, related_name='tokens')
subject = models.EmailField()

created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)

class Meta:
ordering = ('subject', )

def __str__(self):
return self.subject

@property
def expires(self):
if self.updated is not None:
return self.updated + timedelta(seconds=int(settings.FILES_AUTH_TTL))

@property
def as_dict(self):
return {
"sub": self.subject,
"iat": self.updated,
"exp": self.expires,
"paths": self.resource.paths if self.resource else [],
}

@property
def as_json(self):
return json.dumps(self.as_dict, indent=4, default=str)

@property
def as_jwt(self):
return encode_token(self.as_dict)

@property
def as_header(self):
return f'Authorization: Bearer {self.as_jwt}'

def get_absolute_url(self, request):
return request.build_absolute_uri(reverse('token', args=[self.as_jwt]))
7 changes: 7 additions & 0 deletions isimip_data/access/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from rest_framework import serializers


class AccessSerializer(serializers.Serializer):

sub = serializers.EmailField()
paths = serializers.ListField()
86 changes: 86 additions & 0 deletions isimip_data/access/templates/access/access.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{% extends 'core/base.html' %}
{% load static %}
{% load i18n %}

{% block main %}

<div class="container">
<header class="wide">
<h1>Request access</h1>
<h2>{{ resource.title }}</h2>
</header>

<div class="row">
<div class="col-md-8">
<h3>Terms of use</h3>
<div class="card">
<div class="card-body">
<p>
In addition to our general <a href="{{ settings.TERMS_OF_USE_URL }}">terms of use</a>, the following terms apply:
</p>

{{ resource.description|linebreaksbr|urlize }}
</div>
</div>
</div>
<div class="col-md-4">
<h3>Confirmation</h3>
<div class="card">
<div class="card-body">
{% if form.is_bound and not form.errors %}
<div class="text-success">
{% blocktrans with email=form.instance trimmed %}
We have sent an email to {{ email }}, containing instructions on how to access
the data. If you do not receive it within a few minutes, please contact us.
{% endblocktrans %}
</div>
{% else %}
<form method="POST">
{% csrf_token %}

<div class="mb-3">
<label for="{{ form.subject.id_for_label }}" class="mb-1 text-bold">
{% trans 'Your email address' %}
</label>

<input type="{{ form.subject.widget_type }}" id="{{ form.subject.id_for_label }}"
name="{{ form.subject.name }}"
value="{{ form.subject.value|default_if_none:'' }}"
class="form-control {% if form.subject.errors %}is-invalid{% endif %}"
aria-describedby="{{ form.subject.id_for_label }}-help"
placeholder="{% trans 'Email' %}">

{% for error in form.subject.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>

<div class="form-check mb-3">
<input type="checkbox" name="{{ form.consent.name }}" id="{{ form.consent.id_for_label }}"
{% if form.consent.value %}checked{% endif %}
class="form-check-input {% if form.consent.errors %}is-invalid{% endif %}"
aria-describedby="{{ form.consent.id_for_label }}-help">

<label class="form-check-label" for="{{ form.consent.id_for_label }}">
{% trans 'I agree to the <span id="show-terms-of-use">terms of use</span>.' %}
</label>

{% if form.consent.errors %}
<div class="invalid-feedback">
{% trans 'You need to agree to the terms of use to proceed.' %}
</div>
{% endif %}
</div>

<button type="submit" class="btn btn-success">
{% trans 'Sign' %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>

{% endblock %}
Loading
Loading