From e9117326e99e573e5d5baf9ad44d46fe4db0c01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cyrill=20K=C3=BCttel?= Date: Mon, 22 Dec 2025 14:19:59 +0100 Subject: [PATCH 1/7] update --- src/onegov/org/templates/user.pt | 4 +- src/onegov/town6/templates/user.pt | 4 +- src/onegov/user/auth/core.py | 1 + src/onegov/user/models/user.py | 8 +++- src/onegov/user/upgrade.py | 29 +++++++++++++- tests/onegov/user/test_auth.py | 63 ++++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/onegov/org/templates/user.pt b/src/onegov/org/templates/user.pt index c94d5e76e0..f371be20aa 100644 --- a/src/onegov/org/templates/user.pt +++ b/src/onegov/org/templates/user.pt @@ -46,8 +46,8 @@
Created
${layout.format_date(layout.model.created, 'datetime_long')}
Last login
-
${layout.format_date(layout.model.modified, 'datetime_long')}
-
Never
+
${layout.format_date(layout.model.last_login, 'datetime_long')}
+
Never
Session information
diff --git a/src/onegov/town6/templates/user.pt b/src/onegov/town6/templates/user.pt index ebb5641521..1acde7a0f7 100644 --- a/src/onegov/town6/templates/user.pt +++ b/src/onegov/town6/templates/user.pt @@ -51,8 +51,8 @@
Created
${layout.format_date(layout.model.created, 'datetime_long')}
Last login
-
${layout.format_date(layout.model.modified, 'datetime_long')}
-
Never
+
${layout.format_date(layout.model.last_login, 'datetime_long')}
+
Never
Session information
diff --git a/src/onegov/user/auth/core.py b/src/onegov/user/auth/core.py index 8a4c84079e..9806a320f0 100644 --- a/src/onegov/user/auth/core.py +++ b/src/onegov/user/auth/core.py @@ -369,6 +369,7 @@ def complete_login( if hasattr(request.app, 'on_login'): request.app.on_login(request, user) + user.last_login = utcnow() user.save_current_session(request) response.completed_login = True # type:ignore[attr-defined] diff --git a/src/onegov/user/models/user.py b/src/onegov/user/models/user.py index a2f3505983..2e159af8d8 100644 --- a/src/onegov/user/models/user.py +++ b/src/onegov/user/models/user.py @@ -3,7 +3,7 @@ from onegov.core.crypto import hash_password, verify_password from onegov.core.orm import Base from onegov.core.orm.mixins import data_property, dict_property, TimestampMixin -from onegov.core.orm.types import JSON, UUID, LowercaseText +from onegov.core.orm.types import JSON, UUID, LowercaseText, UTCDateTime from onegov.core.security import forget, remembered from onegov.core.utils import is_valid_yubikey_format from onegov.core.utils import remove_repeated_dots @@ -23,6 +23,7 @@ from typing import Any, TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Sequence + from datetime import datetime from onegov.core.framework import Framework from onegov.core.request import CoreRequest from onegov.core.types import AppenderQuery @@ -157,6 +158,11 @@ def userprofile(self) -> list[str]: #: true if the user is active active: Column[bool] = Column(Boolean, nullable=False, default=True) + #: timestamp of the last successful login + last_login: Column[datetime | None] = Column( + UTCDateTime, nullable=True, default=None + ) + #: the signup token used by the user signup_token: Column[str | None] = Column( Text, diff --git a/src/onegov/user/upgrade.py b/src/onegov/user/upgrade.py index 4fe6fa6520..62b118443d 100644 --- a/src/onegov/user/upgrade.py +++ b/src/onegov/user/upgrade.py @@ -6,7 +6,7 @@ from collections import defaultdict from onegov.core.upgrade import upgrade_task -from onegov.core.orm.types import JSON, UUID +from onegov.core.orm.types import JSON, UUID, UTCDateTime from onegov.user import User, UserCollection from sqlalchemy import Boolean, Column, Text from sqlalchemy.sql import text @@ -301,3 +301,30 @@ def move_group_id_to_association_table(context: UpgradeContext) -> None: context.session.flush() context.operations.drop_column('users', 'group_id') + + +@upgrade_task('Add last_login column') +def add_last_login_column(context: UpgradeContext) -> None: + if not context.has_table('users'): + return + + context.operations.add_column( + 'users', Column('last_login', UTCDateTime, nullable=True) + ) + + +@upgrade_task('Alter last_login column to UTCDateTime') +def alter_last_login_to_utcdatetime(context: UpgradeContext) -> None: + if not context.has_table('users'): + return + + if not context.has_column('users', 'last_login'): + return + + context.operations.alter_column( + 'users', + 'last_login', + type_=UTCDateTime, + existing_type=None, + existing_nullable=True, + ) diff --git a/tests/onegov/user/test_auth.py b/tests/onegov/user/test_auth.py index 942f2c56ae..32d74fb355 100644 --- a/tests/onegov/user/test_auth.py +++ b/tests/onegov/user/test_auth.py @@ -330,3 +330,66 @@ def test_signup_expired(session: Session) -> None: client_addr='127.0.0.1' ) ) + + +def test_last_login_timestamp(session: Session, redis_url: str) -> None: + + class App(Framework, UserApp): + pass + + @App.identity_policy() + def get_identity_policy() -> IdentityPolicy: + return IdentityPolicy() + + @App.path(path='/auth', model=Auth) + def get_auth() -> Auth: + return Auth(DummyApp(session), to='/') # type: ignore[arg-type] + + @App.view(model=Auth) + def view_auth(self: Auth, request: CoreRequest) -> Response | str: + return ( + self.login_to( + request.GET['username'], request.GET['password'], request + ) + or 'Error' + ) + + App.commit() + + UserCollection(session).add('testuser', 'testpass', 'member') + transaction.commit() + + app = App() + app.namespace = 'test' + app.configure_application(identity_secure=False, redis_url=redis_url) + app.application_id = 'test/last-login' + + client = Client(app) + + user = UserCollection(session).by_username('testuser') + assert user is not None + assert user.last_login is None + + before_login = utcnow() + response = client.get('/auth?username=testuser&password=testpass') + after_login = utcnow() + + assert response.status_code == 302 + + session.expire_all() + user = UserCollection(session).by_username('testuser') + assert user is not None + assert user.last_login is not None + assert before_login <= user.last_login <= after_login + + first_login = user.last_login + time.sleep(0.1) + + response = client.get('/auth?username=testuser&password=testpass') + assert response.status_code == 302 + + session.expire_all() + user = UserCollection(session).by_username('testuser') + assert user is not None + assert user.last_login is not None + assert user.last_login > first_login From c342e0a26766af60de3af2bfb19128a9033dd756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cyrill=20K=C3=BCttel?= Date: Tue, 23 Dec 2025 09:53:04 +0100 Subject: [PATCH 2/7] Pas: Gets rid of inline event handler. TYPE: Bugfix LINK: OGC-2860 --- src/onegov/pas/templates/import_logs.pt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/onegov/pas/templates/import_logs.pt b/src/onegov/pas/templates/import_logs.pt index fa24a1216c..e10d02bfca 100644 --- a/src/onegov/pas/templates/import_logs.pt +++ b/src/onegov/pas/templates/import_logs.pt @@ -33,11 +33,8 @@ - - ${layout.format_date(log_entry.log.created, 'datetime_long')} + + ${layout.format_date(log_entry.log.created, 'datetime_long')} ${log_entry.log.user.username if log_entry.log.user else request.translate('System')} From 87762a7747b4900aa672fbd03a50b9fe0d7655c8 Mon Sep 17 00:00:00 2001 From: Chantal Trutmann <79152553+BreathingFlesh@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:58:28 +0100 Subject: [PATCH 3/7] Assembly: Add missing translations TYPE: Bugfix LINK: OGC-2896 --- .../de_CH/LC_MESSAGES/onegov.landsgemeinde.po | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/onegov/landsgemeinde/locale/de_CH/LC_MESSAGES/onegov.landsgemeinde.po b/src/onegov/landsgemeinde/locale/de_CH/LC_MESSAGES/onegov.landsgemeinde.po index 0cc0e7751f..bd44d630cc 100644 --- a/src/onegov/landsgemeinde/locale/de_CH/LC_MESSAGES/onegov.landsgemeinde.po +++ b/src/onegov/landsgemeinde/locale/de_CH/LC_MESSAGES/onegov.landsgemeinde.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: OneGov Cloud 1.0\n" -"POT-Creation-Date: 2025-10-23 14:05+0200\n" +"POT-Creation-Date: 2025-12-22 16:12+0100\n" "PO-Revision-Date: 2021-03-03 16:24+0100\n" "Language-Team: German\n" "Language: de_CH\n" @@ -363,14 +363,23 @@ msgstr "Nächstes Traktandum" msgid "Excerpt from the Memorial.pdf" msgstr "Auszug aus dem Memorial.pdf" +msgid "Links" +msgstr "Links" + msgid "No ${assembly} defined yet." msgstr "Noch keine ${assembly} erfasst." msgid "Downloads, Audio & Assembly items" msgstr "Downloads, Audio & Traktanden" -msgid "Memorial (PDF)" -msgstr "Memorial (PDF)" +msgid "Memorial part 1" +msgstr "Memorial Teil 1" + +msgid "Memorial part 2" +msgstr "Memorial Teil 2" + +msgid "Supplement to the memorial" +msgstr "Nachtrag zum Memorial" msgid "Protocol" msgstr "Protokoll" @@ -403,6 +412,9 @@ msgstr "" msgid "Copied to Clipboard!" msgstr "In die Zwischenablage kopiert!" +msgid "Memorial (PDF)" +msgstr "Memorial (PDF)" + msgid "more" msgstr "mehr" From b2f63b83afc9045ade850ebc0f704de78d6217b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cyrill=20K=C3=BCttel?= Date: Tue, 23 Dec 2025 17:03:37 +0100 Subject: [PATCH 4/7] Remove unneded upgrade task only used locally --- src/onegov/user/upgrade.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/onegov/user/upgrade.py b/src/onegov/user/upgrade.py index 62b118443d..8462df969f 100644 --- a/src/onegov/user/upgrade.py +++ b/src/onegov/user/upgrade.py @@ -312,19 +312,3 @@ def add_last_login_column(context: UpgradeContext) -> None: 'users', Column('last_login', UTCDateTime, nullable=True) ) - -@upgrade_task('Alter last_login column to UTCDateTime') -def alter_last_login_to_utcdatetime(context: UpgradeContext) -> None: - if not context.has_table('users'): - return - - if not context.has_column('users', 'last_login'): - return - - context.operations.alter_column( - 'users', - 'last_login', - type_=UTCDateTime, - existing_type=None, - existing_nullable=True, - ) From 9f71faa05285b2c7a1ea882bcfc701d8a139a276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cyrill=20K=C3=BCttel?= Date: Tue, 23 Dec 2025 19:01:00 +0100 Subject: [PATCH 5/7] fixes linting --- src/onegov/user/upgrade.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/onegov/user/upgrade.py b/src/onegov/user/upgrade.py index 8462df969f..5430f49a4f 100644 --- a/src/onegov/user/upgrade.py +++ b/src/onegov/user/upgrade.py @@ -311,4 +311,3 @@ def add_last_login_column(context: UpgradeContext) -> None: context.operations.add_column( 'users', Column('last_login', UTCDateTime, nullable=True) ) - From ed8387df0237b1abaded47a534af0aefebc8cf7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cyrill=20K=C3=BCttel?= Date: Tue, 23 Dec 2025 20:15:38 +0100 Subject: [PATCH 6/7] makes upgrade task more robust --- src/onegov/user/upgrade.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/onegov/user/upgrade.py b/src/onegov/user/upgrade.py index 5430f49a4f..3ea907a59e 100644 --- a/src/onegov/user/upgrade.py +++ b/src/onegov/user/upgrade.py @@ -308,6 +308,7 @@ def add_last_login_column(context: UpgradeContext) -> None: if not context.has_table('users'): return - context.operations.add_column( - 'users', Column('last_login', UTCDateTime, nullable=True) - ) + if not context.has_column('users', 'last_login'): + context.operations.add_column( + 'users', Column('last_login', UTCDateTime, nullable=True) + ) From be1d0ac69289150261a5d2a24ceb834563a3f6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cyrill=20K=C3=BCttel?= Date: Tue, 6 Jan 2026 15:48:08 +0100 Subject: [PATCH 7/7] Bulk pre-populate with jsonb_each --- src/onegov/user/upgrade.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/onegov/user/upgrade.py b/src/onegov/user/upgrade.py index 3ea907a59e..6ac4c8d161 100644 --- a/src/onegov/user/upgrade.py +++ b/src/onegov/user/upgrade.py @@ -312,3 +312,27 @@ def add_last_login_column(context: UpgradeContext) -> None: context.operations.add_column( 'users', Column('last_login', UTCDateTime, nullable=True) ) + + # Pre-populate last_login from existing session data + context.operations.execute( + """ + UPDATE users + SET last_login = subquery.max_timestamp::timestamp + FROM ( + SELECT + id, + MAX( + (session_value->>'timestamp')::timestamp + ) as max_timestamp + FROM + users, + LATERAL jsonb_each(data->'sessions') + AS session_entries(session_key, session_value) + WHERE + data->'sessions' IS NOT NULL + AND jsonb_typeof(data->'sessions') = 'object' + GROUP BY id + ) AS subquery + WHERE users.id = subquery.id; + """ + )