309 lines
11 KiB
Python
309 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.fetchall()
|
|
]
|
|
|
|
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})
|
|
events = []
|
|
for result in cursor.fetchall():
|
|
if result['userid'] is not None:
|
|
userid = UserID(result['userid'])
|
|
else:
|
|
userid = None
|
|
if result['arcadeid'] is not None:
|
|
arcadeid = ArcadeID(result['arcadeid'])
|
|
else:
|
|
arcadeid = None
|
|
events.append(
|
|
Event(
|
|
result['id'],
|
|
result['timestamp'],
|
|
userid,
|
|
arcadeid,
|
|
result['type'],
|
|
self.deserialize(result['data']),
|
|
),
|
|
)
|
|
return events
|
|
|
|
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})
|