Skip to content

Commit 968e991

Browse files
committed
issue #11. It works: add tests, script, context_processor. A bit of refactoring
1 parent 5f61d98 commit 968e991

File tree

13 files changed

+256
-55
lines changed

13 files changed

+256
-55
lines changed

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,14 @@ And add it to your templates (will add a redirect script to your html):
101101
{{ redirect_to_login_page }}
102102
```
103103

104-
It also works with `SESSION_TIME`.
104+
If you want to use this in your JavaScript code, following template variables will be useful:
105+
106+
```
107+
var sessionEnd = {{ seconds_until_session_end }};
108+
var idleEnd = {{ seconds_until_idle_end }};
109+
```
110+
111+
`REDIRECT_TO_LOGIN_PAGE` works with `SESSION_TIME` too.
105112

106113
## ⌛ Limit session time
107114

@@ -176,5 +183,6 @@ AUTO_LOGOUT = {
176183
'IDLE_TIME': timedelta(minutes=5),
177184
'SESSION_TIME': timedelta(minutes=30),
178185
'MESSAGE': 'The session has expired. Please login again to continue.',
186+
'REDIRECT_TO_LOGIN_PAGE': True,
179187
}
180188
```

django_auto_logout/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.4.0'
1+
__version__ = '0.5.0'
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from django.conf import settings
2+
from django.utils.safestring import mark_safe
3+
from .utils import now, seconds_until_session_end, seconds_until_idle_time_end
4+
5+
LOGOUT_TIMEOUT_SCRIPT_PATTERN = """
6+
<script>
7+
(function() {
8+
var w = window,
9+
s = w.localStorage,
10+
%s
11+
w.addEventListener('load', function() {
12+
s['djalLogoutAt'] = at;
13+
14+
function upd() {
15+
if (s['djalLogoutAt'] > at) {
16+
at = s['djalLogoutAt'];
17+
setTimeout(upd, at - Date.now());
18+
}
19+
else {
20+
delete s['djalLogoutAt'];
21+
w.location.reload();
22+
}
23+
}
24+
25+
setTimeout(upd, at - Date.now());
26+
});
27+
})();
28+
</script>
29+
"""
30+
31+
32+
def _trim(s: str) -> str:
33+
return ''.join([line.strip() for line in s.split('\n')])
34+
35+
36+
def auto_logout_client(request):
37+
if request.user.is_anonymous:
38+
return {}
39+
40+
options = getattr(settings, 'AUTO_LOGOUT')
41+
if not options:
42+
return {}
43+
44+
ctx = {}
45+
current_time = now()
46+
47+
if 'SESSION_TIME' in options:
48+
ctx['seconds_until_session_end'] = seconds_until_session_end(request, options['SESSION_TIME'], current_time)
49+
50+
if 'IDLE_TIME' in options:
51+
ctx['seconds_until_idle_end'] = seconds_until_idle_time_end(request, options['IDLE_TIME'], current_time)
52+
53+
if options.get('REDIRECT_TO_LOGIN_PAGE'):
54+
at = None
55+
56+
if 'SESSION_TIME' in options and 'IDLE_TIME' in options:
57+
at = (
58+
f"at=Date.now()+Math.max(Math.min({ ctx['seconds_until_session_end'] },"
59+
f"{ ctx['seconds_until_idle_end'] }),0)*1000+999;"
60+
)
61+
elif 'SESSION_TIME' in options:
62+
at = f"at=Date.now()+Math.max({ ctx['seconds_until_session_end'] },0)*1000+999;"
63+
elif 'IDLE_TIME' in options:
64+
at = f"at=Date.now()+Math.max({ ctx['seconds_until_idle_end'] },0)*1000+999;"
65+
66+
if at:
67+
ctx['redirect_to_login_page'] = mark_safe(_trim(LOGOUT_TIMEOUT_SCRIPT_PATTERN % at))
68+
69+
return ctx

django_auto_logout/middleware.py

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,40 @@
1-
from datetime import datetime, timedelta
21
import logging
32
from typing import Callable
43
from django.conf import settings
54
from django.http import HttpRequest, HttpResponse
65
from django.contrib.auth import get_user_model, logout
76
from django.contrib.messages import info
8-
from pytz import timezone
7+
8+
from .utils import now, seconds_until_idle_time_end, seconds_until_session_end
99

1010
UserModel = get_user_model()
1111
logger = logging.getLogger(__name__)
1212

1313

1414
def _auto_logout(request: HttpRequest, options):
15-
user = request.user
1615
should_logout = False
16+
current_time = now()
1717

18-
if settings.USE_TZ:
19-
now = datetime.now(tz=timezone(settings.TIME_ZONE))
20-
else:
21-
now = datetime.now()
22-
23-
if options.get('SESSION_TIME') is not None:
24-
if isinstance(options['SESSION_TIME'], timedelta):
25-
ttl = options['SESSION_TIME']
26-
elif isinstance(options['SESSION_TIME'], int):
27-
ttl = timedelta(seconds=options['SESSION_TIME'])
28-
else:
29-
raise TypeError(f"AUTO_LOGOUT['SESSION_TIME'] should be `int` or `timedelta`, "
30-
f"not `{type(options['SESSION_TIME']).__name__}`.")
31-
32-
time_expired = user.last_login < now - ttl
33-
should_logout |= time_expired
34-
logger.debug('Check SESSION_TIME: %s < %s (%s)', user.last_login, now, time_expired)
35-
36-
if options.get('IDLE_TIME') is not None:
37-
if isinstance(options['IDLE_TIME'], timedelta):
38-
ttl = options['IDLE_TIME']
39-
elif isinstance(options['IDLE_TIME'], int):
40-
ttl = timedelta(seconds=options['IDLE_TIME'])
41-
else:
42-
raise TypeError(f"AUTO_LOGOUT['IDLE_TIME'] should be `int` or `timedelta`, "
43-
f"not `{type(options['IDLE_TIME']).__name__}`.")
44-
45-
if 'django_auto_logout_last_request' in request.session:
46-
last_req = datetime.fromisoformat(request.session['django_auto_logout_last_request'])
47-
else:
48-
last_req = now
49-
request.session['django_auto_logout_last_request'] = last_req.isoformat()
18+
if 'SESSION_TIME' in options:
19+
session_time = seconds_until_session_end(request, options['SESSION_TIME'], current_time)
20+
should_logout |= session_time < 0
21+
logger.debug('Check SESSION_TIME: %ss until session ends.', session_time)
5022

51-
time_expired = last_req < now - ttl
52-
should_logout |= time_expired
53-
logger.debug('Check IDLE_TIME: %s < %s (%s)', last_req, now, time_expired)
23+
if 'IDLE_TIME' in options:
24+
idle_time = seconds_until_idle_time_end(request, options['IDLE_TIME'], current_time)
25+
should_logout |= idle_time < 0
26+
logger.debug('Check IDLE_TIME: %ss until idle ends.', idle_time)
5427

55-
if should_logout:
28+
if should_logout and 'django_auto_logout_last_request' in request.session:
5629
del request.session['django_auto_logout_last_request']
5730
else:
58-
request.session['django_auto_logout_last_request'] = now.isoformat()
31+
request.session['django_auto_logout_last_request'] = current_time.isoformat()
5932

6033
if should_logout:
61-
logger.debug('Logout user %s', user)
34+
logger.debug('Logout user %s', request.user)
6235
logout(request)
6336

64-
if options.get('MESSAGE') is not None:
37+
if 'MESSAGE' in options:
6538
info(request, options['MESSAGE'])
6639

6740

django_auto_logout/utils.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from datetime import datetime, timedelta
2+
from typing import Union
3+
from django.conf import settings
4+
from django.http import HttpRequest
5+
from pytz import timezone
6+
7+
8+
def now() -> datetime:
9+
"""
10+
Returns the current time with the Django project timezone.
11+
:return: datetime
12+
"""
13+
if settings.USE_TZ:
14+
return datetime.now(tz=timezone(settings.TIME_ZONE))
15+
return datetime.now()
16+
17+
18+
def seconds_until_session_end(
19+
request: HttpRequest,
20+
session_time: Union[int, timedelta],
21+
current_time: datetime
22+
) -> float:
23+
"""
24+
Get seconds until the end of the session.
25+
:param request: django.http.HttpRequest
26+
:param session_time: int - for seconds | timedelta
27+
:param current_time: datetime - use django_auto_logout.utils.now
28+
:return: float
29+
"""
30+
if isinstance(session_time, timedelta):
31+
ttl = session_time
32+
elif isinstance(session_time, int):
33+
ttl = timedelta(seconds=session_time)
34+
else:
35+
raise TypeError(f"AUTO_LOGOUT['SESSION_TIME'] should be `int` or `timedelta`, "
36+
f"not `{type(session_time).__name__}`.")
37+
38+
return (request.user.last_login - current_time + ttl).total_seconds()
39+
40+
41+
def seconds_until_idle_time_end(
42+
request: HttpRequest,
43+
idle_time: Union[int, timedelta],
44+
current_time: datetime
45+
) -> float:
46+
"""
47+
Get seconds until the end of downtime.
48+
:param request: django.http.HttpRequest
49+
:param idle_time: int - for seconds | timedelta
50+
:param current_time: datetime - use django_auto_logout.utils.now
51+
:return: float
52+
"""
53+
if isinstance(idle_time, timedelta):
54+
ttl = idle_time
55+
elif isinstance(idle_time, int):
56+
ttl = timedelta(seconds=idle_time)
57+
else:
58+
raise TypeError(f"AUTO_LOGOUT['IDLE_TIME'] should be `int` or `timedelta`, "
59+
f"not `{type(idle_time).__name__}`.")
60+
61+
if 'django_auto_logout_last_request' in request.session:
62+
last_req = datetime.fromisoformat(request.session['django_auto_logout_last_request'])
63+
else:
64+
last_req = current_time
65+
66+
return (last_req - current_time + ttl).total_seconds()

example/example/settings.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
'django.template.context_processors.request',
6767
'django.contrib.auth.context_processors.auth',
6868
'django.contrib.messages.context_processors.messages',
69+
70+
'django_auto_logout.context_processors.auto_logout_client',
6971
],
7072
},
7173
},
@@ -159,11 +161,12 @@
159161
}
160162

161163
LOGIN_URL = '/login/'
162-
164+
LOGIN_REDIRECT_URL = '/login-required/'
163165

164166
# DJANGO AUTO LOGIN
165167
AUTO_LOGOUT = {
166168
'IDLE_TIME': 10, # 10 seconds
167169
'SESSION_TIME': 120, # 2 minutes
168170
'MESSAGE': 'The session has expired. Please login again to continue.',
171+
'REDIRECT_TO_LOGIN_PAGE': True,
169172
}

example/example/urls.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from django.contrib import admin
22
from django.urls import path
33

4-
from some_app_login_required.views import login_page, login_required_view
4+
from some_app_login_required.views import UserLoginView, login_required_view
55

66
urlpatterns = [
77
path('admin/', admin.site.urls),
8-
path('login/', login_page),
8+
path('login/', UserLoginView.as_view()),
99
path('login-required/', login_required_view),
1010
]

example/some_app_login_required/templates/layout.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8">
5-
<title>Title</title>
5+
<title>{% block title %}{% endblock %}</title>
66
</head>
77
<body>
88
{% for message in messages %}
@@ -11,6 +11,13 @@
1111
</div>
1212
{% endfor %}
1313

14+
<p>
15+
<a href="/login-required/">internal link</a>
16+
<a href="https://github.com/bugov">external link</a>
17+
</p>
18+
1419
{% block content %}{% endblock %}
20+
21+
{{ redirect_to_login_page }}
1522
</body>
1623
</html>
Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
{% extends 'layout.html' %}
22

3+
{% block title %}login page{% endblock %}
4+
35
{% block content %}
4-
<p>
5-
login page
6-
</p>
6+
<div class="box">
7+
<h4 class="form-header">login page</h4>
8+
9+
<form method="post">
10+
{% csrf_token %}
11+
<input type="hidden" name="next" value="{{ next }}">
12+
<table>
13+
{{ form }}
14+
<tr>
15+
<td colspan="2"><button type="submit" class="btn btn-primary btn-block">Log in</button></td>
16+
</tr>
17+
</table>
18+
</form>
19+
</div>
720
{% endblock %}

example/some_app_login_required/templates/login_required.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
{% extends 'layout.html' %}
22

3+
{% block title %}login required page{% endblock %}
4+
35
{% block content %}
46
<p>
57
login required view

example/some_app_login_required/tests.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,59 @@ def test_no_messages_if_no_messages(self):
205205
resp = self.client.get(resp['location'])
206206
self.assertContains(resp, 'login page', msg_prefix=resp.content.decode())
207207
self.assertNotContains(resp, 'class="message info"', msg_prefix=resp.content.decode())
208+
209+
210+
class TestAutoLogoutRedirectToLoginPage(TestAutoLogout):
211+
def test_script_anon(self):
212+
settings.AUTO_LOGOUT = {
213+
'IDLE_TIME': 10, # 10 seconds
214+
'SESSION_TIME': 120, # 2 minutes
215+
'REDIRECT_TO_LOGIN_PAGE': True,
216+
}
217+
resp = self.client.get(settings.LOGIN_URL)
218+
self.assertNotContains(resp, '<script>')
219+
220+
self.client.force_login(self.user)
221+
resp = self.client.get(settings.LOGIN_URL)
222+
self.assertContains(resp, '<script>')
223+
224+
def test_session_idle_combinations(self):
225+
self.client.force_login(self.user)
226+
227+
settings.AUTO_LOGOUT = {
228+
'IDLE_TIME': 10, # 10 seconds
229+
'SESSION_TIME': 120, # 2 minutes
230+
'REDIRECT_TO_LOGIN_PAGE': True,
231+
}
232+
self.assertContains(self.client.get(self.url), '<script>')
233+
self.assertContains(self.client.get(self.url), 'Math.min')
234+
235+
settings.AUTO_LOGOUT = {
236+
'IDLE_TIME': 10, # 10 seconds
237+
'REDIRECT_TO_LOGIN_PAGE': True,
238+
}
239+
self.assertContains(self.client.get(self.url), '<script>')
240+
self.assertNotContains(self.client.get(self.url), 'Math.min')
241+
242+
settings.AUTO_LOGOUT = {
243+
'SESSION_TIME': 120, # 2 minutes
244+
'REDIRECT_TO_LOGIN_PAGE': True,
245+
}
246+
self.assertContains(self.client.get(self.url), '<script>')
247+
self.assertNotContains(self.client.get(self.url), 'Math.min')
248+
249+
settings.AUTO_LOGOUT = {
250+
'REDIRECT_TO_LOGIN_PAGE': True,
251+
}
252+
self.assertNotContains(self.client.get(self.url), '<script>')
253+
254+
def test_no_config(self):
255+
self.client.force_login(self.user)
256+
257+
settings.AUTO_LOGOUT = {
258+
'REDIRECT_TO_LOGIN_PAGE': False,
259+
}
260+
self.assertNotContains(self.client.get(self.url), '<script>')
261+
262+
settings.AUTO_LOGOUT = {}
263+
self.assertNotContains(self.client.get(self.url), '<script>')

0 commit comments

Comments
 (0)