Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions server/mergin/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand All @@ -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}
12 changes: 12 additions & 0 deletions server/mergin/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Loading