diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 39b94e91..5dcf275e 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -36,6 +36,8 @@ class User(db.Model): default=datetime.datetime.utcnow, ) + last_signed_in = db.Column(db.DateTime(), nullable=True) + __table_args__ = ( db.Index("ix_user_username", func.lower(username), unique=True), db.Index("ix_user_email", func.lower(email), unique=True), @@ -289,6 +291,7 @@ def __init__(self, user_id: int, ua: str, ip: str, device_id: Optional[str] = No self.user_agent = ua self.ip_address = ip self.device_id = device_id + self.timestamp = datetime.datetime.now(tz=datetime.timezone.utc) @staticmethod def add_record(user_id: int, req: request) -> None: @@ -300,4 +303,37 @@ def add_record(user_id: int, req: request) -> None: return lh = LoginHistory(user_id, ua, ip, device_id) db.session.add(lh) + + # cache user last login + User.query.filter_by(id=user_id).update({"last_signed_in": lh.timestamp}) + db.session.commit() + + @staticmethod + def get_users_last_signed_in(user_ids: list) -> dict: + """Get users last signed in dates. + Result is also cached in User table for future use. + """ + result = ( + db.session.query( + LoginHistory.user_id, + func.max(LoginHistory.timestamp).label("last_signed_in"), + ) + .filter(LoginHistory.user_id.in_(user_ids)) + .group_by(LoginHistory.user_id) + .all() + ) + + user_mapping = [ + { + "id": row.user_id, # user_id as PK in User table + "last_signed_in": row.last_signed_in, + } + for row in result + ] + if not user_mapping: + return {} + + # cache users last signed in + db.session.bulk_update_mappings(User, user_mapping) db.session.commit() + return {item["id"]: item["last_signed_in"] for item in user_mapping} diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index 90777122..d53b01bc 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -77,6 +77,15 @@ def test_login(client, data, headers, expected): ) assert login_history assert login_history.device_id == str(headers.get("X-Device-Id")) + assert user.last_signed_in == login_history.timestamp + users_last_signed_in = LoginHistory.get_users_last_signed_in([user.id]) + assert users_last_signed_in[user.id] == login_history.timestamp + + # verify missing value is cached on first LoginHistory access + user.last_signed_in = None + db.session.commit() + users_last_signed_in = LoginHistory.get_users_last_signed_in([user.id]) + assert user.last_signed_in == users_last_signed_in[user.id] def test_logout(client): @@ -376,6 +385,7 @@ def test_api_login(client, data, headers, expected): .first() ) assert login_history + assert user.last_signed_in == login_history.timestamp def test_api_login_from_urllib(client): @@ -394,6 +404,8 @@ def test_api_login_from_urllib(client): .first() ) assert not login_history + # we do not have recored last login yet + assert user.last_signed_in is None def test_api_user_profile(client): diff --git a/server/migrations/community/b9ec9ab6694f_add_user_last_signed_in.py b/server/migrations/community/b9ec9ab6694f_add_user_last_signed_in.py new file mode 100644 index 00000000..0ebc9250 --- /dev/null +++ b/server/migrations/community/b9ec9ab6694f_add_user_last_signed_in.py @@ -0,0 +1,25 @@ +"""Add user last signed in + +Revision ID: b9ec9ab6694f +Revises: 6cb54659c1de +Create Date: 2025-09-09 15:43:19.554498 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "b9ec9ab6694f" +down_revision = "6cb54659c1de" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("user", sa.Column("last_signed_in", sa.DateTime(), nullable=True)) + + +def downgrade(): + op.drop_column("user", "last_signed_in")