redeem access permission tokens

This commit is contained in:
Laura Klünder 2017-12-10 14:13:20 +01:00
parent 003bfbe389
commit 101a4c6bf2
12 changed files with 133 additions and 19 deletions

View file

@ -8,7 +8,7 @@ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy from django.utils.translation import ungettext_lazy
from c3nav.control.models import UserPermissions from c3nav.control.models import UserPermissions
from c3nav.mapdata.models.access import AccessPermissionToken, AccessRestriction from c3nav.mapdata.models.access import AccessPermissionToken, AccessPermissionTokenItem, AccessRestriction
class UserPermissionsForm(ModelForm): class UserPermissionsForm(ModelForm):
@ -99,11 +99,12 @@ class AccessPermissionForm(Form):
def get_token(self): def get_token(self):
restrictions = [] restrictions = []
for restriction in self.cleaned_data['access_restrictions']: for restriction in self.cleaned_data['access_restrictions']:
expires = self.cleaned_data['expires'] expire_date = self.cleaned_data['expires']
author_expires = self.author_access_permissions.get(restriction.pk) author_expire_date = self.author_access_permissions.get(restriction.pk)
if author_expires is not None: if author_expire_date is not None:
expires = author_expires if expires is None else min(expires, author_expires) expire_date = author_expire_date if expire_date is None else min(expire_date, author_expire_date)
restrictions.append((restriction.pk, expires)) restrictions.append(AccessPermissionTokenItem(pk=restriction.pk, expire_date=expire_date,
title=restriction.title))
return AccessPermissionToken(author=self.author, return AccessPermissionToken(author=self.author,
can_grant=self.cleaned_data.get('can_grant', '0') == '1', can_grant=self.cleaned_data.get('can_grant', '0') == '1',
restrictions=tuple(restrictions)) restrictions=tuple(restrictions))

View file

@ -11,10 +11,10 @@
{% trans 'Scan this QR code to get access permissions:' %} {% trans 'Scan this QR code to get access permissions:' %}
</p> </p>
<p> <p>
<img src="/qr/access/{{ token }}"> <img src="{{ url_qr }}">
</p> </p>
<p> <p>
{{ absolute_url }} {{ url_absolute }}
</p> </p>
<p> <p>
<a href="{% url 'control.access' %}">« {% trans 'back' %}</a> <a href="{% url 'control.access' %}">« {% trans 'back' %}</a>

View file

@ -6,6 +6,6 @@ urlpatterns = [
url(r'^users/$', user_list, name='control.users'), url(r'^users/$', user_list, name='control.users'),
url(r'^users/(?P<user>\d+)/$', user_detail, name='control.users.detail'), url(r'^users/(?P<user>\d+)/$', user_detail, name='control.users.detail'),
url(r'^access/$', grant_access, name='control.access'), url(r'^access/$', grant_access, name='control.access'),
url(r'^access/qr/(?P<token>[^/]+)', grant_access_qr, name='control.access.qr'), url(r'^access/(?P<token>[^/]+)$', grant_access_qr, name='control.access.qr'),
url(r'^$', main_index, name='control.index'), url(r'^$', main_index, name='control.index'),
] ]

View file

@ -155,12 +155,14 @@ def grant_access_qr(request, token):
messages.error(request, _('You can only display your most recently created token.')) messages.error(request, _('You can only display your most recently created token.'))
if token is None: if token is None:
redirect('control.access') return redirect('control.access')
token.bump() token.bump()
token.save() token.save()
url = reverse('site.access.redeem', kwargs={'token': str(token.id)})
return render(request, 'control/access_qr.html', { return render(request, 'control/access_qr.html', {
'token': token.id, 'url': url,
'absolute_url': request.build_absolute_uri('/access/qr/%s' % token.id) 'url_qr': reverse('site.qr', kwargs={'path': url}),
'url_absolute': request.build_absolute_uri(url),
}) })

View file

@ -1,6 +1,8 @@
import pickle import pickle
import uuid import uuid
from collections import namedtuple
from datetime import timedelta from datetime import timedelta
from typing import Iterable
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
@ -35,6 +37,9 @@ def default_valid_until():
return timezone.now()+timedelta(seconds=20) return timezone.now()+timedelta(seconds=20)
AccessPermissionTokenItem = namedtuple('AccessPermissionTokenItem', ('pk', 'expire_date', 'title'))
class AccessPermissionToken(models.Model): class AccessPermissionToken(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
@ -52,11 +57,11 @@ class AccessPermissionToken(models.Model):
data = models.BinaryField() data = models.BinaryField()
@property @property
def restrictions(self): def restrictions(self) -> Iterable[AccessPermissionTokenItem]:
return pickle.loads(self.data) return pickle.loads(self.data)
@restrictions.setter @restrictions.setter
def restrictions(self, value): def restrictions(self, value: Iterable[AccessPermissionTokenItem]):
self.data = pickle.dumps(value) self.data = pickle.dumps(value)
def redeem(self, user=None): def redeem(self, user=None):
@ -68,13 +73,13 @@ class AccessPermissionToken(models.Model):
self.redeemed = True self.redeemed = True
if user: if user:
for pk, expire_date in self.restrictions: for restriction in self.restrictions:
obj, created = AccessPermission.objects.get_or_create( obj, created = AccessPermission.objects.get_or_create(
user=user, user=user,
access_restriction_id=pk access_restriction_id=restriction.pk
) )
obj.author_id = self.author_id obj.author_id = self.author_id
obj.expire_date = expire_date obj.expire_date = restriction.expire_date
obj.can_grant = self.can_grant obj.can_grant = self.can_grant
obj.save() obj.save()
self.redeemed_by = user self.redeemed_by = user

View file

@ -686,6 +686,22 @@ ul.messages li.alert-danger {
background-color:#FFEEEE; background-color:#FFEEEE;
} }
#messages {
z-index: 5;
position: absolute;
top: -20px;
width: 100vw;
padding: 10px;
}
#messages ul.messages {
margin: auto;
max-width: 700px;
}
#messages ul.messages .close {
float: right;
color: inherit;
}
.search-form input { .search-form input {
max-width: 400px; max-width: 400px;
vertical-align: top; vertical-align: top;
@ -712,6 +728,14 @@ main.control form tr > * {
margin: 0; margin: 0;
} }
main .narrow {
max-width: 400px;
margin: auto;
}
main .narrow p, main .narrow form, main .narrow button {
margin-bottom: 1.0rem;
}
.user-permissions-form label { .user-permissions-form label {
font-weight: 400; font-weight: 400;
width: auto; width: auto;

View file

@ -41,6 +41,15 @@ c3nav = {
} }
c3nav.continue_init(); c3nav.continue_init();
}); });
$('#messages').find('ul.messages li').each(function() {
$(this).prepend(
$('<a href="#" class="close"><i class="material-icons">close</i></a>').click(function(e) {
e.preventDefault();
$(this).parent().remove();
})
);
})
}, },
continue_init: function() { continue_init: function() {
c3nav.init_map(); c3nav.init_map();

View file

@ -3,8 +3,11 @@
{% block content %} {% block content %}
<main class="account"> <main class="account">
<div class="narrow">
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
{% include 'site/fragment_messages.html' %}
{% if back_url %} {% if back_url %}
<p><a href="{{ back_url }}">« {% trans 'back' %}</a></p> <p><a href="{{ back_url }}">« {% trans 'back' %}</a></p>
{% endif %} {% endif %}
@ -17,5 +20,6 @@
<a href="{{ bottom_link_url }}?{{ request.GET.urlencode }}">{{ bottom_link_text }}</a> <a href="{{ bottom_link_url }}?{{ request.GET.urlencode }}">{{ bottom_link_text }}</a>
{% endif %} {% endif %}
</form> </form>
</div>
</main> </main>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,24 @@
{% extends 'site/base.html' %}
{% load i18n %}
{% block content %}
<main>
<div class="narrow">
<h2>{{ title }}</h2>
<form method="post">
{% include 'site/fragment_messages.html' %}
{% for text in texts %}
<p>{{ text }}</p>
{% endfor %}
{% csrf_token %}
<button>{% if button_text %}{{ button_text }}{% else %}{{ title }}{% endif %}</button>
<p>
<a href="{% url 'site.index' %}">« {% trans 'back' %}</a>
</p>
</form>
</div>
</main>
{% endblock %}

View file

@ -5,6 +5,7 @@
{% block content %} {% block content %}
<main class="map" data-state="{{ state }}"{% if embed %} data-embed{% endif %}> <main class="map" data-state="{{ state }}"{% if embed %} data-embed{% endif %}>
<section id="messages">{% include 'site/fragment_messages.html' %}</section>
<section id="attributions"> <section id="attributions">
<a href="{% url 'editor.index' %}" target="_blank">{% trans 'Editor' %}</a> // <a href="{% url 'editor.index' %}" target="_blank">{% trans 'Editor' %}</a> //
<a href="/api/" target="_blank">{% trans 'API' %}</a> // <a href="/api/" target="_blank">{% trans 'API' %}</a> //

View file

@ -1,7 +1,7 @@
from django.conf.urls import url from django.conf.urls import url
from c3nav.site.views import (account_view, change_password_view, login_view, logout_view, map_index, qr_code, from c3nav.site.views import (access_redeem_view, account_view, change_password_view, login_view, logout_view,
register_view) map_index, qr_code, register_view)
slug = r'(?P<slug>[a-z0-9-_.:]+)' slug = r'(?P<slug>[a-z0-9-_.:]+)'
slug2 = r'(?P<slug2>[a-z0-9-_.:]+)' slug2 = r'(?P<slug2>[a-z0-9-_.:]+)'
@ -21,4 +21,5 @@ urlpatterns = [
url(r'^register$', register_view, name='site.register'), url(r'^register$', register_view, name='site.register'),
url(r'^account/$', account_view, name='site.account'), url(r'^account/$', account_view, name='site.account'),
url(r'^account/change_password$', change_password_view, name='site.account.change_password'), url(r'^account/change_password$', change_password_view, name='site.account.change_password'),
url(r'^access/(?P<token>[^/]+)$', access_redeem_view, name='site.access.redeem'),
] ]

View file

@ -7,16 +7,21 @@ from django.contrib import messages
from django.contrib.auth import login, logout from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
from django.contrib.auth.views import redirect_to_login
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
from django.views.decorators.cache import cache_control, never_cache from django.views.decorators.cache import cache_control, never_cache
from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.http import etag from django.views.decorators.http import etag
from c3nav.mapdata.models import Location, Source from c3nav.mapdata.models import Location, Source
from c3nav.mapdata.models.access import AccessPermissionToken
from c3nav.mapdata.models.locations import LocationRedirect, SpecificLocation from c3nav.mapdata.models.locations import LocationRedirect, SpecificLocation
from c3nav.mapdata.utils.locations import get_location_by_slug_for_request, levels_by_short_label_for_request from c3nav.mapdata.utils.locations import get_location_by_slug_for_request, levels_by_short_label_for_request
from c3nav.mapdata.utils.user import get_user_data from c3nav.mapdata.utils.user import get_user_data
@ -206,3 +211,41 @@ def change_password_view(request):
@login_required(login_url='site.login') @login_required(login_url='site.login')
def account_view(request): def account_view(request):
return render(request, 'site/account.html', {}) return render(request, 'site/account.html', {})
@never_cache
@login_required(login_url='site.login')
def access_redeem_view(request, token):
with transaction.atomic():
try:
token = AccessPermissionToken.objects.select_for_update().get(id=token, redeemed=False,
valid_until__gte=timezone.now())
except AccessPermissionToken.DoesNotExist:
messages.error(request, _('This token does not exist or was already redeemed.'))
return redirect('site.index')
num_restrictions = len(token.restrictions)
if request.method == 'POST':
token.redeemed = True
token.save()
if not request.user.is_authenticated:
messages.info(request, _('You need to log in to unlock areas.'))
request.session['redeem_token_on_login'] = token.id
return redirect_to_login(request.get_full_path(), 'site.login')
token.redeemed_by = request.user
token.save()
messages.success(request, ungettext_lazy('Area successfully unlocked.',
'Areas successfully unlocked.', num_restrictions))
return redirect('site.index')
return render(request, 'site/confirm.html', {
'title': ungettext_lazy('Unlock area', 'Unlock areas', num_restrictions),
'texts': (ungettext_lazy('You have been invited to unlock the following area:',
'You have been invited to unlock the following areas:',
num_restrictions),
', '.join(str(restriction.title) for restriction in token.restrictions)),
})