346 lines
11 KiB
Python
346 lines
11 KiB
Python
from sqlalchemy import Table, Column, UniqueConstraint # type: ignore
|
|
from sqlalchemy.types import String, Integer, Text, JSON # type: ignore
|
|
from sqlalchemy.dialects.mysql import BIGINT as BigInteger # type: ignore
|
|
from typing import Optional, Dict, List, Tuple, Any
|
|
|
|
from bemani.common import GameConstants, Time
|
|
from bemani.data.mysql.base import BaseData, metadata
|
|
from bemani.data.types import News, Event, UserID, ArcadeID
|
|
|
|
"""
|
|
Table for storing network news, as edited by an admin. This is displayed
|
|
on the front page of the frontend of the network.
|
|
"""
|
|
news = Table(
|
|
"news",
|
|
metadata,
|
|
Column("id", Integer, nullable=False, primary_key=True),
|
|
Column("timestamp", Integer, nullable=False, index=True),
|
|
Column("title", String(255), nullable=False),
|
|
Column("body", Text, nullable=False),
|
|
mysql_charset="utf8mb4",
|
|
)
|
|
|
|
"""
|
|
Table for storing scheduled work history, so that individual game code
|
|
can determine if it should run scheduled work or not.
|
|
"""
|
|
scheduled_work = Table(
|
|
"scheduled_work",
|
|
metadata,
|
|
Column("game", String(32), nullable=False),
|
|
Column("version", Integer, nullable=False),
|
|
Column("name", String(32), nullable=False),
|
|
Column("schedule", String(32), nullable=False),
|
|
Column("year", Integer),
|
|
Column("day", Integer),
|
|
UniqueConstraint(
|
|
"game", "version", "name", "schedule", name="game_version_name_schedule"
|
|
),
|
|
mysql_charset="utf8mb4",
|
|
)
|
|
|
|
"""
|
|
Table for storing audit entries, such as crashes, PCBID denials, daily
|
|
song selection, etc. Anything that could be inspected later to verify
|
|
correct operation of the network.
|
|
"""
|
|
audit = Table(
|
|
"audit",
|
|
metadata,
|
|
Column("id", Integer, nullable=False, primary_key=True),
|
|
Column("timestamp", Integer, nullable=False, index=True),
|
|
Column("userid", BigInteger(unsigned=True), index=True),
|
|
Column("arcadeid", Integer, index=True),
|
|
Column("type", String(64), nullable=False, index=True),
|
|
Column("data", JSON, nullable=False),
|
|
mysql_charset="utf8mb4",
|
|
)
|
|
|
|
|
|
class NetworkData(BaseData):
|
|
def get_all_news(self) -> List[News]:
|
|
"""
|
|
Grab all news in the system.
|
|
|
|
Returns:
|
|
A list of News objects sorted by timestamp.
|
|
"""
|
|
sql = "SELECT id, timestamp, title, body FROM news ORDER BY timestamp DESC"
|
|
cursor = self.execute(sql)
|
|
return [
|
|
News(
|
|
result["id"],
|
|
result["timestamp"],
|
|
result["title"],
|
|
result["body"],
|
|
)
|
|
for result in cursor
|
|
]
|
|
|
|
def create_news(self, title: str, body: str) -> int:
|
|
"""
|
|
Given a title and body, create a new news entry.
|
|
|
|
Parameters:
|
|
title - String title of the entry.
|
|
body - String body of the entry, may contain HTML.
|
|
|
|
Returns:
|
|
The ID of the newly created entry.
|
|
"""
|
|
sql = "INSERT INTO news (timestamp, title, body) VALUES (:timestamp, :title, :body)"
|
|
cursor = self.execute(
|
|
sql, {"timestamp": Time.now(), "title": title, "body": body}
|
|
)
|
|
return cursor.lastrowid
|
|
|
|
def get_news(self, newsid: int) -> Optional[News]:
|
|
"""
|
|
Given a news ID, grab that news entry from the DB.
|
|
|
|
Parameters:
|
|
newsid - Integer specifying news ID.
|
|
|
|
Returns:
|
|
A News object if the news entry was found or None otherwise.
|
|
"""
|
|
sql = "SELECT timestamp, title, body FROM news WHERE id = :id"
|
|
cursor = self.execute(sql, {"id": newsid})
|
|
if cursor.rowcount != 1:
|
|
# Couldn't find an entry with this ID
|
|
return None
|
|
|
|
result = cursor.fetchone()
|
|
return News(
|
|
newsid,
|
|
result["timestamp"],
|
|
result["title"],
|
|
result["body"],
|
|
)
|
|
|
|
def put_news(self, news: News) -> None:
|
|
"""
|
|
Given a news object, store it back into the DB.
|
|
|
|
Parameters:
|
|
news - A News object to be updated.
|
|
"""
|
|
sql = "UPDATE news SET title = :title, body = :body WHERE id = :id"
|
|
self.execute(sql, {"id": news.id, "title": news.title, "body": news.body})
|
|
|
|
def destroy_news(self, newsid: int) -> None:
|
|
"""
|
|
Given a news ID, remove that news entry from the DB.
|
|
|
|
Parameters:
|
|
newsid - Integer specifying news ID.
|
|
"""
|
|
sql = "DELETE FROM news WHERE id = :id LIMIT 1"
|
|
self.execute(sql, {"id": newsid})
|
|
|
|
def get_schedule_duration(self, schedule: str) -> Tuple[int, int]:
|
|
"""
|
|
Given a schedule type, returns the timestamp for the start and end
|
|
of the current schedule of this type.
|
|
"""
|
|
if schedule not in ["daily", "weekly"]:
|
|
raise Exception(
|
|
"Logic error, specify either 'daily' or 'weekly' for schedule type!"
|
|
)
|
|
|
|
if schedule == "daily":
|
|
return (Time.beginning_of_today(), Time.end_of_today())
|
|
|
|
if schedule == "weekly":
|
|
return (Time.beginning_of_this_week(), Time.end_of_this_week())
|
|
|
|
# Should never happen
|
|
return (0, 0)
|
|
|
|
def should_schedule(
|
|
self, game: GameConstants, version: int, name: str, schedule: str
|
|
) -> bool:
|
|
"""
|
|
Given a game/version/name pair and a schedule value, return whether
|
|
this scheduled work is overdue or not.
|
|
"""
|
|
if schedule not in ["daily", "weekly"]:
|
|
raise Exception(
|
|
"Logic error, specify either 'daily' or 'weekly' for schedule type!"
|
|
)
|
|
|
|
sql = """
|
|
SELECT year, day FROM scheduled_work
|
|
WHERE
|
|
game = :game AND
|
|
version = :version AND
|
|
name = :name AND
|
|
schedule = :schedule
|
|
"""
|
|
cursor = self.execute(
|
|
sql,
|
|
{
|
|
"game": game.value,
|
|
"version": version,
|
|
"name": name,
|
|
"schedule": schedule,
|
|
},
|
|
)
|
|
if cursor.rowcount != 1:
|
|
# No scheduled work was registered, so time to get going!
|
|
return True
|
|
|
|
result = cursor.fetchone()
|
|
|
|
if schedule == "daily":
|
|
# Just look at the day and year, make sure it matches
|
|
year, day = Time.days_into_year()
|
|
if year != result["year"]:
|
|
# Wrong year, so we certainly need to run!
|
|
return True
|
|
if day != result["day"]:
|
|
# Wrong day and we're daily, so need to run!
|
|
return True
|
|
|
|
if schedule == "weekly":
|
|
# Find the beginning of the week (Monday), as days since epoch.
|
|
if Time.week_in_days_since_epoch() != result["day"]:
|
|
# Wrong week, so we should run!
|
|
return True
|
|
|
|
# We have already run this work for this schedule
|
|
return False
|
|
|
|
def mark_scheduled(
|
|
self, game: GameConstants, version: int, name: str, schedule: str
|
|
) -> None:
|
|
if schedule not in ["daily", "weekly"]:
|
|
raise Exception(
|
|
"Logic error, specify either 'daily' or 'weekly' for schedule type!"
|
|
)
|
|
|
|
if schedule == "daily":
|
|
year, day = Time.days_into_year()
|
|
sql = """
|
|
INSERT INTO scheduled_work (game, version, name, schedule, year, day)
|
|
VALUES (:game, :version, :name, :schedule, :year, :day)
|
|
ON DUPLICATE KEY UPDATE year=VALUES(year), day=VALUES(day)
|
|
"""
|
|
self.execute(
|
|
sql,
|
|
{
|
|
"game": game.value,
|
|
"version": version,
|
|
"name": name,
|
|
"schedule": schedule,
|
|
"year": year,
|
|
"day": day,
|
|
},
|
|
)
|
|
|
|
if schedule == "weekly":
|
|
days = Time.week_in_days_since_epoch()
|
|
sql = """
|
|
INSERT INTO scheduled_work (game, version, name, schedule, day)
|
|
VALUES (:game, :version, :name, :schedule, :day)
|
|
ON DUPLICATE KEY UPDATE day=VALUES(day)
|
|
"""
|
|
self.execute(
|
|
sql,
|
|
{
|
|
"game": game.value,
|
|
"version": version,
|
|
"name": name,
|
|
"schedule": schedule,
|
|
"day": days,
|
|
},
|
|
)
|
|
|
|
def put_event(
|
|
self,
|
|
event: str,
|
|
data: Dict[str, Any],
|
|
timestamp: Optional[int] = None,
|
|
userid: Optional[UserID] = None,
|
|
arcadeid: Optional[ArcadeID] = None,
|
|
) -> None:
|
|
if timestamp is None:
|
|
timestamp = Time.now()
|
|
sql = "INSERT INTO audit (timestamp, userid, arcadeid, type, data) VALUES (:ts, :uid, :aid, :type, :data)"
|
|
self.execute(
|
|
sql,
|
|
{
|
|
"ts": timestamp,
|
|
"type": event,
|
|
"data": self.serialize(data),
|
|
"uid": userid,
|
|
"aid": arcadeid,
|
|
},
|
|
)
|
|
|
|
def get_events(
|
|
self,
|
|
userid: Optional[UserID] = None,
|
|
arcadeid: Optional[ArcadeID] = None,
|
|
event: Optional[str] = None,
|
|
limit: Optional[int] = None,
|
|
since_id: Optional[int] = None,
|
|
until_id: Optional[int] = None,
|
|
) -> List[Event]:
|
|
# Base query
|
|
sql = "SELECT id, timestamp, userid, arcadeid, type, data FROM audit "
|
|
|
|
# Lets get specific!
|
|
wheres = []
|
|
if userid is not None:
|
|
wheres.append("userid = :userid")
|
|
if arcadeid is not None:
|
|
wheres.append("arcadeid = :arcadeid")
|
|
if event is not None:
|
|
wheres.append("type = :event")
|
|
if since_id is not None:
|
|
wheres.append("id >= :since_id")
|
|
if until_id is not None:
|
|
wheres.append("id < :until_id")
|
|
if len(wheres) > 0:
|
|
sql = sql + f"WHERE {' AND '.join(wheres)} "
|
|
|
|
# Order it newest to oldest
|
|
sql = sql + "ORDER BY id DESC"
|
|
if limit is not None:
|
|
sql = sql + " LIMIT :limit"
|
|
cursor = self.execute(
|
|
sql,
|
|
{
|
|
"userid": userid,
|
|
"arcadeid": arcadeid,
|
|
"event": event,
|
|
"limit": limit,
|
|
"since_id": since_id,
|
|
"until_id": until_id,
|
|
},
|
|
)
|
|
|
|
return [
|
|
Event(
|
|
result["id"],
|
|
result["timestamp"],
|
|
UserID(result["userid"]) if result["userid"] is not None else None,
|
|
ArcadeID(result["arcadeid"])
|
|
if result["arcadeid"] is not None
|
|
else None,
|
|
result["type"],
|
|
self.deserialize(result["data"]),
|
|
)
|
|
for result in cursor
|
|
]
|
|
|
|
def delete_events(self, oldest_event_ts: int) -> None:
|
|
"""
|
|
Given a timestamp of the oldset event we should keep around, delete
|
|
all events older than this timestamp.
|
|
"""
|
|
sql = "DELETE FROM audit WHERE timestamp < :ts"
|
|
self.execute(sql, {"ts": oldest_event_ts})
|