diff --git a/alembic/versions/dc4df9b41ca4_vip_rework_to_vip_lists.py b/alembic/versions/dc4df9b41ca4_vip_rework_to_vip_lists.py new file mode 100644 index 000000000..340415653 --- /dev/null +++ b/alembic/versions/dc4df9b41ca4_vip_rework_to_vip_lists.py @@ -0,0 +1,159 @@ +"""vip rework to vip lists + +Revision ID: dc4df9b41ca4 +Revises: 89a3502370a0 +Create Date: 2026-03-31 08:53:14.047059 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'dc4df9b41ca4' +down_revision = '89a3502370a0' +branch_labels = None +depends_on = None + + +def upgrade(): + + op.create_table( + "vip_list", + sa.Column("id", sa.Integer, nullable=False, primary_key=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column( + "sync", + sa.Enum("IGNORE_UNKNOWN", "REMOVE_UNKNOWN", name="viplistsyncmethod"), + nullable=False, + ), + sa.Column("servers", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "vip_list_record", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("admin_name", sa.String(), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("notes", sa.String(), nullable=True), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("player_id_id", sa.Integer(), nullable=False), + sa.Column("vip_list_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["player_id_id"], + ["steam_id_64.id"], + ), + sa.ForeignKeyConstraint(["vip_list_id"], ["vip_list.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "id", "player_id_id", "vip_list_id", name="unique_vip_player_id_vip_list" + ), + ) + op.create_index( + op.f("ix_vip_list_record_player_id_id"), + "vip_list_record", + ["player_id_id"], + unique=False, + ) + op.create_index( + op.f("ix_vip_list_record_vip_list_id"), + "vip_list_record", + ["vip_list_id"], + unique=False, + ) + + # Create default vip list from player_vip table + # using ID 0 for the default list + # and ID 1 for the default Seed VIP list + # We can't prefill the Seed VIP list reliably because it requires a game + # server connection and this runs in the maintenance container; but future + # records by default will go to the Seed VIP list + op.execute( + """INSERT INTO vip_list(id, name, sync, servers) + VALUES (0, 'Default', 'IGNORE_UNKNOWN', NULL) + ON CONFLICT DO NOTHING""" + ) + op.execute( + """INSERT INTO vip_list(name, sync, servers) + VALUES ('Seed VIP', 'IGNORE_UNKNOWN', NULL) + ON CONFLICT DO NOTHING""" + ) + + # Migrate old VIP records; this is not the same as VIP entries on the game server + # but whatever records were in player_vip + # We can't access their description; that is stored on the game server + # and this is run in the maintenance container with no connection to any server + op.execute( + """INSERT INTO + vip_list_record ( + admin_name, + created_at, + active, + description, + notes, + expires_at, + player_id_id, + vip_list_id + ) + SELECT + 'CRCON', + NOW (), + true, + NULL, + NULL, + CASE WHEN expiration > '2030-01-01' THEN NULL ELSE expiration END, + playersteamid_id, + 0 + FROM + ( + SELECT + expiration, + playersteamid_id, + ROW_NUMBER() OVER ( + PARTITION BY + playersteamid_id + ORDER BY + expiration DESC + ) AS rn + FROM + player_vip + WHERE server_number IS NOT NULL + ) sub + WHERE + sub.rn = 1 + """ + ) + + op.drop_constraint("unique_player_server_vip", "player_vip", type_="unique") + op.drop_index(op.f("ix_player_vip_playersteamid_id"), table_name="player_vip") + op.drop_table("player_vip") + + +def downgrade(): + op.create_table( + "player_vip", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("expiration", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column( + "playersteamid_id", + sa.Integer, + sa.ForeignKey("steam_id_64.id"), + nullable=False, + ), + sa.Column("server_number", sa.Integer(), nullable=True), + ) + + op.create_unique_constraint( + "unique_player_server_vip", "player_vip", ["playersteamid_id", "server_number"] + ) + op.create_index( + op.f("ix_player_vip_playersteamid_id"), + "player_vip", + ["playersteamid_id"], + unique=False, + ) + op.drop_table("vip_list_record") + op.drop_table("vip_list") + op.execute("DROP TYPE viplistsyncmethod") diff --git a/config/crontab b/config/crontab index 6a769639e..b7fa71ca0 100644 --- a/config/crontab +++ b/config/crontab @@ -7,6 +7,8 @@ LOGGING_FILENAME=cron.log 5 * * * * /bin/bash /config/do_logrotate.sh # This routine updates your database every day at 10:00, pull steam profiles older than 30 days 0 10 * * * /code/manage.py enrich_db_users +# This routine resynchs your VIP lists with the game server every 5 minutes +*/5 * * * * /code/manage.py inactivate_expired_vip_records # Below is an example show how to set your map to hill 400 at 9 am every day, remove the # to enable # 0 9 * * * /code/manage.py set_map hill400_warfare >> /config/cronout 2>&1 # Below is an example show how to set your welcome message to Hello (line break) toto... at 23h15 every day, you can use the same variables as in the UI diff --git a/config/supervisord.conf b/config/supervisord.conf index 025f2d723..6e6c2284a 100644 --- a/config/supervisord.conf +++ b/config/supervisord.conf @@ -10,11 +10,12 @@ environment=LOGGING_FILENAME=broadcasts_%(ENV_SERVER_NUMBER)s.log startretries=100 startsecs=1 -[program:expiring_vips] -command=/code/manage.py expiring_vips -environment=LOGGING_FILENAME=expiring_vips_%(ENV_SERVER_NUMBER)s.log -startretries=10 -autorestart=true +[program:vip_lists] +command=/code/manage.py vip_lists +environment=LOGGING_FILENAME=vip_lists_%(ENV_SERVER_NUMBER)s.log +startretries=100 +startsecs=10 +autostart=true [program:seed_vip] command=/code/manage.py seed_vip diff --git a/entrypoint.sh b/entrypoint.sh index 166e1fa9c..ede48c71d 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -56,6 +56,9 @@ then echo "from django.contrib.auth.models import User; User.objects.create_superuser('admin', 'admin@example.com', 'admin') if not User.objects.filter(username='admin').first() else None" | python manage.py shell fi export LOGGING_FILENAME=api_$SERVER_NUMBER.log + # Synchronize the game server with our VIP lists in case they differ + # which could be after moving game servers; long downtime, etc. + python -m rcon.cli synchronize_vip_lists daphne -b 0.0.0.0 -p 8001 rconweb.asgi:application & # Successfully running gunicorn will create the pid file which is how Docker determines the container is healthy gunicorn --preload --pid gunicorn.pid -w $NB_API_WORKERS -k gthread --threads $NB_API_THREADS -t 120 -b 0.0.0.0 rconweb.wsgi diff --git a/rcon/api_commands.py b/rcon/api_commands.py index 6bdd8eeaa..99df46d8b 100644 --- a/rcon/api_commands.py +++ b/rcon/api_commands.py @@ -5,7 +5,7 @@ from logging import getLogger from typing import Any, Dict, Iterable, Literal, Optional, Sequence, Type -from rcon import blacklist, game_logs, maps, player_history, webhook_service +from rcon import blacklist, game_logs, maps, player_history, webhook_service, vip from rcon.audit import ingame_mods, online_mods from rcon.cache_utils import RedisCached, get_redis_pool from rcon.commands import HLLCommandFailedError @@ -20,7 +20,7 @@ get_message_template_categories, get_message_templates, ) -from rcon.models import MessageTemplate, enter_session +from rcon.models import enter_session from rcon.player_history import ( add_flag_to_player, get_players_by_appearance, @@ -44,6 +44,11 @@ PlayerFlagType, PlayerProfileTypeEnriched, ServerInfoType, + VipListRecordEditType, + VipListRecordType, + VipListSyncMethod, + VipListType, + VipListTypeWithRecordsType, VoteMapStatusType, ) from rcon.user_config.auto_broadcast import AutoBroadcastUserConfig @@ -55,7 +60,6 @@ from rcon.user_config.ban_tk_on_connect import BanTeamKillOnConnectUserConfig from rcon.user_config.camera_notification import CameraNotificationUserConfig from rcon.user_config.chat_commands import ChatCommandsUserConfig -from rcon.user_config.expired_vips import ExpiredVipsUserConfig from rcon.user_config.gtx_server_name import GtxServerNameChangeUserConfig from rcon.user_config.legacy_scorebot import ScorebotUserConfig from rcon.user_config.log_line_webhooks import LogLineWebhookUserConfig @@ -84,7 +88,7 @@ KillsWebhooksUserConfig, WatchlistWebhooksUserConfig, ) -from rcon.utils import MISSING +from rcon.utils import MISSING, SERVER_NUMBER, MissingType from rcon.vote_map import VoteMap from rcon.watchlist import PlayerWatch @@ -1103,40 +1107,6 @@ def validate_camera_notification_config( reset_to_default=reset_to_default, ) - def get_expired_vip_config(self) -> ExpiredVipsUserConfig: - return ExpiredVipsUserConfig.load_from_db() - - def set_expired_vip_config( - self, - by: str, - config: dict[str, Any] | BaseUserConfig | None = None, - reset_to_default: bool = False, - **kwargs, - ) -> bool: - return self._validate_user_config( - command_name=inspect.currentframe().f_code.co_name, # type: ignore - by=by, - model=ExpiredVipsUserConfig, - data=config or kwargs, - dry_run=False, - reset_to_default=reset_to_default, - ) - - def validate_expired_vip_config( - self, - by: str, - config: dict[str, Any] | BaseUserConfig | None = None, - reset_to_default: bool = False, - **kwargs, - ) -> bool: - return self._validate_user_config( - command_name=inspect.currentframe().f_code.co_name, # type: ignore - by=by, - model=ExpiredVipsUserConfig, - data=config or kwargs, - dry_run=True, - reset_to_default=reset_to_default, - ) def get_server_name_change_config(self) -> GtxServerNameChangeUserConfig: return GtxServerNameChangeUserConfig.load_from_db() @@ -2109,3 +2079,254 @@ def edit_player_account( "account": account_db.to_dict(), "msg": "Successfully updated account details.", } + + # VIP List Endpoints + def get_vip_lists( + self, with_records: bool = False + ) -> list[VipListType | VipListTypeWithRecordsType]: + with enter_session() as sess: + return [ + lst.to_dict(with_records=with_records) + for lst in vip.get_vip_lists(sess=sess) + ] + + def get_vip_lists_for_server(self) -> list[VipListType]: + with enter_session() as sess: + return [ + lst.to_dict() + for lst in vip.get_vip_lists_for_server( + sess=sess, server_number=SERVER_NUMBER + ) + ] + + def get_vip_list( + self, vip_list_id: int, strict: bool = False, with_records: bool = False + ) -> VipListType | None: + with enter_session() as sess: + new_list = vip.get_vip_list( + sess=sess, vip_list_id=vip_list_id, strict=strict + ) + return new_list.to_dict(with_records=with_records) if new_list else None + + def create_vip_list( + self, name: str, sync: VipListSyncMethod, servers: Sequence[int] | None + ) -> VipListType: + return vip.create_vip_list(name=name, sync=sync, servers=servers) + + def edit_vip_list( + self, + vip_list_id: int, + name: str | MissingType = MISSING, + sync: VipListSyncMethod | MissingType = MISSING, + servers: Sequence[int] | MissingType = MISSING, + ) -> VipListType | None: + return vip.edit_vip_list( + vip_list_id=vip_list_id, name=name, sync=sync, servers=servers + ) + + def delete_vip_list(self, vip_list_id: int) -> bool: + return vip.delete_vip_list(vip_list_id=vip_list_id) + + def get_vip_list_record( + self, record_id: int, strict: bool = False + ) -> VipListRecordType | None: + with enter_session() as sess: + record = vip.get_vip_record(sess=sess, record_id=record_id, strict=strict) + return record.to_dict() if record else None + + def get_player_vip_list_record(self, player_id: str, vip_list_id: int): + return vip.get_player_vip_list_record( + player_id=player_id, + vip_list_id=vip_list_id, + ) + + def add_vip_list_record( + self, + player_id: str, + vip_list_id: int, + description: str | None = None, + active: bool = True, + expires_at: datetime | None = None, + notes: str | None = None, + admin_name: str = "CRCON", + ) -> VipListRecordType | None: + return vip.add_record_to_vip_list( + player_id=player_id, + vip_list_id=vip_list_id, + description=description, + active=active, + expires_at=expires_at, + notes=notes, + admin_name=admin_name, + ) + + def edit_vip_list_record( + self, + record_id: int, + vip_list_id: int | MissingType = MISSING, + description: str | MissingType = MISSING, + active: bool | MissingType = MISSING, + expires_at: datetime | MissingType = MISSING, + notes: str | MissingType = MISSING, + admin_name: str = "CRCON", + ) -> VipListRecordType | None: + return vip.edit_vip_list_record( + record_id=record_id, + vip_list_id=vip_list_id, + description=description, + active=active, + expires_at=expires_at, + notes=notes, + admin_name=admin_name, + ) + + def add_or_edit_vip_list_record( + self, + player_id: str, + vip_list_id: int, + description: str | MissingType = MISSING, + active: bool | MissingType = MISSING, + expires_at: datetime | MissingType = MISSING, + notes: str | MissingType = MISSING, + admin_name: str = "CRCON", + ): + return vip.add_or_edit_vip_list_record( + player_id=player_id, + vip_list_id=vip_list_id, + description=description, + active=active, + expires_at=expires_at, + notes=notes, + admin_name=admin_name, + ) + + def bulk_add_vip_list_records( + self, records: Iterable[VipListRecordType] + ) -> None | defaultdict[str, set[int]]: + return vip.bulk_add_vip_records(records=records) + + def bulk_delete_vip_list_records(self, record_ids: Iterable[int]): + return vip.bulk_delete_vip_records(record_ids=record_ids) + + def bulk_edit_vip_list_records( + self, records: Iterable[VipListRecordEditType] + ) -> None: + return vip.bulk_edit_vip_records(records=records) + + def delete_vip_list_record( + self, + record_id: int, + ) -> bool: + return vip.delete_vip_list_record(record_id=record_id) + + def get_vip_status_for_player_ids( + self, player_ids: set[str] + ) -> dict[str, VipListRecordType]: + return vip.get_vip_status_for_player_ids(player_ids=player_ids) + + def get_active_vip_records(self, vip_list_id: int) -> list[VipListRecordType]: + with enter_session() as sess: + return [ + record.to_dict() + for record in vip.get_active_vip_records( + sess=sess, vip_list_id=vip_list_id + ) + ] + + def get_inactive_vip_records(self, vip_list_id: int) -> list[VipListRecordType]: + with enter_session() as sess: + return [ + record.to_dict() + for record in vip.get_inactive_vip_records( + sess=sess, vip_list_id=vip_list_id + ) + ] + + def get_player_vip_records( + self, + player_id: str, + include_expired: bool = True, + include_inactive: bool = True, + include_other_servers=True, + exclude: set[int] | None = None, + ) -> list[VipListRecordType]: + with enter_session() as sess: + return [ + record.to_dict() + for record in vip.get_player_vip_list_records( + sess=sess, + player_id=player_id, + include_expired=include_expired, + include_inactive=include_inactive, + include_other_servers=include_other_servers, + exclude=exclude, + ) + ] + + def get_vip_list_records( + self, + player_id: str | None = None, + # Can't use admin_name without the API introspection will set it + # whatever user made the API call + author_admin_name: str | None = None, + include_active: bool | None = None, + description_or_player_name: str | None = None, + notes: str | None = None, + vip_list_id: int | None = None, + exclude_expired: bool = False, + page_size: int = 50, + page: int = 1, + ): + # TODO: type this + with enter_session() as sess: + records, total_records = vip.search_vip_list_records( + sess=sess, + player_id=player_id, + admin_name=author_admin_name, + active=include_active, + description_or_player_name=description_or_player_name, + notes=notes, + vip_list_id=vip_list_id, + exclude_expired=exclude_expired, + page_size=page_size, + page=page, + ) + + return { + "records": [record.to_dict(with_vip_list=True) for record in records], + "total": total_records, + } + + def get_all_vip_records_for_server( + self, server_number: int + ) -> list[VipListRecordType]: + with enter_session() as sess: + return [ + record.to_dict() + for record in vip.get_all_records_for_server( + sess=sess, server_number=server_number + ) + ] + + def inactivate_expired_vip_records(self) -> None: + return vip.inactivate_expired_records() + + def extend_vip_duration( + self, player_id: str, vip_list_id: int, duration: timedelta | int + ) -> VipListRecordType | None: + return vip.extend_vip_duration( + player_id=player_id, vip_list_id=vip_list_id, duration=duration + ) + + def revoke_all_vip(self, player_id: str): + return vip.revoke_all_vip(player_id=player_id) + + def synchronize_with_game_server(self): + return vip.synchronize_with_game_server(server_number=SERVER_NUMBER) + + def convert_old_style_vip_records(self, records: Iterable[str], vip_list_id: int): + return vip.convert_old_style_vip_records( + records=records, vip_list_id=vip_list_id + ) + + # End VIP List Endpoints \ No newline at end of file diff --git a/rcon/blacklist.py b/rcon/blacklist.py index 3516fdf3a..12158200e 100644 --- a/rcon/blacklist.py +++ b/rcon/blacklist.py @@ -41,7 +41,7 @@ BlacklistType, PlayerActionState, ) -from rcon.utils import MISSING, get_server_number +from rcon.utils import MISSING, get_server_number, get_server_number_mask logger = logging.getLogger(__name__) red = get_redis_client() @@ -74,11 +74,6 @@ def update_penalty_count( logger.exception(e) -def get_server_number_mask(): - server_number = SERVER_NUMBER - return 1 << (server_number - 1) - - def get_blacklists(sess: Session): return sess.scalars(select(Blacklist).order_by(Blacklist.id)).all() @@ -241,7 +236,7 @@ def search_blacklist_records( def is_player_blacklisted( - sess: Session, player_id: str, exclude: set[int] = {} + sess: Session, player_id: str, exclude: set[int] = set() ) -> BlacklistRecord | None: """Determine whether a player is blacklisted, and return the blacklist record with the highest priority. @@ -514,7 +509,7 @@ def add_record_to_blacklist( player_names = [n.name for n in player.names] if not player: raise RuntimeError( - "Unable to create player Steam ID, check the DB connection" + "Unable to create PlayerID record, check the DB connection" ) blacklist = get_blacklist(sess, blacklist_id, True) diff --git a/rcon/cli.py b/rcon/cli.py index b4ea251f8..7583f22df 100644 --- a/rcon/cli.py +++ b/rcon/cli.py @@ -3,14 +3,15 @@ import logging import sys from datetime import datetime, timedelta +from random import randint +from time import sleep from typing import Any, Set, Type import click import pydantic from sqlalchemy import func as pg_func -from sqlalchemy import select, text, update +from sqlalchemy import select, text -import rcon.expiring_vips.service import rcon.seed_vip.service import rcon.user_config import rcon.user_config.utils @@ -37,6 +38,13 @@ BaseWebhookUserConfig, ) from rcon.utils import ApiKey +from rcon.vip import ( + ALL_SERVERS_MASK, + VipListCommand, + VipListCommandHandler, + VipListCommandType, + VipListInactivateExpiredCommand, +) from rcon.vote_map import VoteMap logger = logging.getLogger(__name__) @@ -112,11 +120,6 @@ def run_routines(): routines.run() -@cli.command(name="expiring_vips") -def run_expiring_vips(): - rcon.expiring_vips.service.run() - - @cli.command(name="seed_vip") def run_seed_vip(): try: @@ -145,6 +148,36 @@ def run_blacklists(): BlacklistCommandHandler().run() +@cli.command(name="vip_lists") +def run_vip_lists(): + VipListCommandHandler().run() + + +@cli.command(name="inactivate_expired_vip_records") +def inactivate_expired_vip_records(): + # TODO: this should get done in a global supervisor way, not per container + # so to reduce collision chances sleep a random number of secons up to 2 minutes + sleep(randint(0, 120)) + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.INACTIVATE_EXPIRED, + server_mask=ALL_SERVERS_MASK, + payload=VipListInactivateExpiredCommand().model_dump(), + ) + ) + + +@cli.command(name="synchronize_vip_lists") +def synchronize_vip_lists(): + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.SYNCH_GAME_SERVER, + server_mask=ALL_SERVERS_MASK, + payload=VipListInactivateExpiredCommand().model_dump(), + ) + ) + + @cli.command(name="log_recorder") @click.option("-t", "--frequency-min", required=False) @click.option("-i", "--interval", default=10) @@ -184,28 +217,11 @@ def unregister(): ApiKey().delete_key() -@cli.command(name="import_vips") -@click.argument("file", type=click.File("r")) -@click.option("-p", "--prefix", default="") -def importvips(file, prefix): - ctl = get_rcon() - for line in file: - line = line.strip() - player_id, name = line.split(" ", 1) - ctl.add_vip(player_id=player_id, description=f"{prefix}{name}") - - @cli.command(name="clear_cache") def clear(): RedisCached.clear_all_caches(get_redis_pool()) -@cli.command -def export_vips(): - ctl = get_rcon() - print("/n".join(f"{d['player_id']} {d['name']}" for d in ctl.get_vip_ids())) - - def do_print(func): def wrap(*args, **kwargs): from pprint import pprint @@ -524,12 +540,6 @@ def _merge_duplicate_player_ids(existing_ids: set[str] | None = None): ), {"keep": keep, "ids": ids}, ) - session.execute( - text( - "UPDATE player_vip SET playersteamid_id = :keep WHERE playersteamid_id = ANY(:ids)" - ), - {"keep": keep, "ids": ids}, - ) session.execute( text( "UPDATE player_watchlist SET playersteamid_id = :keep WHERE playersteamid_id = ANY(:ids)" diff --git a/rcon/commands.py b/rcon/commands.py index ce611a899..b286246ad 100644 --- a/rcon/commands.py +++ b/rcon/commands.py @@ -9,7 +9,7 @@ from rcon.connection import HLLCommandError, HLLConnection, Handle, Response from rcon.maps import LAYERS, MAPS, UNKNOWN_MAP_NAME, Environment, GameMode, LayerType from rcon.perf_statistics import PerformanceStatistics -from rcon.types import MapRotationResponse, MapSequenceResponse, PlayerInfoType, ServerInfoType, SlotsType, VipId, GameStateType, AdminType +from rcon.types import MapRotationResponse, MapSequenceResponse, PlayerInfoType, ServerInfoType, SlotsType, VipIdType, GameStateType, AdminType from rcon.utils import exception_in_chain logger = logging.getLogger(__name__) @@ -378,9 +378,9 @@ def get_slots(self) -> SlotsType: max_players=resp["maxPlayerCount"], ) - def get_vip_ids(self) -> list[VipId]: + def get_vip_ids(self) -> list[VipIdType]: return [ - VipId(player_id=vip["iD"], name=vip["comment"]) + VipIdType(player_id=vip["iD"], name=vip["comment"]) for vip in self.exchange("GetServerInformation", 2, {"Name": "vipplayers", "Value": ""}).content_dict["vipPlayers"] ] diff --git a/rcon/expiring_vips/__init__.py b/rcon/expiring_vips/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/rcon/expiring_vips/service.py b/rcon/expiring_vips/service.py deleted file mode 100644 index c1a842fa4..000000000 --- a/rcon/expiring_vips/service.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging -import time -from datetime import datetime, timedelta, timezone -from typing import List, Optional - -from pydantic import HttpUrl - -from rcon.discord import send_to_discord_audit -from rcon.models import PlayerID, PlayerVIP, enter_session -from rcon.rcon import Rcon, get_rcon -from rcon.user_config.expired_vips import ExpiredVipsUserConfig -from rcon.utils import INDEFINITE_VIP_DATE, get_server_number - -SERVICE_NAME = "ExpiringVIPs" -logger = logging.getLogger(__name__) - - -def remove_expired_vips(rcon_hook: Rcon, webhook_url: Optional[HttpUrl] = None): - logger.info(f"Checking for expired VIPs") - - count = 0 - server_number = get_server_number() - with enter_session() as session: - expired_vips: List[PlayerVIP] = ( - session.query(PlayerVIP) - .filter( - PlayerVIP.server_number == server_number, - PlayerVIP.expiration < datetime.utcnow(), - ) - .all() - ) - - count = len(expired_vips) - for vip in expired_vips: - name: str - try: - name = vip.player.names[0].name - except IndexError: - name = "No name found" - message = f"Removing VIP from `{name}`/`{vip.player.player_id}` expired `{vip.expiration}`" - logger.info(message) - - webhookurls: list[HttpUrl | None] | None - if webhook_url is None: - webhookurls = None - else: - webhookurls = [webhook_url] - send_to_discord_audit( - message=message, - command_name="remove_vip", - by=SERVICE_NAME, - webhookurls=webhookurls, - ) - rcon_hook.remove_vip(vip.player.player_id) - - # Look for anyone with VIP but without a record and create one for them - vip_ids = rcon_hook.get_vip_ids() - missing_expiration_records = [] - for player in vip_ids: - player_expiration: datetime | None = player["vip_expiration"] - if player_expiration is None: - missing_expiration_records.append(player) - # Find any old style records that had a floating creation date + 200 year expiration - # so they get changed to the new fixed UTC 3000-01-01 datetime - elif ( - player_expiration - and player_expiration - >= datetime.now(timezone.utc) + timedelta(days=365 * 100) - and player_expiration.year < 3000 - ): - missing_expiration_records.append(player) - logger.info( - "Correcting old style expiration date for %s", player["player_id"] - ) - - for raw_player in missing_expiration_records: - player: PlayerID = ( - session.query(PlayerID) - .filter(PlayerID.player_id == raw_player["player_id"]) - .one_or_none() - ) - - if player: - expiration_date = INDEFINITE_VIP_DATE - vip_record = ( - session.query(PlayerVIP) - .filter( - PlayerVIP.player_id_id == player.id, - PlayerVIP.server_number == get_server_number(), - ) - .one_or_none() - ) - - if vip_record: - vip_record.expiration = expiration_date - else: - vip_record = PlayerVIP( - expiration=expiration_date, - player_id_id=player.id, - server_number=server_number, - ) - session.add(vip_record) - - try: - name = player.names[0].name - except IndexError: - name = "No name found" - - logger.info( - f"Creating missing VIP expiration (indefinite) record for {name} / {player.player_id}" - ) - else: - logger.info( - f"{raw_player['player_id']} has VIP on the server but does not have a PlayerSteamID record." - ) - - if count > 0: - logger.info(f"Removed VIP from {count} player(s)") - else: - logger.info("No expired VIPs found") - - -def run(): - rcon_hook = get_rcon() - - while True: - config = ExpiredVipsUserConfig.load_from_db() - - if config.enabled: - remove_expired_vips(rcon_hook, config.discord_webhook_url) - - time.sleep(config.interval_minutes * 60) - - -if __name__ == "__main__": - run() diff --git a/rcon/message_variables.py b/rcon/message_variables.py index 65dcb280c..4becc47a4 100644 --- a/rcon/message_variables.py +++ b/rcon/message_variables.py @@ -11,7 +11,12 @@ from rcon.maps import Layer, categorize_maps, numbered_maps from rcon.player_stats import get_cached_live_game_stats, get_stat from rcon.rcon import Rcon, get_rcon -from rcon.types import CachedLiveGameStats, MessageVariable, PlayerStatsEnum, VipIdType +from rcon.types import ( + CachedLiveGameStats, + MessageVariable, + PlayerStatsEnum, + VipIdWithExpirationType, +) from rcon.user_config.rcon_server_settings import RconServerSettingsUserConfig from rcon.user_config.vote_map import VoteMapUserConfig from rcon.user_config.webhooks import AdminPingWebhooksUserConfig @@ -294,12 +299,11 @@ def format_message_string( def _vip_status( player_id: str | None = None, rcon: Rcon | None = None -) -> VipIdType | None: +) -> VipIdWithExpirationType | None: if rcon is None: rcon = get_rcon() vip = [v for v in rcon.get_vip_ids() if v["player_id"] == player_id] - logger.info(f"{vip=}") if vip: return vip[0] @@ -316,7 +320,7 @@ def _vip_expiration( ) -> datetime | None: vip = _vip_status(player_id=player_id, rcon=rcon) - return vip["vip_expiration"] if vip else None + return vip["expires_at"] if vip else None def _server_short_name(config: RconServerSettingsUserConfig | None = None) -> str: diff --git a/rcon/models.py b/rcon/models.py index 165529fde..880a3709e 100644 --- a/rcon/models.py +++ b/rcon/models.py @@ -3,9 +3,19 @@ import re from collections import defaultdict from contextlib import contextmanager -from datetime import datetime, timezone -from typing import Any, Generator, List, Literal, Optional, Sequence, overload, TypedDict +from datetime import datetime, timedelta, timezone +from typing import ( + Any, + Generator, + List, + Literal, + Optional, + Sequence, + TypedDict, + overload, +) +import dateutil.parser import pydantic from sqlalchemy import TIMESTAMP, Enum, ForeignKey, String, create_engine, select, text, JSON from sqlalchemy.dialects.postgresql import JSONB @@ -26,7 +36,7 @@ from rcon.types import ( AuditLogType, GetDetailedPlayer, - MessageTemplateType, + BasicPlayerProfileType, BlacklistRecordType, BlacklistRecordWithBlacklistType, BlacklistRecordWithPlayerType, @@ -35,6 +45,7 @@ BlacklistWithRecordsType, DBLogLineType, MapsType, + MessageTemplateType, PenaltyCountType, PlayerAccountType, PlayerActionState, @@ -48,14 +59,21 @@ PlayerSessionType, PlayerSoldierType, PlayerStatsType, - PlayerVIPType, ServerCountType, SteamBansType, SteamInfoType, SteamPlayerSummaryType, StructuredLogLineWithMetaData, WatchListType, - MessageTemplateCategory, PlayerTeamAssociation, PlayerTeamConfidence, GameLayout, + MessageTemplateCategory, + PlayerTeamAssociation, + PlayerTeamConfidence, + GameLayout, + VipListRecordType, + VipListRecordWithVipListType, + VipListSyncMethod, + VipListType, + VipListTypeWithRecordsType, ) from rcon.utils import ( SafeStringFormat, @@ -125,11 +143,7 @@ class PlayerID(Base): stats: Mapped["PlayerStats"] = relationship(back_populates="player") blacklists: Mapped[list["BlacklistRecord"]] = relationship(back_populates="player") - vips: Mapped[list["PlayerVIP"]] = relationship( - back_populates="player", - cascade="all, delete-orphan", - lazy="dynamic", - ) + vip_lists: Mapped[list["VipListRecord"]] = relationship(back_populates="player") optins: Mapped[list["PlayerOptins"]] = relationship(back_populates="player") account: Mapped["PlayerAccount"] = relationship(back_populates="player") soldier: Mapped["PlayerSoldier"] = relationship(back_populates="player") @@ -138,18 +152,6 @@ class PlayerID(Base): def server_number(self) -> int: return int(os.getenv("SERVER_NUMBER")) # type: ignore - @hybrid_property - def vip(self) -> Optional["PlayerVIP"]: - return ( - object_session(self) - .query(PlayerVIP) # type: ignore - .filter( - PlayerVIP.player_id_id == self.id, - PlayerVIP.server_number == self.server_number, - ) - .one_or_none() - ) - def get_penalty_count(self) -> PenaltyCountType: counts = defaultdict(int) for action in self.received_actions: @@ -188,7 +190,12 @@ def get_current_playtime_seconds(self) -> int: def to_dict(self, limit_sessions=5) -> PlayerProfileType: this_server = int(get_server_number()) - blacklists = [record.to_dict() for record in self.blacklists] + blacklists: List[BlacklistRecordWithBlacklistType] = [ + record.to_dict() for record in self.blacklists + ] + vip_lists: List[VipListRecordWithVipListType] = [ + record.to_dict() for record in self.vip_lists + ] is_blacklisted = any( record["is_active"] for record in [ @@ -198,8 +205,15 @@ def to_dict(self, limit_sessions=5) -> PlayerProfileType: or any(server == this_server for server in b["blacklist"]["servers"]) ] ) - vips = [v.to_dict() for v in self.vips] - is_vip = any(vip["server_number"] == this_server for vip in vips) + is_vip = any( + record["is_active"] and not record["is_expired"] + # Applies to the server the profile was fetched for + and ( + record["vip_list"]["servers"] is None + or str(self.server_number in record["vip_list"]["servers"]) + ) + for record in vip_lists + ) return { "id": self.id, PLAYER_ID: self.player_id, @@ -214,12 +228,12 @@ def to_dict(self, limit_sessions=5) -> PlayerProfileType: "received_actions": [action.to_dict() for action in self.received_actions], "penalty_count": self.get_penalty_count(), "blacklists": blacklists, + "vip_lists": vip_lists, "is_blacklisted": is_blacklisted, "flags": [f.to_dict() for f in (self.flags or [])], "watchlist": self.watchlist.to_dict() if self.watchlist else None, "is_watched": self.watchlist.is_watched if self.watchlist else False, "steaminfo": self.steaminfo.to_dict() if self.steaminfo else None, - "vips": vips, "is_vip": is_vip, "soldier": self.soldier.to_dict(), "account": self.account.to_dict(), @@ -1000,34 +1014,6 @@ def to_dict(self) -> PlayerAtCountType: } -class PlayerVIP(Base): - __tablename__: str = "player_vip" - __table_args__ = ( - UniqueConstraint( - "playersteamid_id", "server_number", name="unique_player_server_vip" - ), - ) - - id: Mapped[int] = mapped_column(primary_key=True) - expiration: Mapped[datetime] = mapped_column( - TIMESTAMP(timezone=True), nullable=False - ) - # Not making this unique (even though it should be) to avoid breaking existing CRCONs - server_number: Mapped[int] = mapped_column() - - player_id_id: Mapped[int] = mapped_column( - "playersteamid_id", ForeignKey("steam_id_64.id"), nullable=False, index=True - ) - - player: Mapped[PlayerID] = relationship(back_populates="vips") - - def to_dict(self) -> PlayerVIPType: - return { - "server_number": self.server_number, - "expiration": self.expiration, - } - - class AuditLog(Base): __tablename__: str = "audit_log" @@ -1282,3 +1268,153 @@ def to_dict(self) -> MessageTemplateType: "updated_at": self.updated_at, "updated_by": self.updated_by, } + + +class VipList(Base): + __tablename__ = "vip_list" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + sync: Mapped[VipListSyncMethod] = mapped_column( + Enum(VipListSyncMethod), default=VipListSyncMethod.IGNORE_UNKNOWN + ) + servers: Mapped[Optional[int]] + + records: Mapped[list["VipListRecord"]] = relationship( + back_populates="vip_list", cascade="all, delete" + ) + + def get_server_numbers(self) -> Optional[set[int]]: + if self.servers is None: + return None + + return mask_to_server_numbers(self.servers) + + def set_server_numbers(self, server_numbers: Sequence[int] | None): + if server_numbers is None: + self.set_all_servers() + else: + self.servers = server_numbers_to_mask(*server_numbers) + + def set_all_servers(self): + self.servers = None + + @overload + def to_dict(self, with_records: Literal[True]) -> VipListTypeWithRecordsType: ... + @overload + def to_dict(self, with_records: Literal[False]) -> VipListType: ... + @overload + def to_dict(self, with_records: bool = False) -> VipListType: ... + + def to_dict(self, with_records: bool = False) -> VipListType: + res: VipListType = { + "id": self.id, + "name": self.name, + "sync": self.sync, + "servers": list(self.get_server_numbers()) if self.servers else None, + } + + if with_records: + res_with_records: VipListTypeWithRecordsType = { + **res, + "records": [record.to_dict() for record in self.records], + } + return res_with_records + + return res + + +class VipListRecord(Base): + __tablename__ = "vip_list_record" + + # To simplify some logic/endpoints; only allow one record per player per list + __table_args__ = ( + UniqueConstraint( + "player_id_id", "vip_list_id", name="unique_vip_player_id_vip_list" + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + admin_name: Mapped[str] + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), default=lambda: datetime.now(tz=timezone.utc) + ) + active: Mapped[bool] + description: Mapped[Optional[str]] + notes: Mapped[Optional[str]] + expires_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True)) + + player_id_id: Mapped[int] = mapped_column( + ForeignKey("steam_id_64.id"), nullable=False, index=True + ) + vip_list_id: Mapped[int] = mapped_column( + ForeignKey("vip_list.id", ondelete="CASCADE"), nullable=False, index=True + ) + + player: Mapped["PlayerID"] = relationship(back_populates="vip_lists") + vip_list: Mapped[VipList] = relationship(back_populates="records") + + def expires_in(self) -> timedelta | None: + if not self.expires_at: + return None + return self.expires_at - datetime.now(tz=timezone.utc) + + def is_expired(self) -> bool: + if self.expires_at is None: + return False + + # For whatever reason despite our mapped type SQLAlchemy sometimes (always?) + # is returning these as strings and not datetimes + try: + if isinstance(self.expires_at, str): + expires_at = dateutil.parser.parse(self.expires_at) + else: + expires_at = self.expires_at + except dateutil.parser.ParserError as e: + logger.exception(e) + expires_at = None + + return expires_at is not None and expires_at <= datetime.now(tz=timezone.utc) + + @overload + def to_dict(self, with_vip_list: Literal[False]) -> VipListRecordType: ... + @overload + def to_dict(self, with_vip_list: Literal[True]) -> VipListRecordWithVipListType: ... + @overload + def to_dict(self, with_vip_list: bool = True) -> VipListRecordWithVipListType: ... + + def to_dict( + self, with_vip_list: bool = True + ) -> VipListRecordType | VipListRecordWithVipListType: + player_profile: BasicPlayerProfileType = { + "id": self.player.id, + "player_id": self.player.player_id, + "created": self.player.created, + "names": [name.to_dict() for name in self.player.names], + "steaminfo": ( + self.player.steaminfo.to_dict() if self.player.steaminfo else None + ), + } + + data: VipListRecordType = { + "id": self.id, + "vip_list_id": self.vip_list_id, + "player_id": self.player.player_id, + "admin_name": self.admin_name, + "is_active": self.active, + "is_expired": self.is_expired(), + "created_at": self.created_at, + "expires_at": self.expires_at, + "description": self.description, + "notes": self.notes, + "player": player_profile, + } + + if with_vip_list: + data_with_list: VipListRecordWithVipListType = { + **data, + "vip_list": self.vip_list.to_dict(with_records=False), + } + return data_with_list + else: + return data + diff --git a/rcon/player_history.py b/rcon/player_history.py index 3e28d3d8e..0a9561960 100644 --- a/rcon/player_history.py +++ b/rcon/player_history.py @@ -23,6 +23,7 @@ PlayersAction, PlayerSession, SteamInfo, + VipListRecord, WatchList, enter_session, ) @@ -34,7 +35,7 @@ PlayerProfileType, ) from rcon.user_config.rcon_server_settings import RconServerSettingsUserConfig -from rcon.utils import strtobool +from rcon.utils import MISSING, MissingType, strtobool class unaccent(ReturnTypeFromArgs): @@ -130,6 +131,7 @@ def get_players_by_appearance( player_id: str | None = None, player_name: str | None = None, blacklisted: bool | None = None, + is_vip: bool | None = None, is_watched: bool | None = None, exact_name_match: bool = False, ignore_accent: bool = True, @@ -146,6 +148,7 @@ def get_players_by_appearance( last_seen_till = parser.parse(last_seen_till) blacklisted = strtobool(blacklisted) + is_vip = strtobool(is_vip) is_watched = strtobool(is_watched) exact_name_match = strtobool(exact_name_match) ignore_accent = strtobool(ignore_accent) @@ -209,6 +212,20 @@ def get_players_by_appearance( ) .exists() ) + + if is_vip is True: + query = query.filter( + sess.query(VipListRecord) + .where( + VipListRecord.player_id_id == PlayerID.id, + or_( + VipListRecord.expires_at.is_(None), + VipListRecord.expires_at > func.now(), + ), + ) + .exists() + ) + if is_watched is True: query = ( query.join(PlayerID.watchlist) @@ -280,7 +297,6 @@ def sort_name_match(v, v2): "last_seen_timestamp_ms": ( int(p[2].timestamp() * 1000) if p[2] else None ), - "vip_expiration": p[0].vip.expiration if p[0].vip else None, } for p in players ], diff --git a/rcon/rcon.py b/rcon/rcon.py index d436bc23a..98624afb0 100644 --- a/rcon/rcon.py +++ b/rcon/rcon.py @@ -3,23 +3,22 @@ import random import re import time -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import Future, ThreadPoolExecutor, as_completed from datetime import datetime, timezone from functools import cached_property from itertools import chain from typing import Any, Iterable, List, Literal, Optional, Sequence, overload -from dateutil import parser - from rcon.connection import HLLCommandError import rcon.steam_utils from rcon.cache_utils import get_redis_client, invalidates, ttl_cache -from rcon.commands import HLLCommandFailedError, ServerCtl, VipId +from rcon.commands import HLLCommandFailedError, ServerCtl from rcon.maps import UNKNOWN_MAP_NAME, Layer, is_server_loading_map, parse_layer -from rcon.models import PlayerID, PlayerVIP, enter_session, GameLayout +from rcon.models import GameLayout from rcon.perf_statistics import PerformanceStatistics -from rcon.player_history import get_profiles, safe_save_player_action, save_player, get_player_profile +from rcon.player_history import get_profiles, safe_save_player_action, get_player_profile from rcon.settings import SERVER_INFO +from rcon.steam_utils import is_steam_id_64 from rcon.types import ( AdminType, GameLayoutRandomConstraints, @@ -39,6 +38,7 @@ StructuredLogLineType, StructuredLogLineWithMetaData, VipIdType, + VipIdWithExpirationType, ) from rcon.user_config.rcon_connection_settings import RconConnectionSettingsUserConfig from rcon.user_config.rcon_server_settings import RconServerSettingsUserConfig @@ -46,12 +46,13 @@ from rcon.utils import ( ALL_ROLES, ALL_ROLES_KEY_INDEX_MAP, - INDEFINITE_VIP_DATE, + SERVER_NUMBER, MapsHistory, default_player_info_dict, - get_server_number, parse_raw_player_info, ) +from rcon.vip import get_vip_status_for_player_ids +from rcon.win_store_utils import is_windows_store_id PLAYER_ID = "player_id" NAME = "name" @@ -574,156 +575,99 @@ def get_ban(self, player_id: str) -> list[GameServerBanType]: return list(filter(lambda x: x.get(PLAYER_ID) == player_id, bans)) @ttl_cache(ttl=60 * 5) - def get_vip_ids(self) -> list[VipIdType]: - res: list[VipId] = super().get_vip_ids() - player_dicts = [] - - vip_expirations: dict[str, datetime] - with enter_session() as session: - server_number = get_server_number() + def get_vip_ids(self) -> list[VipIdWithExpirationType]: + vip_ids: list[VipIdType] = super().get_vip_ids() - players: list[PlayerVIP] = ( - session.query(PlayerVIP) - .filter(PlayerVIP.server_number == server_number) - .all() - ) - vip_expirations = { - player.player.player_id: player.expiration for player in players - } + vip_lookup = get_vip_status_for_player_ids( + player_ids=set(player["player_id"] for player in vip_ids) + ) - for item in res: - player: VipIdType = { + player_dicts = [] + for item in vip_ids: + player: VipIdWithExpirationType = { PLAYER_ID: item[PLAYER_ID], NAME: item["name"], - "vip_expiration": None, + # Players can have VIP on the game server that we're unaware of + # from a VIP list; this mimics that by giving them a `None` + # expiration; which is the same as an indefinite VIP expiration + # on a VIP list + "expires_at": vip_lookup.get(item[PLAYER_ID], {}).get("expires_at"), } - player["vip_expiration"] = vip_expirations.get(item[PLAYER_ID], None) player_dicts.append(player) return sorted(player_dicts, key=lambda d: d[NAME]) - def remove_vip(self, player_id) -> bool: - """Removes VIP status on the game server and removes their PlayerVIP record.""" - - # Remove VIP before anything else in case we have errors + def remove_vip(self, player_id: str) -> bool: + """Removes VIP status on the game server.""" with invalidates(Rcon.get_vip_ids): result = super().remove_vip(player_id) - - server_number = get_server_number() - with enter_session() as session: - player: PlayerID | None = ( - session.query(PlayerID) - .filter(PlayerID.player_id == player_id) - .one_or_none() - ) - if player and player.vip: - logger.info( - f"Removed VIP from {player_id} expired: {player.vip.expiration}" - ) - # TODO: This is an incredibly dumb fix because I can't get - # the changes to persist otherwise - vip_record: PlayerVIP | None = ( - session.query(PlayerVIP) - .filter( - PlayerVIP.player_id_id == player.id, - PlayerVIP.server_number == server_number, - ) - .one_or_none() - ) - session.delete(vip_record) - elif player and not player.vip: - logger.warning(f"{player_id} has no PlayerVIP record") - else: - # This is okay since you can give VIP to someone who has never been on a game server - # or that your instance of CRCON hasn't seen before, but you might want to prune these - logger.warning(f"{player_id} has no PlayerSteamID record") - - return result + return result def add_vip( - self, player_id: str, description: str, expiration: str | None = None + self, player_id: str, description: str, expiration: str | None = None ) -> bool: """Adds VIP status on the game server and adds or updates their PlayerVIP record.""" with invalidates(Rcon.get_vip_ids): - # Add VIP before anything else in case we have errors + # Prevent people from adding nonsense to the game server + if not is_steam_id_64(player_id) and not is_windows_store_id(player_id): + raise ValueError(f"{player_id} is not a valid player ID format") result = super().add_vip(player_id, description) + return result - expiration = expiration or "" - # postgres and Python have different max date limits - # https://docs.python.org/3.8/library/datetime.html#datetime.MAXYEAR - # https://www.postgresql.org/docs/12/datatype-datetime.html + def bulk_add_vips(self, vips: Iterable[VipIdType]): + """Use a threadpool to mass add VIPs""" + futures: dict[Future[Any], VipIdType] = {} + results_by_player: dict[str, bool] = {} - server_number = get_server_number() - # If we're unable to parse the date, treat them as indefinite VIPs - expiration_date: str | datetime - try: - expiration_date = parser.parse(expiration) - except (parser.ParserError, OverflowError): - logger.warning( - f"Unable to parse {expiration=} for {description=} {player_id=}" - ) - # For our purposes (human lifespans) we can use 200 years in the future as - # the equivalent of indefinite VIP access - expiration_date = INDEFINITE_VIP_DATE - - # Find a player and update their expiration date if it exists or create a new record if not - with enter_session() as session: - player: PlayerID | None = ( - session.query(PlayerID) - .filter(PlayerID.player_id == player_id) - .one_or_none() - ) - if player is None: - # If a player has never been on the server before and their record is - # being created from a VIP list upload, their alias will be saved with - # whatever name is in the upload file which may have metadata in it since - # people use the free form name field in VIP uploads to store stuff - save_player(player_name=description, player_id=player_id) - # Can't use a return value from save_player or it's not bound - # to the session https://docs.sqlalchemy.org/en/20/errors.html#error-bhk3 - player = ( - session.query(PlayerID) - .filter(PlayerID.player_id == player_id) - .one() - ) + for vip in vips: + pool_result = self.run_in_pool("add_vip", vip["player_id"], vip["name"]) + futures[pool_result] = vip - vip_record: PlayerVIP | None = ( - session.query(PlayerVIP) - .filter( - PlayerVIP.server_number == server_number, - PlayerVIP.player_id_id == player.id, - ) - .one_or_none() - ) + fail_count = 0 + for future in as_completed(futures): + vip_data = futures[future] + future_result = False - if vip_record is None: - vip_record = PlayerVIP( - expiration=expiration_date, - player_id_id=player.id, - server_number=server_number, - ) - logger.info( - f"Added new PlayerVIP record {player.player_id=} {expiration_date=}" - ) - session.add(vip_record) - else: - previous_expiration = vip_record.expiration.isoformat() - vip_record.expiration = expiration_date - logger.info( - f"Modified PlayerVIP record {player.player_id=} {vip_record.expiration} {previous_expiration=}" - ) + try: + future_result: bool = future.result() + except Exception: + logger.error("Failed to add VIP for %s", vip_data["player_id"]) - return result + if not future_result: + fail_count += 1 + results_by_player[vip_data["player_id"]] = future_result + + return {"results": results_by_player, "fail_count": fail_count} + + def bulk_remove_vips(self, player_ids: Iterable[str]): + futures: dict[Future[Any], str] = {} + results_by_player: dict[str, bool] = {} + + for player_id in player_ids: + pool_result = self.run_in_pool("remove_vip", player_id) + futures[pool_result] = player_id + + fail_count = 0 + for future in as_completed(futures): + player_id = futures[future] + future_result = False - def remove_all_vips(self) -> bool: - vips = self.get_vip_ids() - for vip in vips: try: - self.remove_vip(vip[PLAYER_ID]) - except (HLLCommandFailedError, ValueError): - raise + future_result: bool = future.result() + except Exception as e: + logger.error("Failed to add VIP for %s", player_id) - return True + if not future_result: + fail_count += 1 + results_by_player[player_id] = future_result + + return {"results": results_by_player, "fail_count": fail_count} + + def remove_all_vips(self): + vips = self.get_vip_ids() + return self.bulk_remove_vips( + player_ids=[player["player_id"] for player in vips] + ) def message_player( self, @@ -965,7 +909,7 @@ def get_status(self) -> StatusType: "current_players": current_players, "max_players": max_players, "short_name": config.short_name, - "server_number": int(get_server_number()), + "server_number": SERVER_NUMBER, } @ttl_cache(ttl=60 * 60 * 24) diff --git a/rcon/seed_vip/service.py b/rcon/seed_vip/service.py index e4a12f95a..734ee0db1 100644 --- a/rcon/seed_vip/service.py +++ b/rcon/seed_vip/service.py @@ -139,12 +139,8 @@ def run(): # Add or update VIP in CRCON using full VIP map reward_players( - rcon=rcon_api, config=config, to_add_vip_steam_ids=to_add_vip_steam_ids, - current_vips=all_vips, - players_lookup=player_name_lookup, - expiration_timestamps=expiration_timestamps, ) # Message those who earned VIP @@ -152,8 +148,7 @@ def run(): rcon=rcon_api, config=config, message=config.player_messages.reward_player_message, - steam_ids=to_add_vip_steam_ids, - expiration_timestamps=expiration_timestamps, + player_ids=to_add_vip_steam_ids, ) # Message those who did not earn @@ -161,8 +156,7 @@ def run(): rcon=rcon_api, config=config, message=config.player_messages.reward_player_message_no_vip, - steam_ids=no_reward_steam_ids, - expiration_timestamps=None, + player_ids=no_reward_steam_ids, ) # Post seeding complete Discord message diff --git a/rcon/seed_vip/utils.py b/rcon/seed_vip/utils.py index 0a35a69d4..cceaf05f2 100644 --- a/rcon/seed_vip/utils.py +++ b/rcon/seed_vip/utils.py @@ -1,12 +1,13 @@ -from collections import defaultdict from datetime import datetime, timedelta, timezone from logging import getLogger from typing import Iterable, Sequence from humanize import naturaldelta, naturaltime +from sqlalchemy.orm import Session import discord from rcon.api_commands import RconAPI +from rcon.models import VipList, VipListRecord, enter_session from rcon.seed_vip.models import ( BaseCondition, Player, @@ -14,9 +15,14 @@ ServerPopulation, VipPlayer, ) -from rcon.types import GameStateType, GetPlayersType, VipIdType +from rcon.types import GameStateType, GetPlayersType, VipIdWithExpirationType from rcon.user_config.seed_vip import SeedVIPUserConfig -from rcon.utils import INDEFINITE_VIP_DATE +from rcon.vip import ( + add_record_to_vip_list, + get_or_create_vip_list, + get_vip_record, + get_vip_status_for_player_ids, +) logger = getLogger(__name__) @@ -32,10 +38,7 @@ def filter_indefinite_vip_steam_ids(current_vips: dict[str, VipPlayer]) -> set[s def has_indefinite_vip(player: VipPlayer | None) -> bool: """Return true if the player has an indefinite VIP status""" - if player is None or player.expiration_date is None: - return False - expiration = player.expiration_date - return expiration >= INDEFINITE_VIP_DATE + return player is not None and player.expiration_date is None def all_met(conditions: Iterable[BaseCondition]) -> bool: @@ -71,6 +74,13 @@ def calc_vip_expiration_timestamp( if expiration is None: timestamp = from_time + config.reward.timeframe.as_timedelta return timestamp + + # If we are re-activating an old record (say someone seeded a week ago) + # we have to reset their expiration time before we add on their newly + # earned time; otherwise it is just going to add to an old date and still + # be expired + if expiration < from_time: + current_expiration = from_time if config.reward.cumulative: return expiration + config.reward.timeframe.as_timedelta @@ -103,7 +113,7 @@ def collect_steam_ids( def format_player_message( message: str, vip_reward: timedelta, - vip_expiration: datetime, + vip_expiration: datetime | None, nice_time_delta: bool = True, nice_expiration_date: bool = True, ) -> str: @@ -112,6 +122,9 @@ def format_player_message( else: delta = vip_reward + if vip_expiration is None: + return message.format(vip_reward=delta, vip_expiration="Never") + if nice_expiration_date: date = naturaltime(vip_expiration) else: @@ -170,77 +183,109 @@ def message_players( rcon: RconAPI, config: SeedVIPUserConfig, message: str, - steam_ids: Iterable[str], - expiration_timestamps: defaultdict[str, datetime] | None, + player_ids: Iterable[str], ): - player_ids = list(steam_ids) - messages = [ - format_player_message( - message=message, - vip_reward=config.reward.timeframe.as_timedelta, - vip_expiration=expiration_timestamps[player_id], - nice_time_delta=config.nice_time_delta, - nice_expiration_date=config.nice_expiration_date, - ) - if expiration_timestamps - else message - for player_id in player_ids - ] - - if config.dry_run: - for player_id, formatted_message in zip(player_ids, messages): - logger.info(f"{config.dry_run=} messaging {player_id}: {formatted_message}") - else: - rcon.bulk_message_players( - player_ids=player_ids, - messages=messages, - ) + """Message each player and include their highest VIP expiration from all their records""" + vip_records = get_vip_status_for_player_ids(player_ids=set(player_ids)) + messages = [] + for player_id in player_ids: + if vip_records.get(player_id): + formatted_message = format_player_message( + message=message, + vip_reward=config.reward.timeframe.as_timedelta, + vip_expiration=vip_records[player_id]["expires_at"], + nice_time_delta=config.nice_time_delta, + nice_expiration_date=config.nice_expiration_date, + ) + else: + formatted_message = message + messages.append(formatted_message) + + if config.dry_run: + for player_id, formatted_message in zip(player_ids, messages): + logger.info( + f"{config.dry_run=} messaging {player_id}: {formatted_message}" + ) + else: + rcon.bulk_message_players( + player_ids=list(player_ids), + messages=messages, + ) def reward_players( - rcon: RconAPI, config: SeedVIPUserConfig, to_add_vip_steam_ids: set[str], - current_vips: dict[str, VipPlayer], - players_lookup: dict[str, str], - expiration_timestamps: defaultdict[str, datetime], ): + """Create or edit VIP list records for all the players who earned VIP""" logger.info(f"Rewarding players with VIP {config.dry_run=}") logger.info(f"Total={len(to_add_vip_steam_ids)} {to_add_vip_steam_ids=}") - logger.info(f"Total={len(current_vips)=} {current_vips=}") - for player_id in to_add_vip_steam_ids: - player = current_vips.get(player_id) - expiration_date = expiration_timestamps[player_id] - if has_indefinite_vip(player): - logger.info( - f"{config.dry_run=} Skipping! pre-existing indefinite VIP for {player_id=} {player=} {expiration_date=}" - ) - continue - - vip_name = ( - player.player.name - if player - else format_vip_reward_name( - players_lookup.get(player_id, "No player name found"), - format_str=config.reward.player_name_format_not_current_vip, - ) + timestamp = datetime.now(tz=timezone.utc) + with enter_session() as sess: + # People can misconfigure their config; or delete a list, but seeding + # is pretty important; so make a new list if we don't find one + vip_list: VipList = get_or_create_vip_list( + sess=sess, vip_list_id=config.vip_list_id, name="Seed VIP" ) + record_lookup: dict[str, VipListRecord] = { + r.player.player_id: r for r in vip_list.records + } - if not config.dry_run: - logger.info( - f"{config.dry_run=} adding VIP to {player_id=} {player=} {vip_name=} {expiration_date=}", - ) - rcon.add_vip( - player_id=player_id, - description=vip_name, - expiration=expiration_date.isoformat(), - ) - - else: + # Fix their config so that we don't keep creating new lists everytime + # the service awards VIP if we made one + if config.vip_list_id != vip_list.id: logger.info( - f"{config.dry_run=} adding VIP to {player_id=} {player=} {vip_name=} {expiration_date=}", + f"Updating VIP list ID to {vip_list.id} from {config.vip_list_id}" ) + config.vip_list_id = vip_list.id + config.save_to_db(config.model_dump()) + + for player_id in to_add_vip_steam_ids: + player_record_created = False + if not config.dry_run: + player_record = record_lookup.get(player_id) + if player_record is None: + player_record = add_record_to_vip_list( + player_id=player_id, + vip_list_id=config.vip_list_id, + description=config.vip_record_description, + ) + player_record_created = True + try: + player_record = get_vip_record( + sess=sess, record_id=player_record["id"] + ) + except TypeError: + logger.error( + "Error while creating new VIP record for %s", player_id + ) + continue + + if not player_record: + logger.error( + "Error while creating new VIP record for %s", player_id + ) + continue + + # The record might be inactive; but we want to make sure they get VIP + player_record.active = True + # If the player record was created because it didn't exist; it has no expiration + player_record.expires_at = calc_vip_expiration_timestamp( + config=config, + expiration=( + timestamp if player_record_created else player_record.expires_at + ), + from_time=timestamp, + ) + + logger.info( + f"{config.dry_run=} adding VIP to {player_id=} {player_record.to_dict(with_vip_list=False)}", + ) + else: + logger.info( + f"{config.dry_run=} adding VIP to {player_id=}", + ) def get_next_player_bucket( @@ -291,7 +336,7 @@ def get_gamestate(rcon: RconAPI) -> GameStateType: def get_vips( rcon: RconAPI, ) -> dict[str, VipPlayer]: - raw_vips: list[VipIdType] = rcon.get_vip_ids() + raw_vips: list[VipIdWithExpirationType] = rcon.get_vip_ids() return { vip["player_id"]: VipPlayer( player=Player( @@ -299,7 +344,7 @@ def get_vips( name=vip["name"], current_playtime_seconds=0, ), - expiration_date=vip["vip_expiration"], + expiration_date=vip["expires_at"], ) for vip in raw_vips - } + } \ No newline at end of file diff --git a/rcon/types.py b/rcon/types.py index cb2c68fc0..ddccf13cc 100644 --- a/rcon/types.py +++ b/rcon/types.py @@ -1,7 +1,7 @@ import datetime import enum from dataclasses import dataclass -from typing import List, Literal, Optional, Sequence +from typing import List, Literal, NotRequired, Optional, Sequence # # TODO: On Python 3.11.* specifically, Pydantic requires we use typing_extensions.TypedDict # over typing.TypedDict. Once we bump our Python image we can replace this. @@ -246,12 +246,6 @@ class StatusType(TypedDict): server_number: int -class VipIdType(TypedDict): - player_id: str - name: str - vip_expiration: datetime.datetime | None - - class GameServerBanType(TypedDict): type: str name: str | None @@ -385,6 +379,71 @@ class BlacklistRecordWithPlayerType(BlacklistRecordWithBlacklistType): formatted_reason: str +class VipListSyncMethod(enum.StrEnum): + """Enumeration of available methods for handling unknown VIP players""" + + IGNORE_UNKNOWN = "ignore_unknown" + """Ignore any player with VIP on the game server that is not on a VIP list. + Players on the list will be handled (active/inactive, expired, etc.) but + people may use multiple methods such as BattleMetrics to award VIP and this + will prevent those players from losing VIP + """ + + REMOVE_UNKNOWN = "remove_unknown" + """Remove any player with VIP on the game server that is not on a VIP list. + This prevents extraneous VIP records from littering the game server, but also + prevents people from using any alternate RCON to also award VIP such as BattleMetrics + or other custom tools; because CRCON will remove any VIP players it is not tracking + """ + + +class VipListRecordTypeNoId(TypedDict): + """For creating new records which won't have a database ID yet""" + + vip_list_id: int + player_id: str + admin_name: str + is_active: bool + is_expired: bool + expires_at: datetime.datetime | None + description: str | None + notes: str | None + + +class VipListRecordType(VipListRecordTypeNoId): + """Used once a record has been persisted to the database and has an ID""" + + id: int + created_at: datetime.datetime + player: BasicPlayerProfileType + + +class VipListRecordWithVipListType(VipListRecordType): + vip_list: "VipListType" + + +class VipListRecordEditType(TypedDict): + """The editable fields of a VipListRecord""" + + id: int + vip_list_id: NotRequired[int] + is_active: NotRequired[bool] + expires_at: NotRequired[datetime.datetime | None] + description: NotRequired[str | None] + notes: NotRequired[str | None] + + +class VipListType(TypedDict): + id: int + name: str + sync: VipListSyncMethod + servers: list[int] | None + + +class VipListTypeWithRecordsType(VipListType): + records: list[VipListRecordType] + + class PlayerActionType(TypedDict): action_type: str reason: Optional[str] @@ -591,11 +650,6 @@ class UserConfigType(TypedDict): value: str -class PlayerVIPType(TypedDict): - server_number: int - expiration: datetime.datetime - - class PlayerSoldierType(TypedDict): eos_id: Optional[str] name: Optional[str] @@ -623,11 +677,11 @@ class PlayerProfileType(BasicPlayerProfileType): penalty_count: PenaltyCountType blacklists: list[BlacklistRecordWithBlacklistType] is_blacklisted: bool + vip_lists: list[VipListRecordWithVipListType] + is_vip: bool flags: list[PlayerFlagType] watchlist: Optional[WatchListType] is_watched: bool - vips: Optional[list[PlayerVIPType]] - is_vip: bool soldier: PlayerSoldierType account: PlayerAccountType @@ -742,11 +796,15 @@ class VACGameBansConfigType(TypedDict): whitelist_flags: list[str] -class VipId(TypedDict): +class VipIdType(TypedDict): player_id: str name: str +class VipIdWithExpirationType(VipIdType): + expires_at: datetime.datetime | None + + class VoteMapPlayerVoteType(TypedDict): player_name: str map_name: str diff --git a/rcon/user_config/__init__.py b/rcon/user_config/__init__.py index 561f0fc0f..2e0e08d03 100644 --- a/rcon/user_config/__init__.py +++ b/rcon/user_config/__init__.py @@ -18,7 +18,6 @@ ban_tk_on_connect, camera_notification, chat_commands, - expired_vips, gtx_server_name, log_line_webhooks, log_stream, diff --git a/rcon/user_config/expired_vips.py b/rcon/user_config/expired_vips.py deleted file mode 100644 index 52672885f..000000000 --- a/rcon/user_config/expired_vips.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Optional, TypedDict - -from pydantic import Field, HttpUrl, field_serializer - -from rcon.user_config.utils import BaseUserConfig, key_check, set_user_config - - -class ExpiredVipsType(TypedDict): - enabled: bool - interval_minutes: int - discord_webhook_url: Optional[HttpUrl] - - -class ExpiredVipsUserConfig(BaseUserConfig): - enabled: bool = Field(default=True) - interval_minutes: int = Field(ge=1, default=60) - discord_webhook_url: Optional[HttpUrl] = Field(default=None) - - @field_serializer("discord_webhook_url") - def serialize_server_url(self, discord_webhook_url: HttpUrl, _info): - if discord_webhook_url is not None: - return str(discord_webhook_url) - else: - return None - - @staticmethod - def save_to_db(values: ExpiredVipsType, dry_run=False): - key_check( - ExpiredVipsType.__required_keys__, - ExpiredVipsType.__optional_keys__, - values.keys(), - ) - - validated_conf = ExpiredVipsUserConfig( - enabled=values.get("enabled"), - interval_minutes=values.get("interval_minutes"), - discord_webhook_url=values.get("discord_webhook_url"), - ) - - if not dry_run: - set_user_config(ExpiredVipsUserConfig.KEY(), validated_conf) diff --git a/rcon/user_config/seed_db.py b/rcon/user_config/seed_db.py index 0fff58913..5eda1c722 100644 --- a/rcon/user_config/seed_db.py +++ b/rcon/user_config/seed_db.py @@ -11,7 +11,6 @@ from rcon.user_config.ban_tk_on_connect import BanTeamKillOnConnectUserConfig from rcon.user_config.camera_notification import CameraNotificationUserConfig from rcon.user_config.chat_commands import ChatCommandsUserConfig -from rcon.user_config.expired_vips import ExpiredVipsUserConfig from rcon.user_config.gtx_server_name import GtxServerNameChangeUserConfig from rcon.user_config.log_line_webhooks import LogLineWebhookUserConfig from rcon.user_config.log_stream import LogStreamUserConfig @@ -62,7 +61,6 @@ def seed_default_config(): ChatCommandsUserConfig.seed_db(sess) RConChatCommandsUserConfig.seed_db(sess) ChatWebhooksUserConfig.seed_db(sess) - ExpiredVipsUserConfig.seed_db(sess) GtxServerNameChangeUserConfig.seed_db(sess) KillsWebhooksUserConfig.seed_db(sess) LogLineWebhookUserConfig.seed_db(sess) diff --git a/rcon/user_config/seed_vip.py b/rcon/user_config/seed_vip.py index d2a4aadaa..d31e7182d 100644 --- a/rcon/user_config/seed_vip.py +++ b/rcon/user_config/seed_vip.py @@ -18,8 +18,6 @@ REWARD_PLAYER_MESSAGE = "Thank you for helping us seed.\n\nYou've been granted {vip_reward} of VIP\n\nYour VIP currently expires: {vip_expiration}" REWARD_PLAYER_MESSAGE_NO_VIP = "Thank you for helping us seed.\n\nThe server is now live and the regular rules apply." -PLAYER_NAME_FORMAT_NOT_CURRENT_VIP = "{player_name} - CRCON Seed VIP" - class RawBufferType(TypedDict): seconds: int @@ -51,8 +49,6 @@ class RawRewardTimeFrameType(TypedDict): class RawRewardType(TypedDict): - forward: bool - player_name_format_not_current_vip: str cumulative: bool timeframe: RawRewardTimeFrameType @@ -68,6 +64,8 @@ class RawPlayerMessagesType(TypedDict): class SeedVIPType(TypedDict): enabled: bool dry_run: bool + vip_list_id: int + vip_record_description: str | None language: str hooks: list[WebhookType] player_announce_thresholds: list[int] @@ -144,16 +142,16 @@ def total_seconds(self): class Reward(pydantic.BaseModel): - forward: bool = Field(default=False) - player_name_format_not_current_vip: str = Field( - default=PLAYER_NAME_FORMAT_NOT_CURRENT_VIP - ) cumulative: bool = Field(default=True) timeframe: RewardTimeFrame = Field(default_factory=RewardTimeFrame) class SeedVIPUserConfig(BaseUserConfig): enabled: bool = Field(default=False) + # ID 0 is the default list; this is common enough we'll use list 1 + # by default which is created during the DB migration + vip_list_id: int = Field(default=1) + vip_record_description: str | None = Field(default="Seed VIP") dry_run: bool = Field(default=True) language: str | None = Field(default="en_US") hooks: list[DiscordWebhook] = Field(default_factory=list) @@ -228,10 +226,6 @@ def save_to_db(values: SeedVIPType, dry_run=False): ) validated_reward = Reward( - forward=raw_reward.get("forward"), - player_name_format_not_current_vip=raw_reward.get( - "player_name_format_not_current_vip" - ), cumulative=raw_reward.get("cumulative"), timeframe=validated_reward_time_frame, ) @@ -239,6 +233,8 @@ def save_to_db(values: SeedVIPType, dry_run=False): validated_conf = SeedVIPUserConfig( enabled=values.get("enabled"), dry_run=values.get("dry_run"), + vip_list_id=values.get("vip_list_id"), + vip_record_description=values.get("vip_record_description"), language=values.get("language"), hooks=validated_hooks, player_announce_thresholds=values.get("player_announce_thresholds"), @@ -253,4 +249,4 @@ def save_to_db(values: SeedVIPType, dry_run=False): if not dry_run: logger.info(f"setting {validated_conf=}") - set_user_config(SeedVIPUserConfig.KEY(), validated_conf) + set_user_config(SeedVIPUserConfig.KEY(), validated_conf) \ No newline at end of file diff --git a/rcon/utils.py b/rcon/utils.py index 12a16550c..5d758b0cd 100644 --- a/rcon/utils.py +++ b/rcon/utils.py @@ -30,14 +30,6 @@ def __missing__(self, key): return key -INDEFINITE_VIP_DATE = datetime( - year=3000, - month=1, - day=1, - tzinfo=timezone.utc, -) - - ALL_ROLES = ( "armycommander", "officer", @@ -362,6 +354,13 @@ def get_server_number() -> str: return server_number +# Add a quick shorthand for the SERVER NUMBER but don't fail in the maintenance container +if os.getenv("SERVER_NUMBER"): + SERVER_NUMBER = int(get_server_number()) +else: + SERVER_NUMBER = 0 + + def exception_in_chain(e: BaseException, c) -> bool: if isinstance(e, c): return True @@ -614,7 +613,7 @@ def strtobool(val) -> bool: if val is None: return False - if isinstance(val, bool): + if isinstance(val, bool) or val == MISSING: return val # sourced from https://stackoverflow.com/a/18472142 with modification @@ -625,3 +624,9 @@ def strtobool(val) -> bool: return False else: raise ValueError("invalid truth value %r" % (val,)) + + +def get_server_number_mask(): + """Calculate server masks for Blacklist/VIP lists""" + server_number = SERVER_NUMBER + return 1 << (server_number - 1) \ No newline at end of file diff --git a/rcon/vip.py b/rcon/vip.py new file mode 100644 index 000000000..971b9d973 --- /dev/null +++ b/rcon/vip.py @@ -0,0 +1,1486 @@ +"""For managing VIP lists""" + +# Unlike a blacklist that can be actioned with a KICK when a player joins; +# VIP has to be applied to the player before they join the server or it's pointless +# For this reason; VIP lists when they sync to the game server, need to ADD any new +# entries and REMOVE any expired/inactive entries and UNKNOWN entries (depending on sync method) + +# They also need to periodically (on a timer) remove anyones VIP that has expired, these +# entries can remain on the list but need to be removed from the game server +# A sync needs to handle/reattempt errors since it must fetch a current list, and vipadd/vipdel +# potentially many entries + +# Players can be on multiple VIP lists; but they will always get the highest expiration date +# amongst the list, and the entries applied to the server will be a UNION of all of the lists +# that are applied to the server; if you toggle a list, removal may or may not actually occur +# on the game server for any specific player; it depends if they still have an active/applicable +# record on a different list + +# A VIP list can be configured to ignore extra VIP on the server (added from other sources like BM) +# or to remove people not on the list +# IF someone is ON the list regardless of this setting; and their VIP has expired, they'll be removed +# when the list is synced +# If the sync method of a list is REMOVE_UNKNOWN and a player ID has VIP on the game server but is not +# on ANY list that applies to the game server, their VIP is removed; otherwise they are ignored when +# the list is synced + +# Multiple lists can apply to the server and they can have different sync methods; but users just need +# to pay attention and understand the system when managing lists; the default will be IGNORE_UNKNOWN +# so they will have to explicitly configure this, and most people will likely use a single list +# At not point will we automatically create records for players based on their existing VIP status +# on the game server because that would pollute our records + + +# Because command forwarding is weird and can fail, and you can only forward commands and not get info +# back, and one game server only knows about itself, and internal CRCON details +# when a list is edited, applied or removed from a server each CRCON instance is responsible for syncing +# the list to the game server, this is done via redis to notify the other game servers when list changes +# are made + +# In the interest of making it easier for people to track info, VIP records can exist but not be active +# for a player to get VIP, their record must be active AND have an indefinite expiration date OR the +# expiration date needs to be in the future + +# inactive OR expired VIP is removed when a list is synced + +# Each list is limited to one single VIP record per player ID to simplify things + +import os +import struct +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from enum import IntEnum, auto +from logging import getLogger +from typing import Iterable, Self, Sequence + +import orjson +import redis +from dateutil import parser +from pydantic import BaseModel, field_validator +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from rcon.cache_utils import get_redis_client +from rcon.commands import HLLCommandFailedError +from rcon.models import PlayerID, PlayerName, VipList, VipListRecord, enter_session +from rcon.player_history import _get_set_player, remove_accent, unaccent +from rcon.steam_utils import is_steam_id_64 +from rcon.types import ( + VipListRecordEditType, + VipListRecordType, + VipListRecordTypeNoId, + VipListSyncMethod, + VipListType, +) +from rcon.utils import ( + MISSING, + SERVER_NUMBER, + MissingType, + get_server_number_mask, + strtobool, +) +from rcon.win_store_utils import is_windows_store_id + +logger = getLogger(__name__) +red = get_redis_client() + + +DEFAULT_LIST_ID = 0 +ALL_SERVERS_MASK = 2**32 - 1 + + +def convert_old_style_vip_records( + records: Iterable[str], vip_list_id: int = DEFAULT_LIST_ID +): + errors = [] + + timestamp = datetime.now(tz=timezone.utc) + vips: list[VipListRecordTypeNoId] = [] + for idx, line in enumerate(records): + idx += 1 + expiration_timestamp = None + try: + player_id, *name_chunks, possible_timestamp = line.strip().split() + # No possible time stamp if name_chunks is empty (only a 2 element list) + if not name_chunks: + description = possible_timestamp + possible_timestamp = None + else: + # This will collapse whitespace that was originally in a player's name + description = " ".join(name_chunks) + try: + expiration_timestamp = parser.parse(possible_timestamp) + except: + logger.warning( + f"#{idx} Unable to parse {possible_timestamp=} for {description=} {player_id=}" + ) + # The last chunk should be treated as part of the players name if it's not a valid date + description += possible_timestamp + + if not is_steam_id_64(player_id) and not is_windows_store_id(player_id): + errors.append( + f"#{idx} {line} has an invalid player ID: `{player_id}`, expected a 17 digit steam id or a windows store id. {is_steam_id_64(player_id)=} {is_windows_store_id(player_id)=}" + ) + continue + if not description: + errors.append( + f"#{idx} {line} doesn't have a name attached to the player ID" + ) + continue + + if expiration_timestamp and expiration_timestamp >= datetime( + year=2100, month=1, day=1, tzinfo=timezone.utc + ): + expiration_timestamp = None + + vips.append( + { + "vip_list_id": vip_list_id, + "player_id": player_id, + "admin_name": "CRCON", + "is_active": True, + "is_expired": ( + expiration_timestamp <= timestamp + if expiration_timestamp + else True + ), + "expires_at": expiration_timestamp, + "description": description, + "notes": None, + } + ) + except Exception as e: + errors.append(f"Error on line #{idx} {line}: {e}") + + if vips: + bulk_add_vip_records(records=vips) + + return { + "vips": vips, + "errors": errors, + } + + +def get_vip_lists(sess: Session) -> Sequence[VipList]: + """Return all VIP lists ordered by ID""" + return sess.scalars(select(VipList).order_by(VipList.id)).all() + + +def get_vip_lists_for_server(sess: Session, server_number: int) -> list[VipList]: + """Return all VIP lists that apply to server_number""" + applicable_lists: list[VipList] = [] + vip_lists = get_vip_lists(sess=sess) + for lst in vip_lists: + server_numbers = lst.get_server_numbers() or set([server_number]) + if server_number in server_numbers: + applicable_lists.append(lst) + + return applicable_lists + + +def get_vip_list( + sess: Session, vip_list_id: int, strict: bool = False +) -> VipList | None: + """Return a specific VIP list if it exists""" + vip_list = sess.get(VipList, vip_list_id) + if not vip_list and strict: + raise HLLCommandFailedError(f"No vip list found with ID {vip_list_id}") + return vip_list + + +def get_or_create_vip_list( + sess: Session, + vip_list_id: int, + name: str, + sync: VipListSyncMethod = VipListSyncMethod.IGNORE_UNKNOWN, + servers: Sequence[int] | None = None, +) -> VipList: + """Return a VIP list if it exists or create it with the parameters""" + logger.info(f"{vip_list_id=}") + vip_list = get_vip_list(sess=sess, vip_list_id=vip_list_id, strict=False) + + logger.info(f"{vip_list.to_dict() if vip_list else None}") + if vip_list is None: + new_list = create_vip_list(name=name, sync=sync, servers=servers) + logger.info(f"{new_list=}") + vip_list = get_vip_list(sess=sess, vip_list_id=new_list["id"], strict=False) + logger.info(f"{vip_list.to_dict() if vip_list else None}") + if vip_list is None: + raise RuntimeError( + "No VIP list found and an error occurred while creating a new one" + ) + + return vip_list + + +def create_vip_list( + name: str, sync: VipListSyncMethod, servers: Sequence[int] | None +) -> VipListType: + """Create a new VIP list""" + with enter_session() as sess: + vip_list = VipList(name=name, sync=sync) + vip_list.set_server_numbers(servers) + sess.add(vip_list) + sess.commit() + + # VipLists are created empty; so no commands need to be sent to the handler + return vip_list.to_dict() + + +def edit_vip_list( + vip_list_id: int, + name: str | MissingType = MISSING, + sync: VipListSyncMethod | MissingType = MISSING, + servers: Sequence[int] | MissingType = MISSING, +) -> VipListType | None: + """Edit an existing VIP list if it exists""" + with enter_session() as sess: + # Let the exception bubble up to the API or wherever this was called + # if we try to edit a list that doesn't exist + vip_list = get_vip_list(sess=sess, vip_list_id=vip_list_id, strict=True) + + # Make type checking happy even though it's impossible to get here + # if the list doesn't exist + if not vip_list: + return + + old_server_mask = vip_list.servers + + if name != MISSING: + vip_list.name = name # type: ignore + if sync != MISSING: + vip_list.sync = sync # type: ignore + if servers != MISSING: + vip_list.set_server_numbers(servers) # type: ignore + + if not sess.is_modified(vip_list): + return vip_list.to_dict() + + # If either the new or old mask applied to all servers; + # we have to send the command to every server + if old_server_mask is None or vip_list.servers is None: + server_mask = ALL_SERVERS_MASK + # Otherwise OR the two masks together to find which servers + # need to be notified + else: + server_mask = old_server_mask | vip_list.servers + + # Save our changes + sess.commit() + new_vip_list = vip_list.to_dict() + + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.EDIT_LIST, + server_mask=server_mask, + payload=VipListEditListCommand(vip_list_id=vip_list.id).model_dump(), + ) + ) + + return new_vip_list + + +def delete_vip_list(vip_list_id: int) -> bool: + """Delete an existing VipList, preventing the default list ID of 0 from being deleted + + When the list is deleted; all records on it are also deleted + """ + + if vip_list_id == DEFAULT_LIST_ID: + raise ValueError("The default VIP list cannot be deleted") + + with enter_session() as sess: + vip_list = get_vip_list(sess, vip_list_id=vip_list_id) + if not vip_list: + return False + + player_ids: set[str] = set() + for record in vip_list.records: + # Record the affected player IDs to send to the handler + player_ids.add(record.player.player_id) + sess.delete(record) + + sess.delete(vip_list) + sess.commit() + + # This is handled on model validation; but it makes type checking unhappy + server_mask = vip_list.servers or ALL_SERVERS_MASK + + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.DELETE_LIST, + server_mask=server_mask, + payload=VipListDeleteListCommand(player_ids=player_ids).model_dump(), + ) + ) + return True + + +def get_vip_record( + sess: Session, record_id: int, strict: bool = False +) -> VipListRecord | None: + """Return a specific VIP list record by ID if it exists""" + record = sess.get(VipListRecord, record_id) + if not record and strict: + raise HLLCommandFailedError(f"No vip list record found with ID {record_id}") + return record + + +def bulk_add_vip_records(records: Iterable[VipListRecordTypeNoId]): + """Accept multiple records for addition + + In the event the unique player ID per VIP list constraint is violated + only the first record will be added + + Returns the player IDs and VIP list IDs that were skipped if any + """ + + player_ids_per_list: defaultdict[int, set[str]] = defaultdict(set) + player_id_vip_list_ids_skipped: defaultdict[str, set[int]] = defaultdict(set) + with enter_session() as sess: + for record in records: + player = _get_set_player(sess, record["player_id"]) + if not player: + raise RuntimeError( + "Unable to create PlayerID record, check the DB connection" + ) + + vip_list = get_vip_list( + sess=sess, vip_list_id=record["vip_list_id"], strict=True + ) + # Make type checking happy even though it's impossible to get here + # if the list doesn't exist + if not vip_list: + return + + if player.player_id in player_ids_per_list[vip_list.id]: + player_id_vip_list_ids_skipped[player.player_id].add( + record["vip_list_id"] + ) + logger.warning( + "Skipping already seen player ID %s for VIP list ID %s", + player.player_id, + vip_list.id, + ) + continue + + player_ids_per_list[vip_list.id].add(player.player_id) + + create_vip_record( + sess=sess, + player=player, + vip_list=vip_list, + description=record["description"], + active=record["is_active"], + expires_at=record["expires_at"], + notes=record["notes"], + ) + + # Synchronize now since we deferred it earlier while editing + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.SYNCH_GAME_SERVER, + server_mask=ALL_SERVERS_MASK, + payload=VipListSynchCommand().model_dump(), + ) + ) + + return player_id_vip_list_ids_skipped + + +def bulk_delete_vip_records(record_ids: Iterable[int]): + with enter_session() as sess: + for record_id in record_ids: + delete_vip_list_record(record_id=record_id, synchronize=False) + + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.SYNCH_GAME_SERVER, + server_mask=ALL_SERVERS_MASK, + payload=VipListSynchCommand().model_dump(), + ) + ) + + +def bulk_edit_vip_records(records: Iterable[VipListRecordEditType]) -> None: + """Update all the provided records, deferring gameserver updates until the end""" + with enter_session() as sess: + for record in records: + edit_vip_list_record( + sess=sess, + record_id=record["id"], + vip_list_id=record.get("vip_list_id", MISSING), + description=record.get("description", MISSING), + active=record.get("is_active", MISSING), + expires_at=record.get("expires_at", MISSING), + notes=record.get("notes", MISSING), + synchronize=False, + ) + + # Synchronize now since we deferred it earlier while editing + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.SYNCH_GAME_SERVER, + server_mask=ALL_SERVERS_MASK, + payload=VipListSynchCommand().model_dump(), + ) + ) + + +def create_vip_record( + sess: Session, + player: PlayerID, + vip_list: VipList, + description: str | None = None, + active: bool = True, + expires_at: datetime | None = None, + notes: str | None = None, + admin_name: str = "CRCON", +) -> VipListRecord: + record = VipListRecord( + admin_name=admin_name, + active=active, + description=description, + notes=notes, + expires_at=expires_at, + player=player, + vip_list=vip_list, + ) + sess.add(record) + logger.info( + "Creating VIP list record for %s on list: name: %s ID: %s", + player.player_id, + vip_list.name, + vip_list.id, + ) + sess.commit() + return record + + +def add_record_to_vip_list( + player_id: str, + vip_list_id: int, + description: str | None = None, + active: bool = True, + expires_at: datetime | None = None, + notes: str | None = None, + admin_name: str = "CRCON", +) -> VipListRecordType | None: + """Create and add a VipListRecord to the specified list""" + with enter_session() as sess: + player = _get_set_player(sess, player_id) + if not player: + raise RuntimeError( + "Unable to create PlayerID record, check the DB connection" + ) + + vip_list = get_vip_list(sess=sess, vip_list_id=vip_list_id, strict=True) + # Make type checking happy even though it's impossible to get here + # if the list doesn't exist + if not vip_list: + return + + # The schema allows people to make as many records as they want for a + # specific player, tracked by unique IDs; differentiated by expiration + # date and then by creation date to sort priority + record = create_vip_record( + sess=sess, + player=player, + vip_list=vip_list, + description=description, + active=active, + expires_at=expires_at, + notes=notes, + admin_name=admin_name, + ) + + res = record.to_dict() + + # This is handled on model validation; but it makes type checking unhappy + server_mask = vip_list.servers or ALL_SERVERS_MASK + + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.CREATE_RECORD, + server_mask=server_mask, + # No actual payload is needed; this command causes a resynch + # with the game server + payload=VipListCreateRecordCommand().model_dump(), + ) + ) + + return res + + +def edit_vip_list_record( + record_id: int, + vip_list_id: int | MissingType = MISSING, + description: str | MissingType = MISSING, + active: bool | MissingType = MISSING, + expires_at: datetime | MissingType = MISSING, + notes: str | MissingType = MISSING, + admin_name: str = "CRCON", + synchronize: bool = True, + sess: Session | None = None, +) -> VipListRecordType | None: + def _edit_vip_list_record( + sess: Session, + record_id: int, + vip_list_id: int | MissingType = MISSING, + description: str | MissingType = MISSING, + active: bool | MissingType = MISSING, + expires_at: datetime | MissingType = MISSING, + notes: str | MissingType = MISSING, + admin_name: str = "CRCON", + synchronize: bool = True, + ): + active = strtobool(active) + record = get_vip_record(sess=sess, record_id=record_id, strict=True) + + # Make type checking happy even though it's impossible to get here + # if the list doesn't exist + if not record: + return + + old_record = record.to_dict() + old_server_mask = record.vip_list.servers + + # Update any included attributes + if vip_list_id != MISSING: + record.vip_list_id = vip_list_id # type: ignore + if description != MISSING: + record.description = description # type: ignore + if active != MISSING: + record.active = active # type: ignore + if expires_at != MISSING: + record.expires_at = expires_at # type: ignore + if notes != MISSING: + record.notes = notes # type: ignore + + # Update to the latest person who touched the record + # or the default 'CRCON' if used internally + record.admin_name = admin_name + + # Return if nothing was modified + if not sess.is_modified(record): + return old_record + + new_record = record.to_dict() + + # If either the new or old mask applied to all servers; + # we have to send the command to every server + if old_server_mask is None or record.vip_list.servers is None: + server_mask = ALL_SERVERS_MASK + # Otherwise OR the two masks together to find which servers + # need to be notified + else: + server_mask = old_server_mask | record.vip_list.servers + + # Allow skipping synchronization; this is done on a 5 minute timer + # anyway; and if we're doing bulk edits we can do all of the edits + # and then manually trigger a synchronization + if synchronize: + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.EDIT_RECORD, + server_mask=server_mask, + # No actual payload is needed; this command causes a resynch + # with the game server + payload=VipListEditRecordCommand().model_dump(), + ) + ) + + return new_record + + if sess is None: + with enter_session() as sess: + return _edit_vip_list_record( + sess=sess, + record_id=record_id, + vip_list_id=vip_list_id, + description=description, + active=active, + expires_at=expires_at, + notes=notes, + admin_name=admin_name, + synchronize=synchronize, + ) + else: + return _edit_vip_list_record( + sess=sess, + record_id=record_id, + vip_list_id=vip_list_id, + description=description, + active=active, + expires_at=expires_at, + notes=notes, + admin_name=admin_name, + synchronize=synchronize, + ) + + +def add_or_edit_vip_list_record( + player_id: str, + vip_list_id: int, + description: str | MissingType = MISSING, + active: bool | MissingType = MISSING, + expires_at: datetime | MissingType = MISSING, + notes: str | MissingType = MISSING, + admin_name: str = "CRCON", + sess: Session | None = None, +) -> VipListRecordType | None: + """Alllow editing a VIP list record by player ID creating the record if it doesn't exist""" + + def _add_or_edit( + sess: Session, + player_id: str, + vip_list_id: int, + description: str | MissingType = MISSING, + active: bool | MissingType = MISSING, + expires_at: datetime | MissingType = MISSING, + notes: str | MissingType = MISSING, + admin_name: str = "CRCON", + ) -> VipListRecordType | None: + record = get_player_vip_list_record( + sess=sess, player_id=player_id, vip_list_id=vip_list_id + ) + + if record: + return edit_vip_list_record( + record_id=record.id, + vip_list_id=vip_list_id, + description=description, + active=active, + expires_at=expires_at, + notes=notes, + admin_name=admin_name, + ) + else: + _description = None if isinstance(description, MissingType) else description + _active = True if isinstance(active, MissingType) else active == True + _expires_at = None if isinstance(expires_at, MissingType) else expires_at + _notes = None if isinstance(notes, MissingType) else notes + + return add_record_to_vip_list( + player_id=player_id, + vip_list_id=vip_list_id, + description=_description, + active=_active, + expires_at=_expires_at, + notes=_notes, + admin_name=admin_name, + ) + + if sess is None: + with enter_session() as sess: + return _add_or_edit( + sess=sess, + player_id=player_id, + vip_list_id=vip_list_id, + description=description, + active=active, + expires_at=expires_at, + notes=notes, + admin_name=admin_name, + ) + else: + return _add_or_edit( + sess=sess, + player_id=player_id, + vip_list_id=vip_list_id, + description=description, + active=active, + expires_at=expires_at, + notes=notes, + admin_name=admin_name, + ) + + +def delete_vip_list_record(record_id: int, synchronize: bool = True) -> bool: + """Delete the specified VIP list record if it exists""" + with enter_session() as sess: + record = get_vip_record(sess=sess, record_id=record_id) + + if not record: + return False + + player_id = record.player.player_id + # This is handled on model validation; but it makes type checking unhappy + server_mask = record.vip_list.servers or ALL_SERVERS_MASK + sess.delete(record) + sess.commit() + + if synchronize: + # If we remove a record; that player may or may not still have VIP + # from other lists, the handler will resynch with the game server + # and handle it appropriately + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.DELETE_RECORD, + server_mask=server_mask, + # No actual payload is needed; this command causes a resynch + # with the game server + payload=VipListDeleteRecordCommand( + player_id=player_id + ).model_dump(), + ) + ) + + return True + + +def get_vip_status_for_player_ids(player_ids: set[str]) -> dict[str, VipListRecordType]: + records_by_player: defaultdict[str, list[VipListRecord]] = defaultdict(list) + top_record_by_player: dict[str, VipListRecordType] = {} + with enter_session() as sess: + stmt = ( + select(VipListRecord) + .join(VipListRecord.player) + .filter(PlayerID.player_id.in_(player_ids)) + ) + server_records = sess.execute(stmt).scalars().all() + # A player can have multiple records; group by player + # # TODO: There's probably some SQL we can write to offload this to postgres + # but for now do it in Python + for record in server_records: + records_by_player[record.player.player_id].append(record) + + for player_id, records in records_by_player.items(): + top_record = get_highest_priority_record(records=records) + if top_record: + top_record_by_player[player_id] = top_record.to_dict() + + return top_record_by_player + + +def get_active_vip_records(sess: Session, vip_list_id: int) -> Sequence[VipListRecord]: + """Return all VipListRecords on a list that are flagged as active and haven't expired""" + stmt = ( + select(VipListRecord) + .filter(VipListRecord.vip_list_id == vip_list_id) + # Record must not have expired yet and be marked as active + .filter(VipListRecord.active == True) + .filter( + or_( + VipListRecord.expires_at.is_(None), + VipListRecord.expires_at > func.now(), + ) + ) + ) + + return sess.scalars(stmt).all() + + +def get_inactive_vip_records( + sess: Session, vip_list_id: int +) -> Sequence[VipListRecord]: + """Return all VipListRecords on a list that are either inactive or expired + + Expired records aren't immediately marked as inactive; the next time the list + is reviewed (on a timer) it will be marked as inactive if it's expired + """ + stmt = select(VipListRecord).filter( + # Record must not have expired yet and be marked as active + VipListRecord.vip_list_id == vip_list_id, + or_( + VipListRecord.active == False, + VipListRecord.expires_at <= func.now(), + ), + ) + return sess.scalars(stmt).all() + + +def get_player_vip_list_record( + player_id: str, vip_list_id: int, sess: Session | None = None +) -> VipListRecord | None: + """Return the VIP list record for a player for the indicated list if it exists""" + + def _get_player_vip_list_record(player_id: str, vip_list_id: int, sess: Session): + stmt = ( + select(VipListRecord) + .join(VipListRecord.player) + .join(VipListRecord.vip_list) + .where(PlayerID.player_id == player_id) + .where(VipList.id == vip_list_id) + ) + res: VipListRecord | None = sess.execute(stmt).scalar_one_or_none() + return res + + if sess is None: + with enter_session() as sess: + return _get_player_vip_list_record( + player_id=player_id, vip_list_id=vip_list_id, sess=sess + ) + else: + return _get_player_vip_list_record( + player_id=player_id, vip_list_id=vip_list_id, sess=sess + ) + + +def get_player_vip_list_records( + sess: Session, + player_id: str, + include_expired: bool = True, + include_inactive: bool = True, + include_other_servers=True, + exclude: set[int] | None = None, +) -> Sequence[VipListRecord]: + """Return all VipListRecords associated with player_id based on the criteria""" + stmt = ( + select(VipListRecord) + .join(VipListRecord.player) + .join(VipListRecord.vip_list) + .filter(PlayerID.player_id == player_id) + ) + + if not include_inactive: + stmt = stmt.filter(VipListRecord.active == True) + + if not include_expired: + stmt = stmt.filter( + or_( + VipListRecord.expires_at.is_(None), + VipListRecord.expires_at > func.now(), + ) + ) + + if not include_other_servers: + stmt = stmt.filter( + or_( + # If there is no mask; the list applies to every server + VipList.servers.is_(None), + # Make sure the list applies to this specific server, i.e. there is a 1 + # in the mask position for this server number + VipList.servers.bitwise_and(get_server_number_mask()) != 0, + ) + ) + + if exclude: + stmt = stmt.filter(VipListRecord.id.not_in(exclude)) + + return sess.scalars(stmt).all() + + +def search_vip_list_records( + sess: Session, + player_id: str | None = None, + admin_name: str | None = None, + active: bool | None = None, + description_or_player_name: str | None = None, + notes: str | None = None, + vip_list_id: int | None = None, + exclude_expired: bool = False, + page_size: int = 50, + page: int = 1, +) -> tuple[Sequence[VipListRecord], int]: + """Filter VIP list records by the criteria/page; returning the page records and total number of records""" + page_size = int(page_size) + page = int(page) + + if page <= 0: + raise ValueError("page needs to be >= 1") + if page_size <= 0: + raise ValueError("page_size needs to be >= 1") + + filters = [] + + if player_id: + filters.append(PlayerID.player_id == player_id) + if admin_name: + clean_admin_name = remove_accent(admin_name) + filters.append( + unaccent(VipListRecord.admin_name).ilike(f"%{clean_admin_name}%") + ) + logger.info(f"{admin_name=}") + if active: + filters.append(VipListRecord.active == True) + if description_or_player_name: + clean_description = remove_accent(description_or_player_name) + filters.append( + or_( + unaccent(VipListRecord.description).ilike(f"%{clean_description}%"), + PlayerID.names.any( + unaccent(PlayerName.name).ilike(f"%{clean_description}%") + ), + ) + ) + if notes: + clean_notes = remove_accent(notes) + filters.append(unaccent(VipListRecord.notes).ilike(f"%{clean_notes}%")) + if vip_list_id is not None: + filters.append(VipListRecord.vip_list_id == vip_list_id) + if exclude_expired: + filters.append( + or_( + VipListRecord.expires_at.is_(None), + VipListRecord.expires_at > func.now(), + ) + ) + + # Calculate the total records to return so API consumers can paginate properly + total = sess.execute( + select(func.count(VipListRecord.id)).join(VipListRecord.player).filter(*filters) + ).scalar_one() + + stmt = ( + select(VipListRecord) + .join(VipListRecord.player) + .filter(*filters) + .order_by(VipListRecord.created_at.desc()) + .limit(page_size) + .offset((page - 1) * page_size) + .options(selectinload(VipListRecord.vip_list)) + .options(selectinload(VipListRecord.player).selectinload(PlayerID.names)) + .options(selectinload(VipListRecord.player).selectinload(PlayerID.steaminfo)) + ) + return sess.scalars(stmt).all(), total + + +def get_all_records_for_server( + sess: Session, server_number: int +) -> Sequence[VipListRecord]: + """Return all VipListRecords for the indicated server""" + vip_list_ids = [ + v.id for v in get_vip_lists_for_server(sess=sess, server_number=server_number) + ] + stmt = select(VipListRecord).where(VipListRecord.vip_list_id.in_(vip_list_ids)) + return sess.execute(stmt).scalars().all() + + +def is_player_vip_by_records( + records: Iterable[VipListRecord], timestamp: datetime +) -> bool: + """Return if the player should have VIP based on the provided records""" + if any( + record.active and (not record.expires_at or record.expires_at > timestamp) + for record in records + ): + return True + return False + + +def is_player_vip_by_query( + sess: Session, player_id: str, exclude: set[int] | None = None +) -> VipListRecord | None: + """Check if a player has current VIP from any list; not the game server""" + + # The player may or may not currently have VIP on the server; this just + # determines if they *should* have VIP based on our list + # Updating the game server will be handled when the list is synchronized + records = get_player_vip_list_records( + sess=sess, + player_id=player_id, + include_expired=False, + include_inactive=False, + # Need to exclude other game servers; doesn't matter if they have VIP there or not + include_other_servers=False, + exclude=exclude, + ) + + if not records: + return + + # A player could have entries on multiple lists; get the most relevant one + return get_highest_priority_record(records) + + +def is_higher_priority_record(record: VipListRecord, other: VipListRecord) -> bool: + """Return True if the first record is higher priority or False if the other is""" + + def _is_higher_priority_record( + record_created_at: datetime, + record_expires_at: datetime | None, + other_created_at: datetime, + other_expires_at: datetime | None, + ) -> bool: + """Return True if the first record is higher priority or False if the other is""" + # If both records expire at the same time, select the record + # that was created most recently + if record_expires_at == other_expires_at: + return record_created_at > other_created_at + + # After having asserted that their expiration dates differ, if + # either record has no expiration date, it must have the highest + # priority + elif record_expires_at is None: + return True + elif other_expires_at is None: + return False + + # Otherwise, both have an expiration date. Select the record that + # takes the longest to expire. + return record_expires_at > other_expires_at + + return _is_higher_priority_record( + record.created_at, + record.expires_at, + other.created_at, + other.expires_at, + ) + + +def get_highest_priority_record( + records: Sequence[VipListRecord], +) -> VipListRecord | None: + """Return the highest priority VIP record amongst 0 or more records or None + + A record is higher priority if it either expires further in the future, or + for ties was created more recently + """ + if not records: + return None + + # Find record with highest priority + highest = records[0] + for record in records[1:]: + if is_higher_priority_record(record, highest): + highest = record + return highest + + +def inactivate_expired_records(): + """Mark all expired VipListRecords as inactive and cause a resynch + + This is run periodically on a cron job timer + """ + logger.info( + "Checking for expired/inactive VIP records since %s", + datetime.now(tz=timezone.utc), + ) + with enter_session() as sess: + stmt = select(VipListRecord).filter( + and_( + VipListRecord.expires_at.is_not(None), + VipListRecord.expires_at <= func.now(), + ) + ) + records = sess.execute(stmt).scalars().all() + # TODO: should probably do a bulk update here + for record in records: + logger.info( + "Deactivating %s expired at: %s", + record.player.player_id, + record.expires_at, + ) + record.active = False + sess.commit() + + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.SYNCH_GAME_SERVER, + server_mask=ALL_SERVERS_MASK, + payload=VipListSynchCommand().model_dump(), + ) + ) + + +def extend_vip_duration( + player_id: str, vip_list_id: int, duration: timedelta | int +) -> VipListRecordType | None: + """Extend a temporary VIP record by duration; does nothing to indefinite VIP""" + with enter_session() as sess: + record: VipListRecord | None = get_player_vip_list_record( + sess=sess, player_id=player_id, vip_list_id=vip_list_id + ) + + if not record: + return + + # Do nothing; they already have indefinite VIP and the record should + # be edited if someone wants to change from indefinite -> expiring + if record.expires_at is None: + return record.to_dict() + + if isinstance(duration, int): + extend = timedelta(seconds=duration) + elif isinstance(duration, timedelta): + extend = duration + else: + raise ValueError( + f"{duration} must be either a timedelta or an int quantity of seconds" + ) + + record.expires_at += extend + modified_record = record.to_dict() + + # This is handled on model validation; but it makes type checking unhappy + server_mask = record.vip_list.servers or ALL_SERVERS_MASK + + # This is effectively an edit even though you can only lengthen + # the amount of VIP time someone has + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.EDIT_RECORD, + server_mask=server_mask, + # No actual payload is needed; this command causes a resynch + # with the game server + payload=VipListEditRecordCommand().model_dump(), + ) + ) + + return modified_record + + +def revoke_all_vip(player_id: str): + """Mark all VIP records for player_id inactive and then resynch with the game server""" + with enter_session() as sess: + records = get_player_vip_list_records( + sess=sess, player_id=player_id, include_other_servers=False + ) + for record in records: + record.active = False + + # Cause a resynch with the game server since we've modified records + VipListCommandHandler.send( + VipListCommand( + command=VipListCommandType.REVOKE_VIP, + server_mask=ALL_SERVERS_MASK, + # No actual payload is needed; this command causes a resynch + # with the game server + payload=VipListRevokeAllCommand().model_dump(), + ) + ) + + +def synchronize_with_game_server(server_number: int, rcon=None): + """Compare the game server with applicable lists and add/remove/update VIPs + + Inactive or expired entries will be removed from the game server + Unknown entries (VIP on the game server; not on any applicable list) + will be removed unless ANY applicable list sync method is IGNORE_UNKNOWN + """ + if rcon is None: + # Avoid circular imports + from rcon.api_commands import get_rcon_api + + rcon = get_rcon_api() + + timestamp = datetime.now(tz=timezone.utc) + game_server_vips: dict[str, str] = { + vip["player_id"]: vip["name"] for vip in rcon.get_vip_ids() + } + logger.info("%s players with VIP on the game server", len(game_server_vips)) + + records_by_player: defaultdict[str, list[VipListRecord]] = defaultdict(list) + to_add: list[VipListRecordType] = [] + to_remove: set[str] = set() + record_player_ids: set[str] = set() + + remove_unknown = True + with enter_session() as sess: + server_records = get_all_records_for_server( + sess=sess, server_number=server_number + ) + + # A player can have multiple records; group by player + # # TODO: There's probably some SQL we can write to offload this to postgres + # but for now do it in Python + for record in server_records: + records_by_player[record.player.player_id].append(record) + + vip_lists = get_vip_lists_for_server(sess=sess, server_number=server_number) + if any(lst.sync == VipListSyncMethod.IGNORE_UNKNOWN for lst in vip_lists): + remove_unknown = False + + for player_id, player_records in records_by_player.items(): + # Keep track of all of the player IDs we have seen from VIP records + # in case we are removing unknowns + record_player_ids.add(player_id) + top_record = get_highest_priority_record(records=player_records) + # Check to make sure the player should have VIP; and then check if + # they're missing from the game server; or the description differs + if top_record and is_player_vip_by_records( + records=player_records, timestamp=timestamp + ): + # On the game server we store them as PlayerName - Description, if there is no description + # We just use their PlayerName + try: + player_name = top_record.player.names[0].name + except (IndexError, AttributeError): + player_name = "NO NAME IN CRCON" + + if top_record.description: + description = f"{player_name} - {top_record.description}" + else: + description = f"{player_name}" + + if ( + player_id not in game_server_vips + or top_record.description != game_server_vips.get(player_id) + ): + updated_record = top_record.to_dict() + updated_record["description"] = description + to_add.append(updated_record) + + # VIP is inactive or expired and should be removed if they're on the game server + if player_id in game_server_vips and not is_player_vip_by_records( + records=player_records, timestamp=timestamp + ): + to_remove.add(player_id) + # When a record is deleted; VIP is removed in the handler + # if they no longer have VIP from any other records + + if remove_unknown: + unknown_vips: set[str] = set(game_server_vips.keys()) - record_player_ids + to_remove |= unknown_vips + else: + unknown_vips: set[str] = set(game_server_vips.keys()) - record_player_ids + logger.info("%s unknown VIP ids: %s", len(unknown_vips), unknown_vips) + + logger.info("Adding VIP to %s players", len(to_add)) + logger.info("Removing VIP from %s players", len(to_remove)) + rcon.bulk_add_vips( + vips=[ + { + "player_id": record["player_id"], + "name": ( + record["description"] + if record["description"] + else f"{record['player_id']} No Description Set" + ), + } + for record in to_add + ] + ) + rcon.bulk_remove_vips(player_ids=to_remove) + + +class VipListSynchCommand(BaseModel): + pass + + +class VipListInactivateExpiredCommand(BaseModel): + pass + + +class VipListEditListCommand(BaseModel): + vip_list_id: int + + +class VipListDeleteListCommand(BaseModel): + player_ids: set[str] + + +class VipListCreateRecordCommand(BaseModel): + pass + + +class VipListEditRecordCommand(BaseModel): + pass + + +class VipListDeleteRecordCommand(BaseModel): + player_id: str + + +class VipListRevokeAllCommand(BaseModel): + pass + + +class VipListCommandType(IntEnum): + SYNCH_GAME_SERVER = auto() + INACTIVATE_EXPIRED = auto() + + # CREATE_LIST is not needed; lists are created with no records + EDIT_LIST = auto() + DELETE_LIST = auto() + + CREATE_RECORD = auto() + EDIT_RECORD = auto() + DELETE_RECORD = auto() + + REVOKE_VIP = auto() + + +class VipListCommand(BaseModel): + """Handles encoding/decoding commands to/from bytes for/from Redis""" + + command: VipListCommandType + server_mask: int + payload: dict + + # TODO: this is used multiple places, consolidate + @staticmethod + def _convert_types(o): + if isinstance(o, set): + return [val for val in sorted(o)] + else: + raise ValueError(f"Cannot serialize {o}, {type(o)} to JSON") + + @field_validator("server_mask", mode="before") + @classmethod + def convert_none_to_mask(cls, v: int | None) -> int: + if v is None: + # Create mask with the first 32 bits flipped + return ALL_SERVERS_MASK + return v + + def encode(self) -> bytes: + """Dump our command type and payload to bytes for Redis""" + return struct.pack("II", self.command, self.server_mask) + orjson.dumps( + self.payload, + default=VipListCommand._convert_types, + ) + + @classmethod + def decode(cls, data: bytes) -> Self: + """Unpack bytes from Redis and construct a VipListCommand""" + split_at = struct.calcsize("II") + + command_id, server_mask = struct.unpack("II", data[:split_at]) + command = VipListCommandType(command_id) + + payload = orjson.loads(data[split_at:]) + + return cls(command=command, server_mask=server_mask, payload=payload) + + +class VipListCommandHandler: + CHANNEL = "vip_list" + + def __init__(self) -> None: + redis_url = os.getenv("HLL_REDIS_URL") + if not redis_url: + raise RuntimeError("HLL_REDIS_URL not set") + + # Initialize our own little Redis client, because the shared one has + # a global timeout and attempts to decode our little packets + self.red = redis.Redis.from_url( + redis_url, single_connection_client=True, decode_responses=False + ) + + self.pubsub = self.red.pubsub(ignore_subscribe_messages=True) + + # Avoid circular imports + from rcon.api_commands import get_rcon_api + + self.rcon = get_rcon_api() + + @staticmethod + def send(cmd: VipListCommand): + if cmd.server_mask == 0: + # Command will be ignored by all servers, don't bother sending it. + return + + # Publish the command; each individual CRCON instance will be monitoring + # this from VipListCommandHandler.run() run as a service from supervisord + red.publish(VipListCommandHandler.CHANNEL, cmd.encode()) + + def run(self) -> None: + """Run the command handler loop, this is a blocking call. + + Cron is used externally to periodically inactivate expired records + and resynch all the connected game servers + """ + logger.info("Starting vip list command handler loop") + self.pubsub.subscribe(self.CHANNEL) + for message in self.pubsub.listen(): + try: + data: bytes = message["data"] + cmd = VipListCommand.decode(data) + + if not (cmd.server_mask & get_server_number_mask()): + # Command is not meant for this server + continue + logger.info("Handling %s command", cmd.command.name) + + try: + match cmd.command: + case VipListCommandType.SYNCH_GAME_SERVER: + self.handle_synchronize( + VipListSynchCommand.model_validate(cmd.payload) + ) + case VipListCommandType.INACTIVATE_EXPIRED: + self.handle_inactivate_expired_records( + VipListInactivateExpiredCommand.model_validate( + cmd.payload + ) + ) + case VipListCommandType.EDIT_LIST: + self.handle_edit_list( + VipListEditListCommand.model_validate(cmd.payload) + ) + case VipListCommandType.DELETE_LIST: + self.handle_delete_list( + VipListDeleteListCommand.model_validate(cmd.payload) + ) + case VipListCommandType.CREATE_RECORD: + self.handle_create_record( + VipListCreateRecordCommand.model_validate(cmd.payload) + ) + case VipListCommandType.EDIT_RECORD: + self.handle_edit_record( + VipListEditRecordCommand.model_validate(cmd.payload) + ) + case VipListCommandType.DELETE_RECORD: + self.handle_delete_record( + VipListDeleteRecordCommand.model_validate(cmd.payload) + ) + case VipListCommandType.REVOKE_VIP: + self.handle_revoke_all( + VipListRevokeAllCommand.model_validate(cmd.payload) + ) + case _: + logger.error("Unknown command %r", cmd.command) + except: + logger.exception( + "Error whilst executing %s command with payload %s", + cmd.command.name, + cmd.payload, + ) + except: + logger.exception("Failed to parse data %s", message["data"]) + logger.info("Ready for next message!") + + def handle_synchronize(self, payload: VipListSynchCommand): + """Resynch VIP records with the game server""" + synchronize_with_game_server(rcon=self.rcon, server_number=SERVER_NUMBER) + + def handle_inactivate_expired_records( + self, payload: VipListInactivateExpiredCommand + ): + """Inactivate any records that have expired""" + inactivate_expired_records() + + def handle_edit_list(self, payload: VipListEditListCommand): + """Editing a list causes a resynch with the game server""" + synchronize_with_game_server(rcon=self.rcon, server_number=SERVER_NUMBER) + + def handle_delete_list(self, payload: VipListDeleteListCommand): + """Handle a VipList being deleted. + + When a VipList is deleted; all players with VIP on the game server who were + on the list and do not have VIP from another applicable list lose their + VIP status on the game server + + Players with VIP on the game server who aren't on the affected list are ignored + and unknown (not on any lists, but VIP on the game server) are handled + according to other applicable lists VipListSyncMethod (the default list + can never be deleted) + """ + to_remove_player_ids: set[str] = set() + with enter_session() as sess: + for player_id in payload.player_ids: + # Make sure the player shouldn't still have VIP from some other list + if not is_player_vip_by_query(sess=sess, player_id=player_id): + to_remove_player_ids.add(player_id) + + self.rcon.bulk_remove_vips(player_ids=to_remove_player_ids) + + def handle_create_record(self, payload: VipListCreateRecordCommand): + """Record creation causes a resynch with the game server""" + synchronize_with_game_server(rcon=self.rcon, server_number=SERVER_NUMBER) + + def handle_edit_record(self, payload: VipListEditRecordCommand): + """Record editing causes a resynch with the game server""" + synchronize_with_game_server(rcon=self.rcon, server_number=SERVER_NUMBER) + + def handle_delete_record(self, payload: VipListDeleteRecordCommand): + """Record deletion removes VIP if the player has no other applicable records""" + with enter_session() as sess: + if not is_player_vip_by_query(sess=sess, player_id=payload.player_id): + self.rcon.remove_vip(player_id=payload.player_id) + + def handle_revoke_all(self, payload: VipListRevokeAllCommand): + """Revoking VIP causes a resynch with the game server""" + synchronize_with_game_server(rcon=self.rcon, server_number=SERVER_NUMBER) \ No newline at end of file diff --git a/rcon/workers.py b/rcon/workers.py index 1a692e5b0..8a75a4669 100644 --- a/rcon/workers.py +++ b/rcon/workers.py @@ -1,7 +1,6 @@ import datetime import logging import os -from concurrent.futures import as_completed from datetime import timedelta from typing import Set @@ -17,7 +16,6 @@ from rcon.player_history import get_player from rcon.player_stats import TimeWindowStats from rcon.types import MapInfo, PlayerStat, GameLayout -from rcon.utils import INDEFINITE_VIP_DATE logger = logging.getLogger("rcon") @@ -328,68 +326,3 @@ def get_job_results(job_key): "func_name": job.func_name, "check_timestamp": datetime.datetime.now().timestamp(), } - - -def worker_bulk_vip(name_ids, job_key, mode="override"): - queue = get_queue() - return queue.enqueue( - bulk_vip, - name_ids=name_ids, - mode=mode, - result_ttl=6000, - job_timeout=1200, - job_id=job_key, - ) - - -def bulk_vip(name_ids, mode="override"): - from rcon.api_commands import get_rcon_api - - ctl = get_rcon_api() - errors = [] - logger.info(f"bulk_vip name_ids {name_ids[0]} type {type(name_ids)}") - vips = ctl.get_vip_ids() - - removal_futures = { - ctl.run_in_pool("remove_vip", vip["player_id"]): vip - for idx, vip in enumerate(vips) - } - for future in as_completed(removal_futures): - try: - result = future.result() - if not result: - errors.append(f"Failed to add {removal_futures[future]}") - except Exception: - logger.exception(f"Failed to remove vip from {removal_futures[future]}") - - processed_additions = [] - for description, player_id, expiration_timestamp in name_ids: - if not expiration_timestamp: - expiration_timestamp = INDEFINITE_VIP_DATE.isoformat() - else: - expiration_timestamp = expiration_timestamp.isoformat() - - processed_additions.append((description, player_id, expiration_timestamp)) - - add_futures = { - ctl.run_in_pool( - "add_vip", - player_id=player_id, - description=description, - expiration=expiration_timestamp, - ): player_id - for idx, (description, player_id, expiration_timestamp) in enumerate( - processed_additions - ) - } - for future in as_completed(add_futures): - try: - result = future.result() - if not result: - errors.append(f"Failed to add {add_futures[future]}") - except Exception: - logger.exception(f"Failed to add vip to {add_futures[future]}") - - if not errors: - errors.append("ALL OK") - return errors diff --git a/rcongui/src/features/player-action/permissions.js b/rcongui/src/features/player-action/permissions.js index ac42660d5..3d0d10e3d 100644 --- a/rcongui/src/features/player-action/permissions.js +++ b/rcongui/src/features/player-action/permissions.js @@ -27,10 +27,6 @@ export const permissions = [ permission: "can_view_player_messages", description: "Can view messages sent to players", }, - { - permission: "can_change_expired_vip_config", - description: "Can change Expired VIP config", - }, { permission: "can_change_server_name", description: "Can change the server name", @@ -109,10 +105,6 @@ export const permissions = [ permission: "change_contenttype", description: "Can change content type", }, - { - permission: "can_view_expired_vip_config", - description: "Can view Expired VIP config", - }, { permission: "can_view_auto_settings", description: "Can view auto settings", @@ -836,4 +828,32 @@ export const permissions = [ permission: "can_change_watch_killrate_config", description: "Can change the Watch KillRate config", }, + { + permission: "can_change_vip_list_records", + description: "Can revoke VIP and edit VIP lists records", + }, + { + permission: "can_add_vip_list_records", + description: "Can add players to VIP lists", + }, + { + permission: "can_change_vip_lists", + description: "Can change VIP lists", + }, + { + permission: "can_delete_vip_lists", + description: "Can delete VIP lists", + }, + { + permission: "can_view_vip_lists", + description: "Can view VIP lists and their records", + }, + { + permission: "can_create_vip_lists", + description: "Can create VIP lists", + }, + { + permission: "can_delete_vip_lists_records", + description: "Can remove players from VIP lists", + }, ]; diff --git a/rcongui/src/queries/vip-query.jsx b/rcongui/src/queries/vip-query.jsx index ffa1d0cb2..711e8a28e 100644 --- a/rcongui/src/queries/vip-query.jsx +++ b/rcongui/src/queries/vip-query.jsx @@ -1,36 +1,259 @@ import { cmd } from "@/utils/fetchUtils"; import { queryOptions } from "@tanstack/react-query"; -import { queryClient } from "@/queryClient"; -export const vipQueryKeys = { - list: [{ queryIdentifier: "get_vip_ids" }], - add: [{ queryIdentifier: "add_vip" }], - remove: [{ queryIdentifier: "remove_vip" }], +// Define query keys to maintain consistent query identifiers +export const vipManagerQueryKeys = { + vips: [{ queryIdentifier: "get_vip_ids" }], + vipLists: [{ queryIdentifier: "get_vip_lists" }], + vipListsForServer: [{ queryIdentifier: "get_vip_lists_for_server" }], + vipList: [{ queryIdentifier: "get_vip_list" }], + vipListRecord: [{ queryIdentifier: "get_vip_list_record" }], + vipStatusForPlayers: [{ queryIdentifier: "get_vip_status_for_player_ids" }], + activeVipRecords: [{ queryIdentifier: "get_active_vip_records" }], + inactiveVipRecords: [{ queryIdentifier: "get_inactive_vip_records" }], + playerVipRecords: [{ queryIdentifier: "get_player_vip_records" }], + playerVipListRecord: [{ queryIdentifier: "get_player_vip_list_record" }], + vipListRecords: [{ queryIdentifier: "get_vip_list_records" }], + allVipRecordsForServer: [{ queryIdentifier: "get_all_vip_records_for_server" }], }; -export const vipQueryOptions = { - list: () => +// Define query options for fetching data +export const vipManagerQueryOptions = { + // Get all VIPs + vips: () => queryOptions({ - queryKey: vipQueryKeys.list, + queryKey: vipManagerQueryKeys.vips, queryFn: () => cmd.GET_VIPS(), }), + + // Get all VIP lists + vipLists: (params) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.vipLists, params], + queryFn: () => cmd.GET_VIP_LISTS({ params }), + }), + + // Get VIP lists for a specific server + vipListsForServer: () => + queryOptions({ + queryKey: vipManagerQueryKeys.vipListsForServer, + queryFn: () => cmd.GET_VIP_LISTS_FOR_SERVER(), + }), + + // Get a single VIP list + vipList: (vipListId, params) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.vipList, vipListId, params], + queryFn: () => cmd.GET_VIP_LIST({ params: { vip_list_id: vipListId, ...params } }), + }), + + // Get a single VIP list record + vipListRecord: (recordId) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.vipListRecord, recordId], + queryFn: () => cmd.GET_VIP_LIST_RECORD({ params: { record_id: recordId } }), + }), + + // Get VIP status for specific players + vipStatusForPlayers: (playerIds) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.vipStatusForPlayers, playerIds], + queryFn: () => cmd.GET_VIP_STATUS_FOR_PLAYERS({ params: { player_ids: playerIds } }), + }), + + // Get all active VIP records + activeVipRecords: (vipListId) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.activeVipRecords, vipListId], + queryFn: () => cmd.GET_ACTIVE_VIP_RECORDS({ params: { vip_list_id: vipListId } }), + }), + + // Get all inactive VIP records + inactiveVipRecords: (vipListId) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.inactiveVipRecords, vipListId], + queryFn: () => cmd.GET_INACTIVE_VIP_RECORDS({ params: { vip_list_id: vipListId } }), + }), + + // Get VIP records for a specific player + playerVipRecords: (playerId, params) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.playerVipRecords, playerId, params], + queryFn: () => cmd.GET_PLAYER_VIP_RECORDS({ params: { player_id: playerId, ...params } }), + }), + + // Get a player's VIP list record + playerVipListRecord: (params) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.playerVipListRecord, params], + queryFn: () => cmd.GET_PLAYER_VIP_LIST_RECORD({ params }), + }), + + // Get all records for a VIP list + vipListRecords: (params) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.vipListRecords, params], + queryFn: () => cmd.GET_VIP_LIST_RECORDS({ params }), + }), + + // Get all VIP records for a server + allVipRecordsForServer: (serverNumber) => + queryOptions({ + queryKey: [...vipManagerQueryKeys.allVipRecordsForServer, serverNumber], + queryFn: () => cmd.GET_ALL_VIP_RECORDS_FOR_SERVER({ params: { server_number: serverNumber } }), + }), }; -// const onMutationSuccess = (_, { player_id }) => { -// queryClient.invalidateQueries({ queryKey: ["player", "profile", player_id] }); -// }; +// Define mutation options for updating data +export const vipManagerMutationOptions = { + // Delete a VIP + deleteVip: { + mutationFn: (playerId) => + cmd.DELETE_VIP({ + payload: { player_id: playerId }, + throwRouteError: false, + }), + }, -export const vipMutationOptions = { - add: { - mutationKey: vipQueryKeys.add, - mutationFn: ({ description, player_id, expiration, forward = false }) => + // Add a VIP + addVip: { + mutationFn: ({ playerId, description }) => cmd.ADD_VIP({ - payload: { description, player_id, expiration, forward }, + payload: { player_id: playerId, description }, + throwRouteError: false, }), }, - remove: { - mutationKey: vipQueryKeys.remove, - mutationFn: ({ player_id, forward = false }) => - cmd.DELETE_VIP({ payload: { player_id, forward } }), + + // Create a new VIP list + createVipList: { + mutationFn: (vipList) => + cmd.CREATE_VIP_LIST({ + payload: vipList, + throwRouteError: false, + }), }, -}; + + // Edit an existing VIP list + editVipList: { + mutationFn: (vipList) => + cmd.EDIT_VIP_LIST({ + payload: vipList, + throwRouteError: false, + }), + }, + + // Delete a VIP list + deleteVipList: { + mutationFn: (vipListId) => + cmd.DELETE_VIP_LIST({ + payload: { vip_list_id: vipListId }, + throwRouteError: false, + }), + }, + + // Add a record to a VIP list + addVipListRecord: { + mutationFn: (record) => + cmd.ADD_VIP_LIST_RECORD({ + payload: record, + throwRouteError: false, + }), + }, + + // Edit a VIP list record + editVipListRecord: { + mutationFn: (record) => + cmd.EDIT_VIP_LIST_RECORD({ + payload: record, + throwRouteError: false, + }), + }, + + // Add or edit a VIP list record + addOrEditVipListRecord: { + mutationFn: (record) => + cmd.ADD_OR_EDIT_VIP_LIST_RECORD({ + payload: record, + throwRouteError: false, + }), + }, + + // Bulk add VIP list records + bulkAddVipListRecords: { + mutationFn: (records) => + cmd.BULK_ADD_VIP_LIST_RECORDS({ + payload: { records }, + throwRouteError: false, + }), + }, + + // Bulk delete VIP list records + bulkDeleteVipListRecords: { + mutationFn: (recordIds) => + cmd.BULK_DELETE_VIP_LIST_RECORDS({ + payload: { record_ids: recordIds }, + throwRouteError: false, + }), + }, + + // Bulk edit VIP list records + bulkEditVipListRecords: { + mutationFn: (records) => + cmd.BULK_EDIT_VIP_LIST_RECORDS({ + payload: { records }, + throwRouteError: false, + }), + }, + + // Delete a VIP list record + deleteVipListRecord: { + mutationFn: (recordId) => + cmd.DELETE_VIP_LIST_RECORD({ + payload: { record_id: recordId }, + throwRouteError: false, + }), + }, + + // Inactivate expired VIP records + inactivateExpiredVipRecords: { + mutationFn: () => + cmd.INACTIVATE_EXPIRED_VIP_RECORDS({ + throwRouteError: false, + }), + }, + + // Extend VIP duration + extendVipDuration: { + mutationFn: ({ recordId, duration }) => + cmd.EXTEND_VIP_DURATION({ + payload: { record_id: recordId, duration }, + throwRouteError: false, + }), + }, + + // Revoke all VIP for a specific player + revokeAllVip: { + mutationFn: (playerId) => + cmd.REVOKE_ALL_VIP({ + payload: { player_id: playerId }, + throwRouteError: false, + }), + }, + + // Synchronize with game server + synchronizeWithGameServer: { + mutationFn: () => + cmd.SYNCHRONIZE_WITH_GAME_SERVER({ + throwRouteError: false, + }), + }, + + // Convert old-style VIP records into a VIP list + convertOldStyleVipRecords: { + mutationFn: ({ records, vipListId }) => + cmd.CONVERT_OLD_STYLE_VIP_RECORDS({ + payload: { records, vip_list_id: vipListId }, + throwRouteError: false, + }), + }, +}; \ No newline at end of file diff --git a/rcongui/src/utils/fetchUtils.js b/rcongui/src/utils/fetchUtils.js index 3e1d8de00..05b955d01 100644 --- a/rcongui/src/utils/fetchUtils.js +++ b/rcongui/src/utils/fetchUtils.js @@ -100,7 +100,6 @@ async function parseJsonResponse(response) { export const cmd = { ADD_CONSOLE_ADMIN: (params) => requestFactory({ method: "POST", cmd: "add_admin", ...params }), ADD_MESSAGE_TEMPLATE: (params) => requestFactory({ method: "POST", cmd: "add_message_template", ...params }), - ADD_VIP: (params) => requestFactory({ method: "POST", cmd: "add_vip", ...params }), AUTHENTICATE: (params) => requestFactory({ method: "POST", cmd: "login", ...params }), MESSAGE_PLAYER: (params) => requestFactory({ method: "POST", cmd: "message_player", ...params }), MESSAGE_ALL_PLAYERS: (params) => requestFactory({ method: "POST", cmd: "message_all_players", ...params }), @@ -108,7 +107,6 @@ export const cmd = { CLEAR_APPLICATION_CACHE: (params) => requestFactory({ method: "POST", cmd: "clear_cache", ...params }), DELETE_CONSOLE_ADMIN: (params) => requestFactory({ method: "POST", cmd: "remove_admin", ...params }), DELETE_MESSAGE_TEMPLATE: (params) => requestFactory({ method: "POST", cmd: "delete_message_template", ...params }), - DELETE_VIP: (params) => requestFactory({ method: "POST", cmd: "remove_vip", ...params }), DISBAND_SQUAD: (params) => requestFactory({ method: "POST", cmd: "disband_squad_by_name", ...params }), EDIT_MESSAGE_TEMPLATE: (params) => requestFactory({ method: "POST", cmd: "edit_message_template", ...params }), EDIT_PLAYER_ACCOUNT: (params) => requestFactory({ method: "POST", cmd: "edit_player_account", ...params }), @@ -174,7 +172,6 @@ export const cmd = { GET_SERVICES: (params) => requestFactory({ method: "GET", cmd: "get_services", ...params }), GET_SYSTEM_USAGE: (params) => requestFactory({ method: "GET", cmd: "get_system_usage", ...params }), GET_VERSION: (params) => requestFactory({ method: "GET", cmd: "get_version", ...params }), - GET_VIPS: (params) => requestFactory({ method: "GET", cmd: "get_vip_ids", ...params }), GET_VOTEKICK_AUTOTOGGLE_CONFIG: (params) => requestFactory({ method: "GET", cmd: "get_votekick_autotoggle_config", ...params }), GET_VOTEMAP_WHITELIST: (params) => requestFactory({ method: "GET", cmd: "get_votemap_whitelist", ...params }), GET_VOTEMAP_CONFIG: (params) => requestFactory({ method: "GET", cmd: "get_votemap_config", ...params }), @@ -219,9 +216,39 @@ export const cmd = { TOGGLE_SERVICE: (params) => requestFactory({ method: "POST", cmd: "do_service", ...params }), UNFLAG_PLAYER: (params) => requestFactory({ method: "POST", cmd: "unflag_player", ...params }), // Files - DOWNLOAD_VIP_FILE: (params) => requestFactory({ method: "GET", cmd: "download_vips", ...params }), - UPLOAD_VIP_FILE: (params) => requestFactory({ method: "POST", cmd: "upload_vips", ...params }), - GET_UPLOAD_VIP_FILE_RESPONSE: (params) => requestFactory({ method: "GET", cmd: "upload_vips_result", ...params }), + // DOWNLOAD_VIP_FILE: (params) => requestFactory({ method: "GET", cmd: "download_vips", ...params }), + // UPLOAD_VIP_FILE: (params) => requestFactory({ method: "POST", cmd: "upload_vips", ...params }), + // GET_UPLOAD_VIP_FILE_RESPONSE: (params) => requestFactory({ method: "GET", cmd: "upload_vips_result", ...params }), + // VIPS + GET_VIPS: (params) => requestFactory({ method: "GET", cmd: "get_vip_ids", ...params }), + DELETE_VIP: (params) => requestFactory({ method: "POST", cmd: "remove_vip", ...params }), + ADD_VIP: (params) => requestFactory({ method: "POST", cmd: "add_vip", ...params }), + GET_VIP_LISTS: (params) => requestFactory({ method: "GET", cmd: "get_vip_lists", ...params }), + GET_VIP_LISTS_FOR_SERVER: (params) => requestFactory({ method: "GET", cmd: "get_vip_lists_for_server", ...params }), + GET_VIP_LIST: (params) => requestFactory({ method: "GET", cmd: "get_vip_list", ...params }), + CREATE_VIP_LIST: (params) => requestFactory({ method: "POST", cmd: "create_vip_list", ...params }), + EDIT_VIP_LIST: (params) => requestFactory({ method: "POST", cmd: "edit_vip_list", ...params }), + DELETE_VIP_LIST: (params) => requestFactory({ method: "POST", cmd: "delete_vip_list", ...params }), + GET_VIP_LIST_RECORD: (params) => requestFactory({ method: "GET", cmd: "get_vip_list_record", ...params }), + ADD_VIP_LIST_RECORD: (params) => requestFactory({ method: "POST", cmd: "add_vip_list_record", ...params }), + EDIT_VIP_LIST_RECORD: (params) => requestFactory({ method: "POST", cmd: "edit_vip_list_record", ...params }), + ADD_OR_EDIT_VIP_LIST_RECORD: (params) => requestFactory({ method: "POST", cmd: "add_or_edit_vip_list_record", ...params }), + BULK_ADD_VIP_LIST_RECORDS: (params) => requestFactory({ method: "POST", cmd: "bulk_add_vip_list_records", ...params }), + BULK_DELETE_VIP_LIST_RECORDS: (params) => requestFactory({ method: "POST", cmd: "bulk_delete_vip_list_records", ...params }), + BULK_EDIT_VIP_LIST_RECORDS: (params) => requestFactory({ method: "POST", cmd: "bulk_edit_vip_list_records", ...params }), + DELETE_VIP_LIST_RECORD: (params) => requestFactory({ method: "POST", cmd: "delete_vip_list_record", ...params }), + GET_VIP_STATUS_FOR_PLAYERS: (params) => requestFactory({ method: "GET", cmd: "get_vip_status_for_player_ids", ...params }), + GET_ACTIVE_VIP_RECORDS: (params) => requestFactory({ method: "GET", cmd: "get_active_vip_records", ...params }), + GET_INACTIVE_VIP_RECORDS: (params) => requestFactory({ method: "GET", cmd: "get_inactive_vip_records", ...params }), + GET_PLAYER_VIP_RECORDS: (params) => requestFactory({ method: "GET", cmd: "get_player_vip_records", ...params }), + GET_PLAYER_VIP_LIST_RECORD: (params) => requestFactory({ method: "GET", cmd: "get_player_vip_list_record", ...params }), + GET_VIP_LIST_RECORDS: (params) => requestFactory({ method: "GET", cmd: "get_vip_list_records", ...params }), + GET_ALL_VIP_RECORDS_FOR_SERVER: (params) => requestFactory({ method: "GET", cmd: "get_all_vip_records_for_server", ...params }), + INACTIVATE_EXPIRED_VIP_RECORDS: (params) => requestFactory({ method: "POST", cmd: "inactivate_expired_vip_records", ...params }), + EXTEND_VIP_DURATION: (params) => requestFactory({ method: "POST", cmd: "extend_vip_duration", ...params }), + REVOKE_ALL_VIP: (params) => requestFactory({ method: "POST", cmd: "revoke_all_vip", ...params }), + SYNCHRONIZE_WITH_GAME_SERVER: (params) => requestFactory({ method: "POST", cmd: "synchronize_with_game_server", ...params }), + CONVERT_OLD_STYLE_VIP_RECORDS: (params) => requestFactory({ method: "POST", cmd: "convert_old_style_vip_records", ...params }), }; export function execute(command, data) { diff --git a/rconweb/api/migrations/0003_create_default_groups.py b/rconweb/api/migrations/0003_create_default_groups.py index fd37d8ce0..828dcab42 100644 --- a/rconweb/api/migrations/0003_create_default_groups.py +++ b/rconweb/api/migrations/0003_create_default_groups.py @@ -145,8 +145,6 @@ "can_change_auto_mod_solo_tank_config", "can_view_tk_ban_on_connect_config", "can_change_tk_ban_on_connect_config", - "can_view_expired_vip_config", - "can_change_expired_vip_config", "can_view_server_name_change_config", "can_change_server_name_change_config", "can_view_log_line_discord_webhook_config", @@ -202,6 +200,14 @@ "can_change_webhook_queues", "can_view_watch_killrate_config", "can_change_watch_killrate_config", + "can_view_vip_lists", + "can_create_vip_lists", + "can_change_vip_lists", + "can_delete_vip_lists", + "can_change_vip_list_records", + "can_add_vip_list_records", + "can_change_vip_list_records", + "can_delete_vip_lists_records", ), ), ( @@ -342,8 +348,6 @@ "can_change_auto_mod_seeding_config", "can_view_tk_ban_on_connect_config", "can_change_tk_ban_on_connect_config", - "can_view_expired_vip_config", - "can_change_expired_vip_config", "can_view_log_line_discord_webhook_config", "can_change_log_line_discord_webhook_config", "can_view_name_kick_config", @@ -394,6 +398,14 @@ "can_change_webhook_queues", "can_view_watch_killrate_config", "can_change_watch_killrate_config", + "can_view_vip_lists", + "can_create_vip_lists", + "can_change_vip_lists", + "can_delete_vip_lists", + "can_change_vip_list_records", + "can_add_vip_list_records", + "can_change_vip_list_records", + "can_delete_vip_lists_records", ), ), ( @@ -481,6 +493,7 @@ "can_view_votemap_status", "can_view_welcome_message", "can_view_message_templates", + "can_view_vip_lists", ), ), ( @@ -554,6 +567,7 @@ "can_view_votemap_status", "can_view_welcome_message", "can_view_message_templates", + "can_view_vip_lists", ), ), ] diff --git a/rconweb/api/migrations/0020_alter_rconuser_options.py b/rconweb/api/migrations/0020_alter_rconuser_options.py index 4b97f9cdc..b792aafca 100644 --- a/rconweb/api/migrations/0020_alter_rconuser_options.py +++ b/rconweb/api/migrations/0020_alter_rconuser_options.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.17 on 2025-01-16 20:53 +# Generated by Django 4.2.18 on 2025-02-13 19:07 from django.db import migrations @@ -6,12 +6,572 @@ class Migration(migrations.Migration): dependencies = [ - ('api', '0019_alter_rconuser_options'), + ("api", "0019_alter_rconuser_options"), ] operations = [ migrations.AlterModelOptions( - name='rconuser', - options={'default_permissions': (), 'permissions': (('can_add_admin_roles', 'Can add HLL game server admin roles to players'), ('can_add_map_to_rotation', 'Can add a map to the rotation'), ('can_add_map_to_whitelist', 'Can add a map to the votemap whitelist'), ('can_add_maps_to_rotation', 'Can add maps to the rotation'), ('can_add_maps_to_whitelist', 'Can add multiple maps to the votemap whitelist'), ('can_add_player_comments', 'Can add comments to a players profile'), ('can_add_player_watch', 'Can add a watch to players'), ('can_add_vip', 'Can add VIP status to players'), ('can_ban_profanities', 'Can ban profanities (censored game chat)'), ('can_change_auto_broadcast_config', 'Can change the automated broadcast settings'), ('can_change_auto_settings', 'Can change auto settings'), ('can_change_autobalance_enabled', 'Can enable/disable autobalance'), ('can_change_autobalance_threshold', 'Can change the autobalance threshold'), ('can_change_broadcast_message', 'Can change the broadcast message'), ('can_change_camera_config', 'Can change camera notification settings'), ('can_change_current_map', 'Can change the current map'), ('can_change_discord_webhooks', 'Can change configured webhooks on the settings page'), ('can_change_idle_autokick_time', 'Can change the idle autokick time'), ('can_change_max_ping_autokick', 'Can change the max ping autokick'), ('can_change_profanities', 'Can add/remove profanities (censored game chat)'), ('can_change_queue_length', 'Can change the server queue size'), ('can_change_real_vip_config', 'Can change the real VIP settings'), ('can_change_server_name', 'Can change the server name'), ('can_change_shared_standard_messages', 'Can change the shared standard messages'), ('can_change_team_switch_cooldown', 'Can change the team switch cooldown'), ('can_change_vip_slots', 'Can change the number of reserved VIP slots'), ('can_change_votekick_autotoggle_config', 'Can change votekick settings'), ('can_change_votekick_enabled', 'Can enable/disable vote kicks'), ('can_change_votekick_threshold', 'Can change vote kick thresholds'), ('can_change_votemap_config', 'Can change the votemap settings'), ('can_change_welcome_message', 'Can change the welcome (rules) message'), ('can_clear_crcon_cache', 'Can clear the CRCON Redis cache'), ('can_download_vip_list', 'Can download the VIP list'), ('can_flag_player', 'Can add flags to players'), ('can_kick_players', 'Can kick players'), ('can_message_players', 'Can message players'), ('can_perma_ban_players', 'Can permanently ban players'), ('can_punish_players', 'Can punish players'), ('can_remove_admin_roles', 'Can remove HLL game server admin roles from players'), ('can_remove_all_vips', 'Can remove all VIPs'), ('can_remove_map_from_rotation', 'Can remove a map from the rotation'), ('can_remove_map_from_whitelist', 'Can remove a map from the votemap whitelist'), ('can_remove_maps_from_rotation', 'Can remove maps from the rotation'), ('can_remove_maps_from_whitelist', 'Can remove multiple maps from the votemap whitelist'), ('can_remove_perma_bans', 'Can remove permanent bans from players'), ('can_remove_player_watch', 'Can remove a watch from players'), ('can_remove_temp_bans', 'Can remove temporary bans from players'), ('can_remove_vip', 'Can remove VIP status from players'), ('can_reset_map_whitelist', 'Can reset the votemap whitelist'), ('can_reset_votekick_threshold', 'Can reset votekick thresholds'), ('can_reset_votemap_state', 'Can reset votemap selection & votes'), ('can_run_raw_commands', 'Can send raw commands to the HLL game server'), ('can_set_map_whitelist', 'Can set the votemap whitelist'), ('can_switch_players_immediately', 'Can immediately switch players'), ('can_switch_players_on_death', 'Can switch players on death'), ('can_temp_ban_players', 'Can temporarily ban players'), ('can_toggle_services', 'Can enable/disable services (automod, etc)'), ('can_unban_profanities', 'Can unban profanities (censored game chat)'), ('can_unflag_player', 'Can remove flags from players'), ('can_upload_vip_list', 'Can upload a VIP list'), ('can_view_admin_groups', 'Can view available admin roles'), ('can_view_admin_ids', 'Can view the name/steam IDs/role of everyone with a HLL game server admin role'), ('can_view_admins', 'Can view users with HLL game server admin roles'), ('can_view_all_maps', 'Can view all possible maps'), ('can_view_audit_logs', 'Can view the can_view_audit_logs endpoint'), ('can_view_audit_logs_autocomplete', 'Can view the get_audit_logs_autocomplete endpoint'), ('can_view_auto_broadcast_config', 'Can view the automated broadcast settings'), ('can_view_auto_settings', 'Can view auto settings'), ('can_view_autobalance_enabled', 'Can view if autobalance is enabled'), ('can_view_autobalance_threshold', 'Can view the autobalance threshold'), ('can_view_available_services', 'Can view services (automod, etc)'), ('can_view_broadcast_message', 'Can view the current broadcast message'), ('can_view_camera_config', 'Can view camera notification settings'), ('can_view_connection_info', "Can view CRCON's connection info"), ('can_view_current_map', 'Can view the currently playing map'), ('can_view_date_scoreboard', 'Can view the date_scoreboard endpoint'), ('can_view_detailed_player_info', 'Can view detailed player info (name, steam ID, loadout, squad, etc.)'), ('can_view_discord_webhooks', 'Can view configured webhooks on the settings page'), ('can_view_game_logs', 'Can view the get_logs endpoint (returns unparsed game logs)'), ('can_view_gamestate', 'Can view the current gamestate'), ('can_view_get_players', 'Can view get_players endpoint (name, steam ID, VIP status and sessions) for all connected players'), ('can_view_get_status', 'Can view the get_status endpoint (server name, current map, player count)'), ('can_view_historical_logs', 'Can view historical logs'), ('can_view_idle_autokick_time', 'Can view the idle autokick time'), ('can_view_ingame_admins', 'Can view admins connected to the game server'), ('can_view_map_rotation', 'Can view the current map rotation'), ('can_view_map_whitelist', 'Can view the votemap whitelist'), ('can_view_max_ping_autokick', 'Can view the max autokick ping'), ('can_view_next_map', 'Can view the next map in the rotation'), ('can_view_online_admins', 'Can view admins connected to CRCON'), ('can_view_online_console_admins', 'Can view the player name of all connected players with a HLL game server admin role'), ('can_view_other_crcon_servers', 'Can view other servers hosted in the same CRCON (forward to all servers)'), ('can_view_perma_bans', 'Can view permanently banned players'), ('can_view_player_bans', 'Can view all bans (temp/permanent) for a specific player'), ('can_view_player_comments', 'Can view comments added to a players profile'), ('can_view_player_history', 'Can view History > Players'), ('can_view_player_info', 'Can view the get_player_info endpoint (Name, steam ID, country and steam bans)'), ('can_view_player_messages', 'Can view messages sent to players'), ('can_view_player_profile', 'View the detailed player profile page'), ('can_view_player_slots', 'Can view the current/max players on the server'), ('can_view_playerids', 'Can view the get_playerids endpoint (name and steam IDs of connected players)'), ('can_view_players', 'Can view get_players endpoint for all connected players '), ('can_view_profanities', 'Can view profanities (censored game chat)'), ('can_view_queue_length', 'Can view the maximum size of the server queue'), ('can_view_real_vip_config', 'Can view the real VIP settings'), ('can_view_recent_logs', 'Can view recent logs (Live view)'), ('can_view_round_time_remaining', 'Can view the amount of time left in the round'), ('can_view_server_name', 'Can view the server name'), ('can_view_shared_standard_messages', 'Can view the shared standard messages'), ('can_view_structured_logs', 'Can view the get_structured_logs endpoint'), ('can_view_team_objective_scores', 'Can view the number of objectives held by each team'), ('can_view_team_switch_cooldown', 'Can view the team switch cooldown value'), ('can_view_detailed_players', 'Can view get_detailed_players endpoint'), ('can_view_team_view', 'Can view get_team_view endpoint (detailed player info by team for all connected players)'), ('can_view_temp_bans', 'Can view temporary banned players'), ('can_view_vip_count', 'Can view the number of connected VIPs'), ('can_view_vip_ids', 'Can view all players with VIP and their expiration timestamps'), ('can_view_vip_slots', 'Can view the number of reserved VIP slots'), ('can_view_votekick_autotoggle_config', 'Can view votekick settings'), ('can_view_votekick_enabled', 'Can view if vote kick is enabled'), ('can_view_votekick_threshold', 'Can view the vote kick thresholds'), ('can_view_votemap_config', 'Can view the votemap settings'), ('can_view_votemap_status', 'Can view the current votemap status (votes, results, etc)'), ('can_view_current_map_sequence', 'Can view the current map shuffle sequence'), ('can_view_map_shuffle_enabled', 'Can view if map shuffle is enabled'), ('can_change_map_shuffle_enabled', 'Can enable/disable map shuffle'), ('can_view_welcome_message', 'Can view the server welcome message'), ('can_view_auto_mod_level_config', 'Can view Auto Mod Level enforcement config'), ('can_change_auto_mod_level_config', 'Can change Auto Mod Level enforcement config'), ('can_view_auto_mod_no_leader_config', 'Can view Auto Mod No Leader enforcement config'), ('can_change_auto_mod_no_leader_config', 'Can change Auto Mod No Leader enforcement config'), ('can_view_auto_mod_seeding_config', 'Can view Auto Mod No Seeding enforcement config'), ('can_change_auto_mod_seeding_config', 'Can change Auto Mod No Seeding enforcement config'), ('can_view_auto_mod_solo_tank_config', 'Can view Auto Mod No Solo Tank enforcement config'), ('can_change_auto_mod_solo_tank_config', 'Can change Auto Mod No Solo Tank enforcement config'), ('can_view_tk_ban_on_connect_config', 'Can view team kill ban on connect config'), ('can_change_tk_ban_on_connect_config', 'Can change team kill ban on connect config'), ('can_view_expired_vip_config', 'Can view Expired VIP config'), ('can_change_expired_vip_config', 'Can change Expired VIP config'), ('can_view_server_name_change_config', 'Can view server name change (GSP credentials!) config'), ('can_change_server_name_change_config', 'Can change server name change (GSP credentials!) config'), ('can_view_log_line_discord_webhook_config', 'Can view log webhook (messages for log events) config'), ('can_change_log_line_discord_webhook_config', 'Can change log webhook (messages for log events) config'), ('can_view_name_kick_config', 'Can view kick players for names config'), ('can_change_name_kick_config', 'Can change kick players for names config'), ('can_view_rcon_connection_settings_config', 'Can view game server connection settings config'), ('can_change_rcon_connection_settings_config', 'Can change game server connection settings config'), ('can_view_rcon_server_settings_config', 'Can view general CRCON server settings'), ('can_change_rcon_server_settings_config', 'Can change general CRCON server settings'), ('can_view_scorebot_config', 'Can view scorebot config'), ('can_change_scorebot_config', 'Can change scorebot config'), ('can_view_standard_broadcast_messages', 'Can view shared broadcast messages'), ('can_change_standard_broadcast_messages', 'Can change shared broadcast messages'), ('can_view_standard_punishment_messages', 'Can view shared punishment messages'), ('can_change_standard_punishment_messages', 'Can change shared punishment messages'), ('can_view_standard_welcome_messages', 'Can view shared welcome messages'), ('can_change_standard_welcome_messages', 'Can change shared welcome messages'), ('can_view_steam_config', 'Can view steam API config'), ('can_change_steam_config', 'Can change steam API config'), ('can_view_vac_game_bans_config', 'Can view VAC/Gameban ban on connect config'), ('can_change_vac_game_bans_config', 'Can change VAC/Gameban ban on connect config'), ('can_view_admin_pings_discord_webhooks_config', 'Can view Discord admin ping config'), ('can_change_admin_pings_discord_webhooks_config', 'Can change Discord admin ping config'), ('can_view_audit_discord_webhooks_config', 'Can view Discord audit config'), ('can_change_audit_discord_webhooks_config', 'Can change Discord audit config'), ('can_view_camera_discord_webhooks_config', 'Can view Discord admin cam notification config'), ('can_change_camera_discord_webhooks_config', 'Can change Discord admin cam notification config'), ('can_view_chat_discord_webhooks_config', 'Can view Discord chat notification config'), ('can_change_chat_discord_webhooks_config', 'Can change Discord chat notification config'), ('can_view_kills_discord_webhooks_config', 'Can view Discord team/teamkill notification config'), ('can_change_kills_discord_webhooks_config', 'Can change Discord team/teamkill notification config'), ('can_view_watchlist_discord_webhooks_config', 'Can view Discord player watchlist notification config'), ('can_change_watchlist_discord_webhooks_config', 'Can change Discord player watchlist notification config'), ('can_restart_webserver', 'Can restart the webserver (Not a complete Docker restart)'), ('can_view_chat_commands_config', 'Can view the chat commands config'), ('can_change_chat_commands_config', 'Can change the chat commands config'), ('can_view_rcon_chat_commands_config', 'Can view the rcon chat commands config'), ('can_change_rcon_chat_commands_config', 'Can change rcon the chat commands config'), ('can_view_log_stream_config', 'Can view the Log Stream config'), ('can_change_log_stream_config', 'Can change the Log Stream config'), ('can_view_blacklists', 'Can view available blacklists'), ('can_add_blacklist_records', 'Can add players to blacklists'), ('can_change_blacklist_records', 'Can unblacklist players and edit blacklist records'), ('can_delete_blacklist_records', 'Can delete blacklist records'), ('can_create_blacklists', 'Can create blacklists'), ('can_change_blacklists', 'Can change blacklists'), ('can_delete_blacklists', 'Can delete blacklists'), ('can_change_game_layout', 'Can change game layout'), ('can_view_message_templates', 'Can view shared message templates'), ('can_add_message_templates', 'Can add new shared message templates'), ('can_delete_message_templates', 'Can delete shared message templates'), ('can_edit_message_templates', 'Can edit shared message templates'), ('can_view_seed_vip_config', 'Can view the Seed VIP config'), ('can_change_seed_vip_config', 'Can change the Seed VIP config'), ('can_view_webhook_queues', 'Can view information about the webhook service'), ('can_change_webhook_queues', 'Can remove messages from the webhook queue service'))}, + name="rconuser", + options={ + "default_permissions": (), + "permissions": ( + ( + "can_add_admin_roles", + "Can add HLL game server admin roles to players", + ), + ("can_add_map_to_rotation", "Can add a map to the rotation"), + ( + "can_add_map_to_whitelist", + "Can add a map to the votemap whitelist", + ), + ("can_add_maps_to_rotation", "Can add maps to the rotation"), + ( + "can_add_maps_to_whitelist", + "Can add multiple maps to the votemap whitelist", + ), + ( + "can_add_player_comments", + "Can add comments to a players profile", + ), + ("can_add_player_watch", "Can add a watch to players"), + ("can_add_vip", "Can add VIP status to players"), + ("can_ban_profanities", "Can ban profanities (censored game chat)"), + ( + "can_change_auto_broadcast_config", + "Can change the automated broadcast settings", + ), + ("can_change_auto_settings", "Can change auto settings"), + ( + "can_change_autobalance_enabled", + "Can enable/disable autobalance", + ), + ( + "can_change_autobalance_threshold", + "Can change the autobalance threshold", + ), + ( + "can_change_broadcast_message", + "Can change the broadcast message", + ), + ( + "can_change_camera_config", + "Can change camera notification settings", + ), + ("can_change_current_map", "Can change the current map"), + ( + "can_change_discord_webhooks", + "Can change configured webhooks on the settings page", + ), + ( + "can_change_idle_autokick_time", + "Can change the idle autokick time", + ), + ( + "can_change_max_ping_autokick", + "Can change the max ping autokick", + ), + ( + "can_change_profanities", + "Can add/remove profanities (censored game chat)", + ), + ("can_change_queue_length", "Can change the server queue size"), + ("can_change_real_vip_config", "Can change the real VIP settings"), + ("can_change_server_name", "Can change the server name"), + ( + "can_change_shared_standard_messages", + "Can change the shared standard messages", + ), + ( + "can_change_team_switch_cooldown", + "Can change the team switch cooldown", + ), + ( + "can_change_vip_slots", + "Can change the number of reserved VIP slots", + ), + ( + "can_change_votekick_autotoggle_config", + "Can change votekick settings", + ), + ("can_change_votekick_enabled", "Can enable/disable vote kicks"), + ( + "can_change_votekick_threshold", + "Can change vote kick thresholds", + ), + ("can_change_votemap_config", "Can change the votemap settings"), + ( + "can_change_welcome_message", + "Can change the welcome (rules) message", + ), + ("can_clear_crcon_cache", "Can clear the CRCON Redis cache"), + ("can_download_vip_list", "Can download the VIP list"), + ("can_flag_player", "Can add flags to players"), + ("can_kick_players", "Can kick players"), + ("can_message_players", "Can message players"), + ("can_perma_ban_players", "Can permanently ban players"), + ("can_punish_players", "Can punish players"), + ( + "can_remove_admin_roles", + "Can remove HLL game server admin roles from players", + ), + ("can_remove_all_vips", "Can remove all VIPs"), + ( + "can_remove_map_from_rotation", + "Can remove a map from the rotation", + ), + ( + "can_remove_map_from_whitelist", + "Can remove a map from the votemap whitelist", + ), + ( + "can_remove_maps_from_rotation", + "Can remove maps from the rotation", + ), + ( + "can_remove_maps_from_whitelist", + "Can remove multiple maps from the votemap whitelist", + ), + ("can_remove_perma_bans", "Can remove permanent bans from players"), + ("can_remove_player_watch", "Can remove a watch from players"), + ("can_remove_temp_bans", "Can remove temporary bans from players"), + ("can_remove_vip", "Can remove VIP status from players"), + ("can_reset_map_whitelist", "Can reset the votemap whitelist"), + ("can_reset_votekick_threshold", "Can reset votekick thresholds"), + ("can_reset_votemap_state", "Can reset votemap selection & votes"), + ( + "can_run_raw_commands", + "Can send raw commands to the HLL game server", + ), + ("can_set_map_whitelist", "Can set the votemap whitelist"), + ( + "can_switch_players_immediately", + "Can immediately switch players", + ), + ("can_switch_players_on_death", "Can switch players on death"), + ("can_temp_ban_players", "Can temporarily ban players"), + ( + "can_toggle_services", + "Can enable/disable services (automod, etc)", + ), + ( + "can_unban_profanities", + "Can unban profanities (censored game chat)", + ), + ("can_unflag_player", "Can remove flags from players"), + ("can_upload_vip_list", "Can upload a VIP list"), + ("can_view_admin_groups", "Can view available admin roles"), + ( + "can_view_admin_ids", + "Can view the name/steam IDs/role of everyone with a HLL game server admin role", + ), + ( + "can_view_admins", + "Can view users with HLL game server admin roles", + ), + ("can_view_all_maps", "Can view all possible maps"), + ( + "can_view_audit_logs", + "Can view the can_view_audit_logs endpoint", + ), + ( + "can_view_audit_logs_autocomplete", + "Can view the get_audit_logs_autocomplete endpoint", + ), + ( + "can_view_auto_broadcast_config", + "Can view the automated broadcast settings", + ), + ("can_view_auto_settings", "Can view auto settings"), + ( + "can_view_autobalance_enabled", + "Can view if autobalance is enabled", + ), + ( + "can_view_autobalance_threshold", + "Can view the autobalance threshold", + ), + ("can_view_available_services", "Can view services (automod, etc)"), + ( + "can_view_broadcast_message", + "Can view the current broadcast message", + ), + ("can_view_camera_config", "Can view camera notification settings"), + ("can_view_connection_info", "Can view CRCON's connection info"), + ("can_view_current_map", "Can view the currently playing map"), + ( + "can_view_date_scoreboard", + "Can view the date_scoreboard endpoint", + ), + ( + "can_view_detailed_player_info", + "Can view detailed player info (name, steam ID, loadout, squad, etc.)", + ), + ( + "can_view_discord_webhooks", + "Can view configured webhooks on the settings page", + ), + ( + "can_view_game_logs", + "Can view the get_logs endpoint (returns unparsed game logs)", + ), + ("can_view_gamestate", "Can view the current gamestate"), + ( + "can_view_get_players", + "Can view get_players endpoint (name, steam ID, VIP status and sessions) for all connected players", + ), + ( + "can_view_get_status", + "Can view the get_status endpoint (server name, current map, player count)", + ), + ("can_view_historical_logs", "Can view historical logs"), + ("can_view_idle_autokick_time", "Can view the idle autokick time"), + ( + "can_view_ingame_admins", + "Can view admins connected to the game server", + ), + ("can_view_map_rotation", "Can view the current map rotation"), + ("can_view_map_whitelist", "Can view the votemap whitelist"), + ("can_view_max_ping_autokick", "Can view the max autokick ping"), + ("can_view_next_map", "Can view the next map in the rotation"), + ("can_view_online_admins", "Can view admins connected to CRCON"), + ( + "can_view_online_console_admins", + "Can view the player name of all connected players with a HLL game server admin role", + ), + ( + "can_view_other_crcon_servers", + "Can view other servers hosted in the same CRCON (forward to all servers)", + ), + ("can_view_perma_bans", "Can view permanently banned players"), + ( + "can_view_player_bans", + "Can view all bans (temp/permanent) for a specific player", + ), + ( + "can_view_player_comments", + "Can view comments added to a players profile", + ), + ("can_view_player_history", "Can view History > Players"), + ( + "can_view_player_info", + "Can view the get_player_info endpoint (Name, steam ID, country and steam bans)", + ), + ("can_view_player_messages", "Can view messages sent to players"), + ( + "can_view_player_profile", + "View the detailed player profile page", + ), + ( + "can_view_player_slots", + "Can view the current/max players on the server", + ), + ( + "can_view_playerids", + "Can view the get_playerids endpoint (name and steam IDs of connected players)", + ), + ( + "can_view_players", + "Can view get_players endpoint for all connected players ", + ), + ( + "can_view_profanities", + "Can view profanities (censored game chat)", + ), + ( + "can_view_queue_length", + "Can view the maximum size of the server queue", + ), + ("can_view_real_vip_config", "Can view the real VIP settings"), + ("can_view_recent_logs", "Can view recent logs (Live view)"), + ( + "can_view_round_time_remaining", + "Can view the amount of time left in the round", + ), + ("can_view_server_name", "Can view the server name"), + ( + "can_view_shared_standard_messages", + "Can view the shared standard messages", + ), + ( + "can_view_structured_logs", + "Can view the get_structured_logs endpoint", + ), + ( + "can_view_team_objective_scores", + "Can view the number of objectives held by each team", + ), + ( + "can_view_team_switch_cooldown", + "Can view the team switch cooldown value", + ), + ( + "can_view_detailed_players", + "Can view get_detailed_players endpoint", + ), + ( + "can_view_team_view", + "Can view get_team_view endpoint (detailed player info by team for all connected players)", + ), + ("can_view_temp_bans", "Can view temporary banned players"), + ("can_view_vip_count", "Can view the number of connected VIPs"), + ( + "can_view_vip_ids", + "Can view all players with VIP and their expiration timestamps", + ), + ("can_view_vip_slots", "Can view the number of reserved VIP slots"), + ( + "can_view_votekick_autotoggle_config", + "Can view votekick settings", + ), + ("can_view_votekick_enabled", "Can view if vote kick is enabled"), + ( + "can_view_votekick_threshold", + "Can view the vote kick thresholds", + ), + ("can_view_votemap_config", "Can view the votemap settings"), + ( + "can_view_votemap_status", + "Can view the current votemap status (votes, results, etc)", + ), + ( + "can_view_current_map_sequence", + "Can view the current map shuffle sequence", + ), + ( + "can_view_map_shuffle_enabled", + "Can view if map shuffle is enabled", + ), + ( + "can_change_map_shuffle_enabled", + "Can enable/disable map shuffle", + ), + ("can_view_welcome_message", "Can view the server welcome message"), + ( + "can_view_auto_mod_level_config", + "Can view Auto Mod Level enforcement config", + ), + ( + "can_change_auto_mod_level_config", + "Can change Auto Mod Level enforcement config", + ), + ( + "can_view_auto_mod_no_leader_config", + "Can view Auto Mod No Leader enforcement config", + ), + ( + "can_change_auto_mod_no_leader_config", + "Can change Auto Mod No Leader enforcement config", + ), + ( + "can_view_auto_mod_seeding_config", + "Can view Auto Mod No Seeding enforcement config", + ), + ( + "can_change_auto_mod_seeding_config", + "Can change Auto Mod No Seeding enforcement config", + ), + ( + "can_view_auto_mod_solo_tank_config", + "Can view Auto Mod No Solo Tank enforcement config", + ), + ( + "can_change_auto_mod_solo_tank_config", + "Can change Auto Mod No Solo Tank enforcement config", + ), + ( + "can_view_tk_ban_on_connect_config", + "Can view team kill ban on connect config", + ), + ( + "can_change_tk_ban_on_connect_config", + "Can change team kill ban on connect config", + ), + ( + "can_view_server_name_change_config", + "Can view server name change (GSP credentials!) config", + ), + ( + "can_change_server_name_change_config", + "Can change server name change (GSP credentials!) config", + ), + ( + "can_view_log_line_discord_webhook_config", + "Can view log webhook (messages for log events) config", + ), + ( + "can_change_log_line_discord_webhook_config", + "Can change log webhook (messages for log events) config", + ), + ( + "can_view_name_kick_config", + "Can view kick players for names config", + ), + ( + "can_change_name_kick_config", + "Can change kick players for names config", + ), + ( + "can_view_rcon_connection_settings_config", + "Can view game server connection settings config", + ), + ( + "can_change_rcon_connection_settings_config", + "Can change game server connection settings config", + ), + ( + "can_view_rcon_server_settings_config", + "Can view general CRCON server settings", + ), + ( + "can_change_rcon_server_settings_config", + "Can change general CRCON server settings", + ), + ("can_view_scorebot_config", "Can view scorebot config"), + ("can_change_scorebot_config", "Can change scorebot config"), + ( + "can_view_standard_broadcast_messages", + "Can view shared broadcast messages", + ), + ( + "can_change_standard_broadcast_messages", + "Can change shared broadcast messages", + ), + ( + "can_view_standard_punishment_messages", + "Can view shared punishment messages", + ), + ( + "can_change_standard_punishment_messages", + "Can change shared punishment messages", + ), + ( + "can_view_standard_welcome_messages", + "Can view shared welcome messages", + ), + ( + "can_change_standard_welcome_messages", + "Can change shared welcome messages", + ), + ("can_view_steam_config", "Can view steam API config"), + ("can_change_steam_config", "Can change steam API config"), + ( + "can_view_vac_game_bans_config", + "Can view VAC/Gameban ban on connect config", + ), + ( + "can_change_vac_game_bans_config", + "Can change VAC/Gameban ban on connect config", + ), + ( + "can_view_admin_pings_discord_webhooks_config", + "Can view Discord admin ping config", + ), + ( + "can_change_admin_pings_discord_webhooks_config", + "Can change Discord admin ping config", + ), + ( + "can_view_audit_discord_webhooks_config", + "Can view Discord audit config", + ), + ( + "can_change_audit_discord_webhooks_config", + "Can change Discord audit config", + ), + ( + "can_view_camera_discord_webhooks_config", + "Can view Discord admin cam notification config", + ), + ( + "can_change_camera_discord_webhooks_config", + "Can change Discord admin cam notification config", + ), + ( + "can_view_chat_discord_webhooks_config", + "Can view Discord chat notification config", + ), + ( + "can_change_chat_discord_webhooks_config", + "Can change Discord chat notification config", + ), + ( + "can_view_kills_discord_webhooks_config", + "Can view Discord team/teamkill notification config", + ), + ( + "can_change_kills_discord_webhooks_config", + "Can change Discord team/teamkill notification config", + ), + ( + "can_view_watchlist_discord_webhooks_config", + "Can view Discord player watchlist notification config", + ), + ( + "can_change_watchlist_discord_webhooks_config", + "Can change Discord player watchlist notification config", + ), + ( + "can_restart_webserver", + "Can restart the webserver (Not a complete Docker restart)", + ), + ( + "can_view_chat_commands_config", + "Can view the chat commands config", + ), + ( + "can_change_chat_commands_config", + "Can change the chat commands config", + ), + ( + "can_view_rcon_chat_commands_config", + "Can view the rcon chat commands config", + ), + ( + "can_change_rcon_chat_commands_config", + "Can change rcon the chat commands config", + ), + ("can_view_log_stream_config", "Can view the Log Stream config"), + ( + "can_change_log_stream_config", + "Can change the Log Stream config", + ), + ("can_view_blacklists", "Can view available blacklists"), + ("can_add_blacklist_records", "Can add players to blacklists"), + ( + "can_change_blacklist_records", + "Can unblacklist players and edit blacklist records", + ), + ("can_delete_blacklist_records", "Can delete blacklist records"), + ("can_create_blacklists", "Can create blacklists"), + ("can_change_blacklists", "Can change blacklists"), + ("can_delete_blacklists", "Can delete blacklists"), + ("can_change_game_layout", "Can change game layout"), + ("can_view_message_templates", "Can view shared message templates"), + ( + "can_add_message_templates", + "Can add new shared message templates", + ), + ( + "can_delete_message_templates", + "Can delete shared message templates", + ), + ("can_edit_message_templates", "Can edit shared message templates"), + ("can_view_seed_vip_config", "Can view the Seed VIP config"), + ("can_change_seed_vip_config", "Can change the Seed VIP config"), + ("can_view_vip_lists", "Can view VIP lists and their records"), + ("can_create_vip_lists", "Can create VIP lists"), + ("can_change_vip_lists", "Can change VIP lists"), + ("can_delete_vip_lists", "Can delete VIP lists"), + ("can_add_vip_list_records", "Can add players to VIP lists"), + ( + "can_change_vip_list_records", + "Can revoke VIP and edit VIP lists records", + ), + ( + "can_delete_vip_lists_records", + "Can remove players from VIP lists", + ), + ), + }, ), - ] + ] \ No newline at end of file diff --git a/rconweb/api/models.py b/rconweb/api/models.py index 2a86f29b9..e6c805468 100644 --- a/rconweb/api/models.py +++ b/rconweb/api/models.py @@ -476,4 +476,14 @@ class Meta: "can_change_watch_killrate_config", "Can change the Watch KillRate config", ), + ("can_view_vip_lists", "Can view VIP lists and their records"), + ("can_create_vip_lists", "Can create VIP lists"), + ("can_change_vip_lists", "Can change VIP lists"), + ("can_delete_vip_lists", "Can delete VIP lists"), + ("can_add_vip_list_records", "Can add players to VIP lists"), + ( + "can_change_vip_list_records", + "Can revoke VIP and edit VIP lists records", + ), + ("can_delete_vip_lists_records", "Can remove players from VIP lists"), ) diff --git a/rconweb/api/urls.py b/rconweb/api/urls.py index 6d69ff0ed..9ed2cabad 100644 --- a/rconweb/api/urls.py +++ b/rconweb/api/urls.py @@ -15,7 +15,6 @@ services, user_settings, views, - vips, ) from .auth import api_response from .decorators import ENDPOINT_HTTP_METHODS, ENDPOINT_PERMISSIONS_LOOKUP @@ -94,9 +93,6 @@ def get_api_documentation(request): ("get_services", services.get_services), ("do_service", services.do_service), ("get_server_list", multi_servers.get_server_list), - ("upload_vips", vips.upload_vips), - ("upload_vips_result", vips.upload_vips_result), - ("download_vips", vips.download_vips), ("get_live_scoreboard", scoreboards.get_live_scoreboard), ("get_scoreboard_maps", scoreboards.get_scoreboard_maps), ("get_map_scoreboard", scoreboards.get_map_scoreboard), @@ -134,7 +130,6 @@ def get_api_documentation(request): "describe_camera_notification_config", user_settings.describe_camera_notification_config, ), - ("describe_expired_vip_config", user_settings.describe_expired_vip_config), ( "describe_server_name_change_config", user_settings.describe_server_name_change_config, diff --git a/rconweb/api/user_settings.py b/rconweb/api/user_settings.py index e1a88e6ae..8909e2b22 100644 --- a/rconweb/api/user_settings.py +++ b/rconweb/api/user_settings.py @@ -11,7 +11,6 @@ from rcon.user_config.ban_tk_on_connect import BanTeamKillOnConnectUserConfig from rcon.user_config.camera_notification import CameraNotificationUserConfig from rcon.user_config.chat_commands import ChatCommandsUserConfig -from rcon.user_config.expired_vips import ExpiredVipsUserConfig from rcon.user_config.gtx_server_name import GtxServerNameChangeUserConfig from rcon.user_config.log_line_webhooks import LogLineWebhookUserConfig from rcon.user_config.log_stream import LogStreamUserConfig @@ -164,19 +163,6 @@ def describe_camera_notification_config(request): ) -@csrf_exempt -@login_required() -@require_http_methods(["GET"]) -def describe_expired_vip_config(request): - command_name = "describe_expired_vip_config" - - return api_response( - result=ExpiredVipsUserConfig.model_json_schema(), - command=command_name, - failed=False, - ) - - @csrf_exempt @login_required() @require_http_methods(["GET"]) diff --git a/rconweb/api/views.py b/rconweb/api/views.py index a38245bcb..745457c1a 100644 --- a/rconweb/api/views.py +++ b/rconweb/api/views.py @@ -412,6 +412,8 @@ def run_raw_command(request): rcon_api.add_maps_to_rotation: "api.can_add_maps_to_rotation", rcon_api.add_maps_to_votemap_whitelist: "api.can_add_maps_to_whitelist", rcon_api.add_message_template: "api.can_add_message_templates", + rcon_api.bulk_add_vips: "api.can_add_vip", + rcon_api.bulk_remove_vips: "api.can_remove_vip", rcon_api.add_vip: "api.can_add_vip", rcon_api.ban_profanities: "api.can_ban_profanities", rcon_api.clear_cache: "api.can_clear_crcon_cache", @@ -469,7 +471,6 @@ def run_raw_command(request): rcon_api.get_map_sequence: "api.can_view_current_map_sequence", rcon_api.get_detailed_player_info: "api.can_view_detailed_player_info", rcon_api.get_detailed_players: "api.can_view_detailed_players", - rcon_api.get_expired_vip_config: "api.get_expired_vip_config", rcon_api.get_gamestate: "api.can_view_gamestate", rcon_api.get_historical_logs: "api.can_view_historical_logs", rcon_api.get_idle_autokick_time: "api.can_view_idle_autokick_time", @@ -562,7 +563,6 @@ def run_raw_command(request): rcon_api.set_chat_commands_config: "api.can_change_chat_commands_config", rcon_api.set_rcon_chat_commands_config: "api.can_change_rcon_chat_commands_config", rcon_api.set_chat_discord_webhooks_config: "api.can_change_chat_discord_webhooks_config", - rcon_api.set_expired_vip_config: "api.can_change_expired_vip_config", rcon_api.set_idle_autokick_time: "api.can_change_idle_autokick_time", rcon_api.set_kills_discord_webhooks_config: "api.can_change_kills_discord_webhooks_config", rcon_api.set_log_line_webhook_config: "api.can_change_log_line_discord_webhook_config", @@ -614,7 +614,6 @@ def run_raw_command(request): rcon_api.validate_chat_commands_config: "api.can_change_chat_commands_config", rcon_api.validate_rcon_chat_commands_config: "api.can_change_rcon_chat_commands_config", rcon_api.validate_chat_discord_webhooks_config: "api.can_change_chat_discord_webhooks_config", - rcon_api.validate_expired_vip_config: "api.can_change_expired_vip_config", rcon_api.validate_kills_discord_webhooks_config: "api.can_change_kills_discord_webhooks_config", rcon_api.validate_log_line_webhook_config: "api.can_change_log_line_discord_webhook_config", rcon_api.validate_name_kick_config: "api.can_change_name_kick_config", @@ -664,6 +663,35 @@ def run_raw_command(request): rcon_api.reset_webhook_queue: "api.can_change_webhook_queues", rcon_api.reset_webhook_queue_type: "api.can_change_webhook_queues", rcon_api.reset_webhook_message_type: "api.can_change_webhook_queues", + rcon_api.get_vip_lists: "api.can_view_vip_lists", + rcon_api.get_vip_lists_for_server: "api.can_view_vip_lists", + rcon_api.get_vip_list: "api.can_view_vip_lists", + rcon_api.create_vip_list: "api.can_create_vip_lists", + rcon_api.edit_vip_list: "api.can_change_vip_lists", + rcon_api.delete_vip_list: "api.can_delete_vip_lists", + rcon_api.get_vip_list_record: "api.can_view_vip_lists", + rcon_api.add_vip_list_record: "api.can_add_vip_list_records", + rcon_api.edit_vip_list_record: "api.can_change_vip_list_records", + rcon_api.add_or_edit_vip_list_record: { + "api.can_add_vip_list_records", + "api.can_change_vip_list_records", + }, + rcon_api.bulk_add_vip_list_records: "api.can_add_vip_list_records", + rcon_api.bulk_delete_vip_list_records: "api.can_change_vip_list_records", + rcon_api.bulk_edit_vip_list_records: "api.can_change_vip_list_records", + rcon_api.delete_vip_list_record: "api.can_delete_vip_lists_records", + rcon_api.get_vip_status_for_player_ids: "api.can_view_vip_lists", + rcon_api.get_active_vip_records: "api.can_view_vip_lists", + rcon_api.get_inactive_vip_records: "api.can_view_vip_lists", + rcon_api.get_player_vip_records: "api.can_view_vip_lists", + rcon_api.get_player_vip_list_record: "api.can_view_vip_lists", + rcon_api.get_vip_list_records: "api.can_view_vip_lists", + rcon_api.get_all_vip_records_for_server: "api.can_view_vip_lists", + rcon_api.inactivate_expired_vip_records: "api.can_change_vip_list_records", + rcon_api.extend_vip_duration: "api.can_change_vip_list_records", + rcon_api.revoke_all_vip: "api.can_change_vip_list_records", + rcon_api.synchronize_with_game_server: "api.can_change_vip_lists", + rcon_api.convert_old_style_vip_records: "api.can_change_vip_lists", } PREFIXES_TO_EXPOSE = [ @@ -729,7 +757,6 @@ def run_raw_command(request): rcon_api.get_map_sequence: ["GET"], rcon_api.get_detailed_player_info: ["GET"], rcon_api.get_detailed_players: ["GET"], - rcon_api.get_expired_vip_config: ["GET"], rcon_api.get_gamestate: ["GET"], rcon_api.get_objective_rows: ["GET"], rcon_api.get_historical_logs: ["GET", "POST"], @@ -829,7 +856,6 @@ def run_raw_command(request): rcon_api.set_chat_commands_config: ["POST"], rcon_api.set_rcon_chat_commands_config: ["POST"], rcon_api.set_chat_discord_webhooks_config: ["POST"], - rcon_api.set_expired_vip_config: ["POST"], rcon_api.set_game_layout: ["POST"], rcon_api.set_idle_autokick_time: ["POST"], rcon_api.set_kills_discord_webhooks_config: ["POST"], @@ -884,7 +910,6 @@ def run_raw_command(request): rcon_api.validate_chat_commands_config: ["POST"], rcon_api.validate_rcon_chat_commands_config: ["POST"], rcon_api.validate_chat_discord_webhooks_config: ["POST"], - rcon_api.validate_expired_vip_config: ["POST"], rcon_api.validate_kills_discord_webhooks_config: ["POST"], rcon_api.validate_log_line_webhook_config: ["POST"], rcon_api.validate_log_stream_config: ["POST"], @@ -925,6 +950,34 @@ def run_raw_command(request): rcon_api.reset_webhook_queue: ["POST"], rcon_api.reset_webhook_queue_type: ["POST"], rcon_api.reset_webhook_message_type: ["POST"], + rcon_api.get_vip_lists: ["GET"], + rcon_api.get_vip_lists_for_server: ["GET"], + rcon_api.get_vip_list: ["GET"], + rcon_api.create_vip_list: ["POST"], + rcon_api.edit_vip_list: ["POST"], + rcon_api.delete_vip_list: ["POST"], + rcon_api.get_vip_list_record: ["GET"], + rcon_api.add_vip_list_record: ["POST"], + rcon_api.edit_vip_list_record: ["POST"], + rcon_api.add_or_edit_vip_list_record: ["POST"], + rcon_api.bulk_add_vip_list_records: ["POST"], + rcon_api.bulk_delete_vip_list_records: ["POST"], + rcon_api.bulk_edit_vip_list_records: ["POST"], + rcon_api.delete_vip_list_record: ["POST"], + rcon_api.get_vip_status_for_player_ids: ["GET"], + rcon_api.get_active_vip_records: ["GET"], + rcon_api.get_inactive_vip_records: ["GET"], + rcon_api.get_player_vip_records: ["GET"], + rcon_api.get_player_vip_list_record: ["GET"], + rcon_api.get_vip_list_records: ["GET"], + rcon_api.get_all_vip_records_for_server: ["GET"], + rcon_api.inactivate_expired_vip_records: ["POST"], + rcon_api.extend_vip_duration: ["POST"], + rcon_api.revoke_all_vip: ["POST"], + rcon_api.synchronize_with_game_server: ["POST"], + rcon_api.convert_old_style_vip_records: ["POST"], + rcon_api.bulk_add_vips: ["POST"], + rcon_api.bulk_remove_vips: ["POST"], } # Check to make sure that ENDPOINT_HTTP_METHODS and ENDPOINT_PERMISSIONS have the same endpoints