Display last seen/last played on admin user page, prepare for delete user feature.

This commit is contained in:
Jennifer Taylor
2026-08-30 19:41:09 +00:00
parent c67987f49a
commit 947252ccc0
5 changed files with 103 additions and 12 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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/<int:userid>/cards/add", methods=["POST"])

View File

@@ -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 = <span className="placeholder">anonymous account</span>;
if (event.userid == null) {
user = <span className="placeholder">deleted account</span>;
} else {
user = <span>{username}</span>;
if (this.props.users[event.userid]) {
username = this.props.users[event.userid];
}
if (username == null) {
user = <span className="placeholder">anonymous account</span>;
} else {
user = <span>{username}</span>;
}
}
}

View File

@@ -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 (
<LabelledSection vertical={true} label="Last Seen">
<Timestamp timestamp={this.state.last_played} className="lastseen" />
</LabelledSection>
);
},
deleteExistingProfile: function(event, refid) {
$.confirm({
escapeKey: 'Cancel',
@@ -474,6 +492,7 @@ var user_management = createReactClass({
{this.renderPassword()}
{this.renderEmail()}
{this.renderPIN()}
{this.renderLastPlayed()}
</div>
<div className="section">
<h3>Cards</h3>
@@ -533,6 +552,12 @@ var user_management = createReactClass({
return profile.name;
}.bind(this),
},
{
name: 'Last Played',
render: function(profile) {
return <Timestamp timestamp={profile.last_played} className="lastseen" />;
}.bind(this),
},
{
name: '',
render: this.renderDeleteProfileButton,