From 947252ccc05855e9cdb38e063bf5b17a1d0eea9a Mon Sep 17 00:00:00 2001 From: Jennifer Taylor Date: Sun, 30 Aug 2026 19:41:09 +0000 Subject: [PATCH] Display last seen/last played on admin user page, prepare for delete user feature. --- bemani/data/mysql/base.py | 24 ++++++++++++++--- bemani/data/mysql/game.py | 23 ++++++++++++++++ bemani/frontend/admin/admin.py | 27 ++++++++++++++++--- .../static/components/events.react.js | 16 ++++++----- .../static/controllers/admin/user.react.js | 25 +++++++++++++++++ 5 files changed, 103 insertions(+), 12 deletions(-) diff --git a/bemani/data/mysql/base.py b/bemani/data/mysql/base.py index 5397a01..1a275c6 100644 --- a/bemani/data/mysql/base.py +++ b/bemani/data/mysql/base.py @@ -131,8 +131,8 @@ class BaseData: Given a previously-opened session, look up an ID. Parameters: - session - String identifying a session that was opened by create_session. - sesstype - Arbitrary string identifying the session type. + session - String identifying a session that was opened by _create_session. + sesstype - Arbitrary string identifying the session type, given as the optype to _create_session. Returns: ID as an integer if found, or None if the session is expired or doesn't exist. @@ -153,6 +153,7 @@ class BaseData: Parameters: opid - ID we wish to start a session for. + optype - The session type, which helps distinguish the ID from an identical one of a different ID type. expiration - Number of seconds before this session is invalid. Returns: @@ -192,7 +193,8 @@ class BaseData: Destroy a previously-created session. Parameters: - session - A session string as returned from create_session. + session - A session string as returned from _create_session. + sesstype - Arbitrary string identifying the session type, given as the optype to _create_session. """ # Remove the session token sql = "DELETE FROM session WHERE session = :session AND type = :sesstype" @@ -201,3 +203,19 @@ class BaseData: # Also weed out any other defunct sessions sql = "DELETE FROM session WHERE expiration < :timestamp" self.execute(sql, {"timestamp": Time.now()}, safe_write_operation=True) + + def _destroy_sessions(self, opid: int, optype: str) -> None: + """ + Destroy any previously created sessions based on ID and type. + + Parameters: + opid - A session's associated ID, the same as would be given to _create_session. + optype - A session's associated type, which segments IDs, the same as would be given to _create_session. + """ + # Remove the session token + sql = "DELETE FROM session WHERE id = :opid AND type = :optype" + self.execute(sql, {"opid": opid, "optype": optype}) + + # Also weed out any other defunct sessions + sql = "DELETE FROM session WHERE expiration < :timestamp" + self.execute(sql, {"timestamp": Time.now()}, safe_write_operation=True) diff --git a/bemani/data/mysql/game.py b/bemani/data/mysql/game.py index 04b4863..69fb3d5 100644 --- a/bemani/data/mysql/game.py +++ b/bemani/data/mysql/game.py @@ -99,6 +99,29 @@ class GameData(BaseData): result = cursor.mappings().fetchone() return ValidatedDict(self.deserialize(result["data"])) + def get_all_settings(self, userid: UserID) -> Dict[GameConstants, ValidatedDict]: + """ + Given a user ID, look up all game-wide settings as a dictionary. + + This is mostly used for the frontend for admins to be able to see when a profile was + last played. + + Parameters: + userid - Integer identifying a user, as possibly looked up by UserData. + + Returns: + A dictionary keyed by GameConstant whose value is a game settings dictionary + as returned by get_settings for that game. + """ + sql = "SELECT game, data FROM game_settings WHERE userid = :userid" + cursor = self.execute(sql, {"userid": userid}) + + return { + GameConstants(result["game"]): ValidatedDict(self.deserialize(result["data"])) + for result in cursor.mappings() + if result["game"] in GameConstants + } + def put_settings(self, game: GameConstants, userid: UserID, settings: Dict[str, Any]) -> None: """ Given a game and a user ID, save game-wide settings to the DB. diff --git a/bemani/frontend/admin/admin.py b/bemani/frontend/admin/admin.py index 3b7f123..6bd0255 100644 --- a/bemani/frontend/admin/admin.py +++ b/bemani/frontend/admin/admin.py @@ -103,13 +103,16 @@ def format_user(user: User) -> Dict[str, Any]: } -def format_profile(profile: Profile) -> Dict[str, object]: +def format_profile(profile: Profile, stats: Dict[GameConstants, ValidatedDict]) -> Dict[str, object]: + gamestats = stats.get(profile.game) or ValidatedDict() + return { "game": profile.game.value, "version": profile.version, "extid": ID.format_extid(profile.extid), "refid": profile.refid, "name": profile.get_str("name", ""), + "last_played": gamestats.get_int("last_play_timestamp", 0), } @@ -387,7 +390,12 @@ def viewuser(userid: int) -> Response: games[game.value][version] = name cards = [__format_card(card) for card in g.data.local.user.get_cards(userid)] - profiles = [format_profile(profile) for profile in g.data.local.user.get_profiles(userid)] + stats = g.data.local.game.get_all_settings(userid) + newest = 0 + for _, playstats in stats.items(): + newest = max(newest, playstats.get_int("last_play_timestamp", 0)) + + profiles = [format_profile(profile, stats) for profile in g.data.local.user.get_profiles(userid)] arcades = g.data.local.machine.get_all_arcades() return render_react( "User", @@ -396,6 +404,7 @@ def viewuser(userid: int) -> Response: "user": { "email": user.email, "username": user.username, + "last_played": newest, }, "games": games, "cards": cards, @@ -435,9 +444,20 @@ def listuser(userid: int) -> Dict[str, Any]: except CardCipherException: return "????????????????" + user = g.data.local.user.get_user(userid) cards = [__format_card(card) for card in g.data.local.user.get_cards(userid)] arcades = g.data.local.machine.get_all_arcades() + stats = g.data.local.game.get_all_settings(userid) + newest = 0 + for _, playstats in stats.items(): + newest = max(newest, playstats.get_int("last_play_timestamp", 0)) + return { + "user": { + "email": user.email, + "username": user.username, + "last_played": newest, + }, "cards": cards, "arcades": {arcade.id: arcade.name for arcade in arcades}, "balances": {arcade.id: g.data.local.user.get_balance(userid, arcade.id) for arcade in arcades}, @@ -1142,7 +1162,8 @@ def removeuserprofile(userid: int) -> Dict[str, Any]: g.data.local.user.delete_profile(profile.game, profile.version, userid) # Return new profile list - return {"profiles": [format_profile(profile) for profile in g.data.local.user.get_profiles(userid)]} + stats = g.data.local.game.get_all_settings(userid) + return {"profiles": [format_profile(profile, stats) for profile in g.data.local.user.get_profiles(userid)]} @admin_pages.route("/users//cards/add", methods=["POST"]) diff --git a/bemani/frontend/static/components/events.react.js b/bemani/frontend/static/components/events.react.js index 9158cb8..856024b 100644 --- a/bemani/frontend/static/components/events.react.js +++ b/bemani/frontend/static/components/events.react.js @@ -168,13 +168,17 @@ var PASELITransactionEvent = createReactClass({ var username = null; var user = null; if (this.props.users) { - if (this.props.users[event.userid]) { - username = this.props.users[event.userid]; - } - if (username == null) { - user = anonymous account; + if (event.userid == null) { + user = deleted account; } else { - user = {username}; + if (this.props.users[event.userid]) { + username = this.props.users[event.userid]; + } + if (username == null) { + user = anonymous account; + } else { + user = {username}; + } } } diff --git a/bemani/frontend/static/controllers/admin/user.react.js b/bemani/frontend/static/controllers/admin/user.react.js index 28e73f8..75e1c45 100644 --- a/bemani/frontend/static/controllers/admin/user.react.js +++ b/bemani/frontend/static/controllers/admin/user.react.js @@ -18,6 +18,7 @@ var user_management = createReactClass({ editing_password: false, new_password1: '', new_password2: '', + last_played: window.user.last_played, generating_recovery: false, cards: window.cards, profiles: window.profiles, @@ -46,12 +47,21 @@ var user_management = createReactClass({ AJAX.get( Link.get('refresh'), function(response) { + // Update key things about the user in case it changes. this.setState({ + last_played: response.user.last_played, cards: response.cards, balances: response.balances, arcades: response.arcades, events: response.events, }); + // Only update values if they aren't being edited. + if (!this.state.editing_email) { + this.setState({email: response.user.email}); + } + if (!this.state.editing_username) { + this.setState({username: response.user.username}); + } // Refresh every 15 seconds setTimeout(this.refreshUser, 5000); }.bind(this) @@ -422,6 +432,14 @@ var user_management = createReactClass({ ); }, + renderLastPlayed: function() { + return ( + + + + ); + }, + deleteExistingProfile: function(event, refid) { $.confirm({ escapeKey: 'Cancel', @@ -474,6 +492,7 @@ var user_management = createReactClass({ {this.renderPassword()} {this.renderEmail()} {this.renderPIN()} + {this.renderLastPlayed()}

Cards

@@ -533,6 +552,12 @@ var user_management = createReactClass({ return profile.name; }.bind(this), }, + { + name: 'Last Played', + render: function(profile) { + return ; + }.bind(this), + }, { name: '', render: this.renderDeleteProfileButton,