Compare commits

..

No commits in common. "master" and "0.8.7" have entirely different histories.

22 changed files with 89 additions and 334 deletions

1
.gitignore vendored
View File

@ -3,4 +3,3 @@
/django_gas.egg-info
/.coverage
/htmlcov
*.pyc

View File

@ -1,38 +1,6 @@
Changelog
=========
0.8.13
------
* Fix: set html email template
0.8.12
------
* Add reset password (thanks Leo!)
0.8.11
------
* Add preview widget in Image Field Form (thanks Leo!)
0.8.10
-----
* New templatetag to check if user has role
0.8.9
-----
* Bugfix
0.8.8
-----
* Support Django 4.2
* Use DeleteView.form_valid instead of DeleteView.delete
* Add no-cache headers to login redirects
0.8.7
-----

View File

@ -2,4 +2,3 @@ Collaborators
=============
* Shakarina
* Leo Hakim

View File

@ -8,9 +8,5 @@ urlpatterns = [
path('login/', views.GASLoginView.as_view(), name='login'),
path('logout/', logout_then_login, {'login_url': 'gas:login'}, name='logout'),
path('change-password/', views.GASPasswordChangeView.as_view(), name='change_password'),
path('reset-password-confirm/<uidb64>/<token>/', views.GASPasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('reset-password-confirm/done/', views.GASPasswordResetCompleteView.as_view(), name='password_reset_complete'),
path('reset-password/done/', views.GASPasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset-password/', views.GASPasswordResetView.as_view(), name='reset_password'),
path('', views.Index.as_view(), name='index'),
]

View File

@ -1,72 +1,45 @@
from django.contrib.auth.views import (
LoginView,
PasswordChangeView,
PasswordResetConfirmView,
PasswordResetDoneView,
PasswordResetView,
)
from django.contrib.auth.views import LoginView, PasswordChangeView
from django.shortcuts import resolve_url
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from gas import gas_settings
from gas.views import GASMixin
class GASPasswordChangeView(GASMixin, PasswordChangeView):
template_name = "gas/base_form.html"
success_url = reverse_lazy("gas:index")
continue_url = reverse_lazy("gas:change_password")
title = _("Change your password")
success_message = _("Password changed.")
from gas import gas_settings
class Index(GASMixin, TemplateView):
main_menu = "index"
template_name = "gas/index.html"
roles = ("staff",)
class GASContextMixin:
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
css = gas_settings.MEDIA["css"]
javascript = gas_settings.MEDIA["js"]
if gas_settings.EXTRA_MEDIA:
css = css + gas_settings.EXTRA_MEDIA.get("css", [])
javascript = javascript + gas_settings.EXTRA_MEDIA.get("js", [])
ctx.update(
{
"logo_static_url": gas_settings.LOGO,
"css": css,
"js": javascript,
}
)
return ctx
class GASLoginView(GASContextMixin, LoginView):
class GASLoginView(LoginView):
template_name = "gas/login.html"
def get_success_url(self):
url = self.get_redirect_url()
return url or resolve_url("gas:index")
return url or resolve_url('gas:index')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
css = gas_settings.MEDIA['css']
js = gas_settings.MEDIA['js']
if gas_settings.EXTRA_MEDIA:
css = css + gas_settings.EXTRA_MEDIA.get('css', [])
js = js + gas_settings.EXTRA_MEDIA.get('js', [])
ctx.update({
'logo_static_url': gas_settings.LOGO,
'css': css,
'js': js,
})
return ctx
class GASPasswordResetView(GASContextMixin, PasswordResetView):
template_name = "gas/reset.html"
html_email_template_name = "registration/password_reset_email.html"
success_url = reverse_lazy("gas:password_reset_done")
class GASPasswordChangeView(GASMixin, PasswordChangeView):
template_name = 'gas/base_form.html'
success_url = reverse_lazy('gas:index')
continue_url = reverse_lazy('gas:change_password')
title = _('Change your password')
success_message = _('Password changed.')
class GASPasswordResetDoneView(GASContextMixin, PasswordResetDoneView):
template_name = "gas/reset_done.html"
class GASPasswordResetConfirmView(GASContextMixin, PasswordResetConfirmView):
template_name = "gas/reset_confirm.html"
success_url = reverse_lazy("gas:password_reset_complete")
class GASPasswordResetCompleteView(GASContextMixin, TemplateView):
template_name = "gas/reset_complete.html"
class Index(GASMixin, TemplateView):
main_menu = 'index'
template_name = "gas/index.html"
roles = ('staff',)

View File

@ -7,9 +7,6 @@ urlpatterns = [
path('', views.UserList.as_view(), name="user_list"),
path('create/', views.CreateUser.as_view(), name="user_create"),
path('<int:pk>/edit/', views.UpdateUser.as_view(), name="user_update"),
path(
'<int:pk>/change-password/',
views.ChangePasswordUser.as_view(),
name="user_change_password"),
path('<int:pk>/change-password/', views.ChangePasswordUser.as_view(), name="user_change_password"),
path('<int:pk>/delete/', views.DeleteUser.as_view(), name="user_delete"),
]

View File

@ -20,5 +20,3 @@ MEDIA = getattr(settings, 'GAS_MEDIA', {
})
EXTRA_MEDIA = getattr(settings, 'GAS_EXTRA_MEDIA', None)
IMAGE_PREVIEW_WIDTH = getattr(settings, 'IMAGE_PREVIEW_WIDTH', 240)

View File

@ -10,7 +10,7 @@ class Entry:
self.icon = icon
self.url = url
self.roles = roles
self.children = {}
self.children = dict()
self.parent = parent
self.order = order if order is not None else float("inf")
@ -20,7 +20,7 @@ class Entry:
return super().__lt__(other)
class GASSite:
class GASSite(object):
base_role = 'admins'
def __init__(self):
@ -42,12 +42,12 @@ class GASSite:
exposed when gas is not active.
"""
if prefix in self._registry['urls']:
raise ImproperlyConfigured(f"Prefix {prefix} already in use")
raise ImproperlyConfigured("Prefix {0} already in use".format(prefix))
self._registry['urls'][prefix] = urls
def register_menu(self, name, label, url=None, icon=None, roles=None, parent=None, order=None):
if name in self._registry['menu']:
raise ImproperlyConfigured(f"Menu entry '{name}' already registered.")
raise ImproperlyConfigured("Menu entry '{0}' already registered.".format(name))
if roles is None:
roles = set()
@ -61,8 +61,8 @@ class GASSite:
if parent:
try:
parent_entry = self._registry['menu'][parent]
except KeyError as exc:
raise ImproperlyConfigured("Parent {parent} not registered.") from exc
except KeyError:
raise ImproperlyConfigured("Parent {} not registered.".format(parent))
parent_entry.children[name] = entry
self._registry['menu'][name] = entry
@ -86,7 +86,7 @@ class GASSite:
for prefix, urls in self._registry['urls'].items():
if prefix:
urlpatterns.append(
re_path(f'^{prefix}/', include(urls)),
re_path(r'^{0}/'.format(prefix), include(urls)),
)
else:
urlpatterns.append(

View File

@ -262,7 +262,3 @@ label {
width: 20em;
padding: 1em;
}
.preview {
display: block;
}

View File

@ -1,14 +0,0 @@
{% load i18n %}{% autoescape off %}
{% blocktranslate %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktranslate %}
{% translate "Please go to the following page and choose a new password:" %}
{% block reset_link %}
{{ protocol }}://{{ domain }}{% url 'gas:password_reset_confirm' uidb64=uid token=token %}
{% endblock %}
{% translate 'Your username, in case youve forgotten:' %} {{ user.get_username }}
{% translate "Thanks for using our site!" %}
{% blocktranslate %}The {{ site_name }} team{% endblocktranslate %}
{% endautoescape %}

View File

@ -1,20 +0,0 @@
{% load i18n static form_tags %}<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{{ gas_title }} {% trans "Login" %}</title>
{% for cssfile in css %}
<link href="{% static cssfile %}" rel="stylesheet" type="text/css" />
{% endfor %}
</head>
<body id="login">
<p>{% translate 'Forgotten your password? Enter your email address below, and well email instructions for setting a new one.' %}</p>
<form action="." method="POST">{% csrf_token %}
{% form_errors form %}
{% form_field form.email %}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">{% trans "Reset my password" %}</button>
</form>
</body>
</html>

View File

@ -1,14 +0,0 @@
{% load i18n static form_tags %}<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{{ gas_title }} {% trans "Login" %}</title>
{% for cssfile in css %}
<link href="{% static cssfile %}" rel="stylesheet" type="text/css" />
{% endfor %}
</head>
<body id="login">
<p>{% translate 'Your password was changed.' %}</p>
</body>
</html>

View File

@ -1,26 +0,0 @@
{% load i18n static form_tags %}<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{{ gas_title }} {% trans "Login" %}</title>
{% for cssfile in css %}
<link href="{% static cssfile %}" rel="stylesheet" type="text/css" />
{% endfor %}
</head>
<body id="login">
{% if validlink %}
<p>{% translate "Please enter your new password twice so we can verify you typed it in correctly." %}</p>
<form action="." method="POST">{% csrf_token %}
<input class="hidden" autocomplete="username" value="{{ form.user.get_username }}">
{% form_errors form %}
{% form_field form.new_password1 %}
{% form_field form.new_password2 %}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">{% trans "Change my password" %}</button>
</form>
{% else %}
<p>{% translate "The password reset link was invalid, possibly because it has already been used. Please request a new password reset." %}</p>
{% endif %}
</body>
</html>

View File

@ -1,17 +0,0 @@
{% load i18n static form_tags %}<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{{ gas_title }} {% trans "Login" %}</title>
{% for cssfile in css %}
<link href="{% static cssfile %}" rel="stylesheet" type="text/css" />
{% endfor %}
</head>
<body id="login">
<p>{% translate 'Weve emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly.' %}</p>
<p>{% translate 'If you dont receive an email, please make sure youve entered the address you registered with, and check your spam folder.' %}</p>
</body>
</html>

View File

@ -1,5 +0,0 @@
{% if widget.is_initial %}{{ widget.initial_text }}: <a href="{{ widget.value.url }}"><img src="{{ widget.value.url }}" width="{{ widget.attrs.IMAGE_PREVIEW_WIDTH }}" class="preview" /></a>{% if not widget.required %}
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}"{% if widget.attrs.disabled %} disabled{% endif %}>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}<br>
{{ widget.input_text }}:{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>

View File

@ -1,8 +1,7 @@
from django import forms, template
from django import forms
from django import template
from django.template.loader import render_to_string
from ..gas_settings import IMAGE_PREVIEW_WIDTH
register = template.Library()
@ -20,10 +19,6 @@ def base_form_field(field, css=None, container_class='', add_another_url=None, f
elif isinstance(field.field, forms.DateField):
field.field.widget.input_type = 'date'
if isinstance(field.field, forms.ImageField):
field.field.widget.template_name = 'gas/tags/widgets/image_preview.html'
field.field.widget.attrs['IMAGE_PREVIEW_WIDTH'] = IMAGE_PREVIEW_WIDTH
if isinstance(field.field, forms.SplitDateTimeField):
field.field.widget.widgets[1].attrs['placeholder'] = '00:00:00'
else:

View File

@ -5,7 +5,6 @@ from django import template
from django.utils.html import mark_safe
from ..sites import site
from ..models import UserRole
register = template.Library()
@ -60,10 +59,3 @@ def pagination(request, page):
@register.filter
def to_json(data):
return mark_safe(json.dumps(data))
@register.filter
def has_role(user, role):
if user.is_superuser:
return True
return UserRole.objects.filter(role__in=(role, "admins"), user=user).exists()

View File

@ -1,60 +0,0 @@
from django.test import TestCase, Client
from django.urls import reverse
from model_bakery import baker
class GASLoginTestCase(TestCase):
def test_load(self):
client = Client()
response = client.get(reverse("gas:login"))
self.assertEqual(response.status_code, 200)
class IndexTestCase(TestCase):
def test_load(self):
admin_user = baker.make(
"auth.User",
username="admin",
is_superuser=True,
)
client = Client()
response = client.get(reverse("gas:index"))
self.assertEqual(response.status_code, 302)
client.force_login(admin_user)
response = client.get(reverse("gas:index"))
self.assertEqual(response.status_code, 200)
class GASPasswordResetViewTestCase(TestCase):
def test_load(self):
client = Client()
response = client.get(reverse("gas:reset_password"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "gas/reset.html")
class GASPasswordResetDoneViewTestCase(TestCase):
def test_load(self):
client = Client()
response = client.get(reverse("gas:password_reset_done"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "gas/reset_done.html")
class GASPasswordResetConfirmViewTestCase(TestCase):
def test_load(self):
client = Client()
response = client.get(reverse("gas:password_reset_confirm", kwargs={"uidb64": "uidb64", "token": "token"}))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "gas/reset_confirm.html")
class GASPasswordResetCompleteViewTestCase(TestCase):
def test_load(self):
client = Client()
response = client.get(reverse("gas:password_reset_complete"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "gas/reset_complete.html")

View File

@ -6,13 +6,13 @@ from django.utils.functional import Promise
class JSONEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, datetime.datetime):
return o.strftime('%Y-%m-%d %H:%M')
if isinstance(o, datetime.date):
return o.strftime('%Y-%m-%d')
if isinstance(o, QuerySet):
return list(o)
if isinstance(o, Promise):
return str(o)
return json.JSONEncoder.default(self, o)
def default(self, obj):
if isinstance(obj, datetime.datetime):
return obj.strftime('%Y-%m-%d %H:%M')
if isinstance(obj, datetime.date):
return obj.strftime('%Y-%m-%d')
if isinstance(obj, QuerySet):
return list(obj)
if isinstance(obj, Promise):
return str(obj)
return json.JSONEncoder.default(self, obj)

View File

@ -1,10 +1,11 @@
import json
from django.contrib import messages
from django.contrib.admin.utils import NestedObjects
from django.core.exceptions import ImproperlyConfigured
from django.db import router
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest, JsonResponse
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest
from django.urls import reverse
from django.utils.cache import add_never_cache_headers
from django.utils.html import escape, escapejs
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
@ -43,26 +44,30 @@ class AjaxCommandsMixin:
"""
def post(self, request, *args, **kwargs):
if 'command' in self.request.POST:
command = self.request.POST['command']
command_processor = getattr(self, f'do_{command}', None)
command_processor = getattr(self, 'do_{0}'.format(self.request.POST['command']), None)
if command_processor is not None:
return command_processor()
return HttpResponseBadRequest()
handler = getattr(super(), 'post', self.http_method_not_allowed)
return handler(request, *args, **kwargs)
else:
return HttpResponseBadRequest()
else:
handler = getattr(super(), 'post', self.http_method_not_allowed)
return handler(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
if 'command' in self.request.GET:
command = self.request.POST['command']
command_processor = getattr(self, f'send_{command}', None)
command_processor = getattr(self, 'send_{0}'.format(self.request.GET['command']), None)
if command_processor is not None:
return command_processor()
return HttpResponseBadRequest()
handler = getattr(super(), 'get', self.http_method_not_allowed)
return handler(request, *args, **kwargs)
else:
return HttpResponseBadRequest()
else:
handler = getattr(super(), 'get', self.http_method_not_allowed)
return handler(request, *args, **kwargs)
def render_json(self, data, encoder=utils.JSONEncoder):
return JsonResponse(data, json_dumps_params={"indent": 2}, encoder=encoder)
return HttpResponse(
json.dumps(data, indent=2, cls=encoder),
content_type='application/json')
class GASMixin:
@ -92,10 +97,7 @@ class GASMixin:
def dispatch(self, *args, **kwargs):
if self.check_user_forbidden():
path = self.request.path
response = HttpResponseRedirect(reverse('gas:login') + f'?next={path}')
add_never_cache_headers(response)
return response
return HttpResponseRedirect(reverse('gas:login') + '?next={}'.format(self.request.path))
return super().dispatch(*args, **kwargs)
def form_valid(self, form):
@ -117,14 +119,16 @@ class GASMixin:
# Forcing possible reverse_lazy evaluation
url = str(self.cancel_url)
return url
return self.get_success_url()
else:
return self.get_success_url()
def get_continue_url(self):
if self.continue_url:
# Forcing possible reverse_lazy evaluation
url = str(self.continue_url)
return url
raise ImproperlyConfigured("No URL to redirect to. Provide a continue_url.")
else:
raise ImproperlyConfigured("No URL to redirect to. Provide a continue_url.")
def get_header_title(self):
" Contents for the <title> tag. "
@ -148,10 +152,10 @@ class GASMixin:
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
css = gas_settings.MEDIA['css']
javascript = gas_settings.MEDIA['js']
js = gas_settings.MEDIA['js']
if gas_settings.EXTRA_MEDIA:
css = css + gas_settings.EXTRA_MEDIA.get('css', [])
javascript = javascript + gas_settings.EXTRA_MEDIA.get('js', [])
js = js + gas_settings.EXTRA_MEDIA.get('js', [])
ctx.update({
'base_template': self.base_template,
'home_url': self.get_home_url(),
@ -163,7 +167,7 @@ class GASMixin:
'gas_title': gas_settings.TITLE,
'logo_static_url': gas_settings.LOGO,
'css': css,
'js': javascript,
'js': js,
})
return ctx
@ -182,7 +186,8 @@ class GASListView(GASMixin, ListView):
self.filter_form = self.get_filter_form()
if self.filter_form is not None and self.filter_form.is_valid():
return self.filter_form.filter(qs)
return qs
else:
return qs
def get_queryset(self):
qs = super().get_queryset()
@ -211,16 +216,13 @@ class GASCreateView(GASMixin, CreateView):
def form_valid(self, form):
response = super().form_valid(form)
if '_popup' in self.request.POST:
# escape() calls force_text.
obj_pk = escape(self.object.pk)
obj = escapejs(self.object)
return HttpResponse(
'<!DOCTYPE html><html><head><title></title></head><body>'
'<script type="text/javascript">'
f' opener.dismissAddAnotherPopup(window, "{obj_pk}", "{obj}");'
'</script></body></html>'
)
return response
'<script type="text/javascript">opener.dismissAddAnotherPopup(window, "%s", "%s");</script></body></html>' %
# escape() calls force_text.
(escape(self.object.pk), escapejs(self.object)))
else:
return response
class GASUpdateView(GASMixin, UpdateView):
@ -253,14 +255,10 @@ class GASDeleteView(GASMixin, DeleteView):
def format_callback(obj):
opts = obj._meta
name = capfirst(opts.verbose_name)
return f'{name}: {obj}'
return '%s: %s' % (capfirst(opts.verbose_name), obj)
collector.collect([self.object])
model_count = {
model._meta.verbose_name_plural: len(objs)
for model, objs in collector.model_objs.items()
}
model_count = {model._meta.verbose_name_plural: len(objs) for model, objs in collector.model_objs.items()}
return collector.nested(format_callback), model_count
def get_context_data(self, **kwargs):
@ -280,7 +278,7 @@ class GASDeleteView(GASMixin, DeleteView):
})
return ctx
def form_valid(self, form):
response = super().form_valid(form)
messages.add_message(self.request, messages.SUCCESS, self.get_deleted_text())
def delete(self, request, *args, **kwargs):
response = super().delete(request, *args, **kwargs)
messages.add_message(request, messages.SUCCESS, self.get_deleted_text())
return response

View File

@ -1 +1 @@
Django>=3.2.13,<5.0
Django==3.2.13

View File

@ -1,6 +1,6 @@
[metadata]
name = django-gas
version = 0.8.13
version = 0.8.7
description = An alternative to django admin
long_description = file: readme.md, changelog.md, collaborators.md
long_description_content_type = text/markdown