Skip to content

Commit 790bad6

Browse files
User: Adds explicit last_login column.
TYPE: Feature LINK: OGC-2454
1 parent a854f10 commit 790bad6

File tree

6 files changed

+111
-6
lines changed

6 files changed

+111
-6
lines changed

src/onegov/org/templates/user.pt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@
4646
<dt i18n:translate>Created</dt>
4747
<dd>${layout.format_date(layout.model.created, 'datetime_long')}</dd>
4848
<dt i18n:translate>Last login</dt>
49-
<dd tal:condition="layout.model.modified">${layout.format_date(layout.model.modified, 'datetime_long')}</dd>
50-
<dd tal:condition="not layout.model.modified" i18n:translate>Never</dd>
49+
<dd tal:condition="layout.model.last_login">${layout.format_date(layout.model.last_login, 'datetime_long')}</dd>
50+
<dd tal:condition="not layout.model.last_login" i18n:translate>Never</dd>
5151
<tal:b define="sessions layout.model.data.get('sessions', '')">
5252
<dt tal:condition="sessions" i18n:translate>Session information</dt>
5353
<dd tal:condition="sessions">

src/onegov/town6/templates/user.pt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@
5151
<dt i18n:translate>Created</dt>
5252
<dd>${layout.format_date(layout.model.created, 'datetime_long')}</dd>
5353
<dt i18n:translate>Last login</dt>
54-
<dd tal:condition="layout.model.modified">${layout.format_date(layout.model.modified, 'datetime_long')}</dd>
55-
<dd tal:condition="not layout.model.modified" i18n:translate>Never</dd>
54+
<dd tal:condition="layout.model.last_login">${layout.format_date(layout.model.last_login, 'datetime_long')}</dd>
55+
<dd tal:condition="not layout.model.last_login" i18n:translate>Never</dd>
5656
<tal:b define="sessions layout.model.data.get('sessions', '')">
5757
<dt tal:condition="sessions" i18n:translate>Session information</dt>
5858
<dd tal:condition="sessions">

src/onegov/user/auth/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ def complete_login(
369369
if hasattr(request.app, 'on_login'):
370370
request.app.on_login(request, user)
371371

372+
user.last_login = utcnow()
372373
user.save_current_session(request)
373374

374375
response.completed_login = True # type:ignore[attr-defined]

src/onegov/user/models/user.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from onegov.core.crypto import hash_password, verify_password
44
from onegov.core.orm import Base
55
from onegov.core.orm.mixins import data_property, dict_property, TimestampMixin
6-
from onegov.core.orm.types import JSON, UUID, LowercaseText
6+
from onegov.core.orm.types import JSON, UUID, LowercaseText, UTCDateTime
77
from onegov.core.security import forget, remembered
88
from onegov.core.utils import is_valid_yubikey_format
99
from onegov.core.utils import remove_repeated_dots
@@ -23,6 +23,7 @@
2323
from typing import Any, TYPE_CHECKING
2424
if TYPE_CHECKING:
2525
from collections.abc import Sequence
26+
from datetime import datetime
2627
from onegov.core.framework import Framework
2728
from onegov.core.request import CoreRequest
2829
from onegov.core.types import AppenderQuery
@@ -157,6 +158,11 @@ def userprofile(self) -> list[str]:
157158
#: true if the user is active
158159
active: Column[bool] = Column(Boolean, nullable=False, default=True)
159160

161+
#: timestamp of the last successful login
162+
last_login: Column[datetime | None] = Column(
163+
UTCDateTime, nullable=True, default=None
164+
)
165+
160166
#: the signup token used by the user
161167
signup_token: Column[str | None] = Column(
162168
Text,

src/onegov/user/upgrade.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from collections import defaultdict
88
from onegov.core.upgrade import upgrade_task
9-
from onegov.core.orm.types import JSON, UUID
9+
from onegov.core.orm.types import JSON, UUID, UTCDateTime
1010
from onegov.user import User, UserCollection
1111
from sqlalchemy import Boolean, Column, Text
1212
from sqlalchemy.sql import text
@@ -301,3 +301,38 @@ def move_group_id_to_association_table(context: UpgradeContext) -> None:
301301

302302
context.session.flush()
303303
context.operations.drop_column('users', 'group_id')
304+
305+
306+
@upgrade_task('Add last_login column')
307+
def add_last_login_column(context: UpgradeContext) -> None:
308+
if not context.has_table('users'):
309+
return
310+
311+
if not context.has_column('users', 'last_login'):
312+
context.operations.add_column(
313+
'users', Column('last_login', UTCDateTime, nullable=True)
314+
)
315+
316+
# Pre-populate last_login from existing session data
317+
context.operations.execute(
318+
"""
319+
UPDATE users
320+
SET last_login = subquery.max_timestamp::timestamp
321+
FROM (
322+
SELECT
323+
id,
324+
MAX(
325+
(session_value->>'timestamp')::timestamp
326+
) as max_timestamp
327+
FROM
328+
users,
329+
LATERAL jsonb_each(data->'sessions')
330+
AS session_entries(session_key, session_value)
331+
WHERE
332+
data->'sessions' IS NOT NULL
333+
AND jsonb_typeof(data->'sessions') = 'object'
334+
GROUP BY id
335+
) AS subquery
336+
WHERE users.id = subquery.id;
337+
"""
338+
)

tests/onegov/user/test_auth.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,66 @@ def test_signup_expired(session: Session) -> None:
330330
client_addr='127.0.0.1'
331331
)
332332
)
333+
334+
335+
def test_last_login_timestamp(session: Session, redis_url: str) -> None:
336+
337+
class App(Framework, UserApp):
338+
pass
339+
340+
@App.identity_policy()
341+
def get_identity_policy() -> IdentityPolicy:
342+
return IdentityPolicy()
343+
344+
@App.path(path='/auth', model=Auth)
345+
def get_auth() -> Auth:
346+
return Auth(DummyApp(session), to='/') # type: ignore[arg-type]
347+
348+
@App.view(model=Auth)
349+
def view_auth(self: Auth, request: CoreRequest) -> Response | str:
350+
return (
351+
self.login_to(
352+
request.GET['username'], request.GET['password'], request
353+
)
354+
or 'Error'
355+
)
356+
357+
App.commit()
358+
359+
UserCollection(session).add('testuser', 'testpass', 'member')
360+
transaction.commit()
361+
362+
app = App()
363+
app.namespace = 'test'
364+
app.configure_application(identity_secure=False, redis_url=redis_url)
365+
app.application_id = 'test/last-login'
366+
367+
client = Client(app)
368+
369+
user = UserCollection(session).by_username('testuser')
370+
assert user is not None
371+
assert user.last_login is None
372+
373+
before_login = utcnow()
374+
response = client.get('/auth?username=testuser&password=testpass')
375+
after_login = utcnow()
376+
377+
assert response.status_code == 302
378+
379+
session.expire_all()
380+
user = UserCollection(session).by_username('testuser')
381+
assert user is not None
382+
assert user.last_login is not None
383+
assert before_login <= user.last_login <= after_login
384+
385+
first_login = user.last_login
386+
time.sleep(0.1)
387+
388+
response = client.get('/auth?username=testuser&password=testpass')
389+
assert response.status_code == 302
390+
391+
session.expire_all()
392+
user = UserCollection(session).by_username('testuser')
393+
assert user is not None
394+
assert user.last_login is not None
395+
assert user.last_login > first_login

0 commit comments

Comments
 (0)