From 65724f5c7fe3c0e31b4b25ca3948be2fd04853be Mon Sep 17 00:00:00 2001 From: BeebBeebBoob Date: Mon, 20 Jan 2025 00:39:15 +0300 Subject: [PATCH] admin: Poll voting (#6361) * add: Poll voting * bundle update * Update code/controllers/subsystem/non-firing/titlescreen.dm Co-authored-by: PlayerUnknown14 <139672964+PlayerUnknown14@users.noreply.github.com> * ngggh I'll do later. Fixes polling mostly and TGUI. but has SQL issues Column 'createdby_ip' cannot be null INSERT INTO poll_question { ERROR 1048 (23000): Column 'pollid' cannot be null * Final fixes and updates * SQL OOPS ALL MY VERSIONS TO 34 --------- Co-authored-by: PlayerUnknown14 <139672964+PlayerUnknown14@users.noreply.github.com> --- SQL/paradise_schema.sql | 93 ++++ SQL/updates/33-34.sql | 93 ++++ code/__DEFINES/misc.dm | 2 +- code/__DEFINES/polls.dm | 25 + code/_globalvars/misc.dm | 11 + code/controllers/subsystem/dbcore.dm | 67 +++ .../subsystem/non-firing/titlescreen.dm | 1 + code/game/world.dm | 1 + code/modules/admin/admin_verbs.dm | 2 + code/modules/admin/polls/player_poll.dm | 436 ++++++++++++++++++ code/modules/admin/polls/poll_db.dm | 205 ++++++++ code/modules/admin/polls/poll_list_panel.dm | 171 +++++++ .../admin/polls/poll_management_panel.dm | 138 ++++++ code/modules/admin/polls/poll_option.dm | 101 ++++ code/modules/admin/polls/poll_option_panel.dm | 74 +++ code/modules/admin/polls/poll_question.dm | 195 ++++++++ code/modules/admin/topic.dm | 6 +- code/modules/admin/verbs/debug.dm | 19 + code/modules/mob/new_player/new_player.dm | 11 + config/example/dbconfig.txt | 2 +- paradise.dme | 8 + .../packages/tgui/interfaces/PollListPanel.js | 50 ++ .../tgui/interfaces/PollManagement.js | 371 +++++++++++++++ .../tgui/interfaces/PollOptionPanel.js | 166 +++++++ tgui/public/tgui.bundle.js | 170 +++---- tools/ci/dbconfig.txt | 2 +- 26 files changed, 2331 insertions(+), 89 deletions(-) create mode 100644 SQL/updates/33-34.sql create mode 100644 code/__DEFINES/polls.dm create mode 100644 code/modules/admin/polls/player_poll.dm create mode 100644 code/modules/admin/polls/poll_db.dm create mode 100644 code/modules/admin/polls/poll_list_panel.dm create mode 100644 code/modules/admin/polls/poll_management_panel.dm create mode 100644 code/modules/admin/polls/poll_option.dm create mode 100644 code/modules/admin/polls/poll_option_panel.dm create mode 100644 code/modules/admin/polls/poll_question.dm create mode 100644 tgui/packages/tgui/interfaces/PollListPanel.js create mode 100644 tgui/packages/tgui/interfaces/PollManagement.js create mode 100644 tgui/packages/tgui/interfaces/PollOptionPanel.js diff --git a/SQL/paradise_schema.sql b/SQL/paradise_schema.sql index e883090be14..10d9514eabf 100644 --- a/SQL/paradise_schema.sql +++ b/SQL/paradise_schema.sql @@ -652,3 +652,96 @@ CREATE TABLE `budget` PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `poll_question` +-- +DROP TABLE IF EXISTS `poll_question`; +CREATE TABLE IF NOT EXISTS `poll_question` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `polltype` enum('Single Option','Text Reply','Rating','Multiple Choice') NOT NULL, + `created_datetime` datetime NOT NULL, + `starttime` datetime NOT NULL, + `endtime` datetime NOT NULL, + `question` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `subtitle` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `adminonly` tinyint(1) unsigned NOT NULL, + `multiplechoiceoptions` int(2) DEFAULT NULL, + `createdby_ckey` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `dontshow` tinyint(1) unsigned NOT NULL, + `minimum_playtime` int(4) NOT NULL, + `allow_revoting` tinyint(1) unsigned NOT NULL, + `deleted` tinyint(1) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `idx_pquest_question_time_ckey` (`question`,`starttime`,`endtime`,`createdby_ckey`), + KEY `idx_pquest_time_deleted_id` (`starttime`,`endtime`, `deleted`, `id`), + KEY `idx_pquest_id_time_type_admin` (`id`,`starttime`,`endtime`,`polltype`,`adminonly`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- +-- Table structure for table `poll_option` +-- +DROP TABLE IF EXISTS `poll_option`; +CREATE TABLE IF NOT EXISTS `poll_option` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `pollid` int(11) NOT NULL, + `text` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `minval` int(3) DEFAULT NULL, + `maxval` int(3) DEFAULT NULL, + `descmin` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `descmid` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `descmax` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `default_percentage_calc` tinyint(1) unsigned NOT NULL DEFAULT '1', + `deleted` tinyint(1) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `idx_pop_pollid` (`pollid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- +-- Table structure for table `poll_textreply` +-- +DROP TABLE IF EXISTS `poll_textreply`; +CREATE TABLE IF NOT EXISTS `poll_textreply` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `datetime` datetime NOT NULL, + `pollid` int(11) NOT NULL, + `ckey` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `replytext` varchar(2048) COLLATE utf8mb4_unicode_ci NOT NULL, + `adminrank` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `deleted` tinyint(1) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `idx_ptext_pollid_ckey` (`pollid`,`ckey`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- +-- Table structure for table `poll_vote` +-- +DROP TABLE IF EXISTS `poll_vote`; +CREATE TABLE IF NOT EXISTS `poll_vote` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `datetime` datetime NOT NULL, + `pollid` int(11) NOT NULL, + `optionid` int(11) NOT NULL, + `ckey` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `adminrank` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `rating` int(2) DEFAULT NULL, + `deleted` tinyint(1) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `idx_pvote_pollid_ckey` (`pollid`,`ckey`), + KEY `idx_pvote_optionid_ckey` (`optionid`,`ckey`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DELIMITER $$ +DROP PROCEDURE IF EXISTS `set_poll_deleted`; +CREATE PROCEDURE `set_poll_deleted`( + IN `poll_id` INT +) +SQL SECURITY INVOKER +BEGIN +UPDATE `poll_question` SET deleted = 1 WHERE id = poll_id; +UPDATE `poll_option` SET deleted = 1 WHERE pollid = poll_id; +UPDATE `poll_vote` SET deleted = 1 WHERE pollid = poll_id; +UPDATE `poll_textreply` SET deleted = 1 WHERE pollid = poll_id; +END +$$ +DELIMITER ; diff --git a/SQL/updates/33-34.sql b/SQL/updates/33-34.sql new file mode 100644 index 00000000000..596c1c7902a --- /dev/null +++ b/SQL/updates/33-34.sql @@ -0,0 +1,93 @@ +# Adds poll related. Poll question as body, options inside body, textreply and vote as player answers +-- +-- Table structure for table `poll_question` +-- +DROP TABLE IF EXISTS `poll_question`; +CREATE TABLE IF NOT EXISTS `poll_question` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `polltype` enum('Single Option','Text Reply','Rating','Multiple Choice') NOT NULL, + `created_datetime` datetime NOT NULL, + `starttime` datetime NOT NULL, + `endtime` datetime NOT NULL, + `question` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `subtitle` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `adminonly` tinyint(1) unsigned NOT NULL, + `multiplechoiceoptions` int(2) DEFAULT NULL, + `createdby_ckey` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `dontshow` tinyint(1) unsigned NOT NULL, + `minimum_playtime` int(4) NOT NULL, + `allow_revoting` tinyint(1) unsigned NOT NULL, + `deleted` tinyint(1) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `idx_pquest_question_time_ckey` (`question`,`starttime`,`endtime`,`createdby_ckey`), + KEY `idx_pquest_time_deleted_id` (`starttime`,`endtime`, `deleted`, `id`), + KEY `idx_pquest_id_time_type_admin` (`id`,`starttime`,`endtime`,`polltype`,`adminonly`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- +-- Table structure for table `poll_option` +-- +DROP TABLE IF EXISTS `poll_option`; +CREATE TABLE IF NOT EXISTS `poll_option` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `pollid` int(11) NOT NULL, + `text` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `minval` int(3) DEFAULT NULL, + `maxval` int(3) DEFAULT NULL, + `descmin` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `descmid` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `descmax` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `default_percentage_calc` tinyint(1) unsigned NOT NULL DEFAULT '1', + `deleted` tinyint(1) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `idx_pop_pollid` (`pollid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- +-- Table structure for table `poll_textreply` +-- +DROP TABLE IF EXISTS `poll_textreply`; +CREATE TABLE IF NOT EXISTS `poll_textreply` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `datetime` datetime NOT NULL, + `pollid` int(11) NOT NULL, + `ckey` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `replytext` varchar(2048) COLLATE utf8mb4_unicode_ci NOT NULL, + `adminrank` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `deleted` tinyint(1) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `idx_ptext_pollid_ckey` (`pollid`,`ckey`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- +-- Table structure for table `poll_vote` +-- +DROP TABLE IF EXISTS `poll_vote`; +CREATE TABLE IF NOT EXISTS `poll_vote` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `datetime` datetime NOT NULL, + `pollid` int(11) NOT NULL, + `optionid` int(11) NOT NULL, + `ckey` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `adminrank` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `rating` int(2) DEFAULT NULL, + `deleted` tinyint(1) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `idx_pvote_pollid_ckey` (`pollid`,`ckey`), + KEY `idx_pvote_optionid_ckey` (`optionid`,`ckey`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DELIMITER $$ +DROP PROCEDURE IF EXISTS `set_poll_deleted`; +CREATE PROCEDURE `set_poll_deleted`( + IN `poll_id` INT +) +SQL SECURITY INVOKER +BEGIN +UPDATE `poll_question` SET deleted = 1 WHERE id = poll_id; +UPDATE `poll_option` SET deleted = 1 WHERE pollid = poll_id; +UPDATE `poll_vote` SET deleted = 1 WHERE pollid = poll_id; +UPDATE `poll_textreply` SET deleted = 1 WHERE pollid = poll_id; +END +$$ +DELIMITER ; diff --git a/code/__DEFINES/misc.dm b/code/__DEFINES/misc.dm index e8aba89f51a..c62dc58a4f5 100644 --- a/code/__DEFINES/misc.dm +++ b/code/__DEFINES/misc.dm @@ -363,7 +363,7 @@ #define EXPLOSION_BLOCK_PROC -1 // The SQL version required by this version of the code -#define SQL_VERSION 33 +#define SQL_VERSION 34 // Vending machine stuff #define CAT_NORMAL 1 diff --git a/code/__DEFINES/polls.dm b/code/__DEFINES/polls.dm new file mode 100644 index 00000000000..e2484052b9b --- /dev/null +++ b/code/__DEFINES/polls.dm @@ -0,0 +1,25 @@ + +//unmagic-strings for types of polls, used by SQL don't change these +#define POLLTYPE_OPTION "Single Option" +#define POLLTYPE_TEXT "Text Reply" +#define POLLTYPE_RATING "Rating" +#define POLLTYPE_MULTI "Multiple Choice" + +#define POLL_SECOND "SECOND" +#define POLL_MINUTE "MINUTE" +#define POLL_HOUR "HOUR" +#define POLL_DAY "DAY" +#define POLL_WEEK "WEEK" +#define POLL_MONTH "MONTH" +#define POLL_YEAR "YEAR" + +///The message sent when you sign up to a poll. +#define POLL_RESPONSE_SIGNUP "signup" +///The message sent when you've already signed up for a poll and are trying to sign up again. +#define POLL_RESPONSE_ALREADY_SIGNED "already_signed" +///The message sent when you are not signed up for a poll. +#define POLL_RESPONSE_NOT_SIGNED "not_signed" +///The message sent when you are too late to unregister from a poll. +#define POLL_RESPONSE_TOO_LATE_TO_UNREGISTER "failed_unregister" +///The message sent when you successfully unregister from a poll. +#define POLL_RESPONSE_UNREGISTERED "unregistered" diff --git a/code/_globalvars/misc.dm b/code/_globalvars/misc.dm index 2c72c497585..71115cf9fc0 100644 --- a/code/_globalvars/misc.dm +++ b/code/_globalvars/misc.dm @@ -63,3 +63,14 @@ GLOBAL_VAR_INIT(pacifism_after_gt, FALSE) GLOBAL_VAR_INIT(communications_blackout, FALSE) +///All currently running polls held as datums +GLOBAL_LIST_EMPTY(polls) +GLOBAL_PROTECT(polls) +///Active polls +GLOBAL_LIST_EMPTY(active_polls) +GLOBAL_PROTECT(active_polls) + + +///All poll option datums of running polls +GLOBAL_LIST_EMPTY(poll_options) +GLOBAL_PROTECT(poll_options) diff --git a/code/controllers/subsystem/dbcore.dm b/code/controllers/subsystem/dbcore.dm index 5bf315823fe..e3c2da658a5 100644 --- a/code/controllers/subsystem/dbcore.dm +++ b/code/controllers/subsystem/dbcore.dm @@ -258,6 +258,73 @@ SUBSYSTEM_DEF(dbcore) return FALSE return new /datum/db_query(connection, sql_query, arguments) +/* + * Takes a list of rows (each row being an associated list of column => value) and inserts them via a single mass query. + * Rows missing columns present in other rows will resolve to SQL NULL + * You are expected to do your own escaping of the data, and expected to provide your own quotes for strings. + * The duplicate_key arg can be true to automatically generate this part of the query + * or set to a string that is appended to the end of the query + * Ignore_errors instructes mysql to continue inserting rows if some of them have errors. + * the erroneous row(s) aren't inserted and there isn't really any way to know why or why errored +*/ +/datum/controller/subsystem/dbcore/proc/MassInsert(table, list/rows, duplicate_key = FALSE, ignore_errors = FALSE, warn = FALSE, async = TRUE, special_columns = null) + if (!table || !rows || !istype(rows)) + return + + // Prepare column list + var/list/columns = list() + var/list/has_question_mark = list() + for (var/list/row in rows) + for (var/column in row) + columns[column] = "?" + has_question_mark[column] = TRUE + for (var/column in special_columns) + columns[column] = special_columns[column] + has_question_mark[column] = findtext(special_columns[column], "?") + + // Prepare SQL query full of placeholders + var/list/query_parts = list("INSERT") + if (ignore_errors) + query_parts += " IGNORE" + query_parts += " INTO " + query_parts += table + query_parts += "\n([columns.Join(", ")])\nVALUES" + + var/list/arguments = list() + var/has_row = FALSE + for (var/list/row in rows) + if (has_row) + query_parts += "," + query_parts += "\n (" + var/has_col = FALSE + for (var/column in columns) + if (has_col) + query_parts += ", " + if (has_question_mark[column]) + var/name = "p[arguments.len]" + query_parts += replacetext(columns[column], "?", ":[name]") + arguments[name] = row[column] + else + query_parts += columns[column] + has_col = TRUE + query_parts += ")" + has_row = TRUE + + if (duplicate_key == TRUE) + var/list/column_list = list() + for (var/column in columns) + column_list += "[column] = VALUES([column])" + query_parts += "\nON DUPLICATE KEY UPDATE [column_list.Join(", ")]" + else if (duplicate_key != FALSE) + query_parts += duplicate_key + + var/datum/db_query/Query = NewQuery(query_parts.Join(), arguments) + if (warn) + . = Query.warn_execute(async) + else + . = Query.Execute(async) + qdel(Query) + /** * Handler to allow many queries to be executed en masse * diff --git a/code/controllers/subsystem/non-firing/titlescreen.dm b/code/controllers/subsystem/non-firing/titlescreen.dm index bd03d67f478..9583d3993a6 100644 --- a/code/controllers/subsystem/non-firing/titlescreen.dm +++ b/code/controllers/subsystem/non-firing/titlescreen.dm @@ -278,6 +278,7 @@ SUBSYSTEM_DEF(title) Настройки игры
Настройки громкости + Открыть голосование "} // html += "Сменить сервер" // TODO: add this after regis merge if(!viewer.prefs.discord_id || (viewer.prefs.discord_id && length(viewer.prefs.discord_id) == 32)) diff --git a/code/game/world.dm b/code/game/world.dm index cdef55bc6d1..5bf9bb2ed5f 100644 --- a/code/game/world.dm +++ b/code/game/world.dm @@ -32,6 +32,7 @@ GLOBAL_DATUM(test_runner, /datum/test_runner) // Right off the bat, load up the DB SSdbcore.CheckSchemaVersion() // This doesnt just check the schema version, it also connects to the db! This needs to happen super early! I cannot stress this enough! SSdbcore.SetRoundID() // Set the round ID here + load_poll_data() // Setup all log paths and stamp them with startups, including round IDs SetupLogs() diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 8111891139d..33e7bbaa412 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -129,6 +129,7 @@ GLOBAL_LIST_INIT(admin_verbs_server, list( /datum/admins/proc/toggleaban, /datum/admins/proc/toggleenter, /*toggles whether people can join the current game*/ /datum/admins/proc/toggleguests, /*toggles whether guests can join the current game*/ + /datum/admins/proc/open_poll_list, /client/proc/select_next_map, /client/proc/toggle_log_hrefs, /client/proc/toggle_twitch_censor, @@ -165,6 +166,7 @@ GLOBAL_LIST_INIT(admin_verbs_debug, list( /client/proc/admin_serialize, /client/proc/jump_to_ruin, /client/proc/clear_dynamic_transit, + /client/proc/cmd_reload_polls, /client/proc/toggle_medal_disable, /client/proc/uid_log, /client/proc/visualise_active_turfs, diff --git a/code/modules/admin/polls/player_poll.dm b/code/modules/admin/polls/player_poll.dm new file mode 100644 index 00000000000..4e39d2a0e4f --- /dev/null +++ b/code/modules/admin/polls/player_poll.dm @@ -0,0 +1,436 @@ +/** + * Shows a list of currently running polls a player can vote/has voted on + * + */ +/mob/new_player/proc/handle_player_polling() + var/list/output = {""} + output += "
Player polls
" + var/rs = UID() + for(var/p in GLOB.active_polls) + var/datum/poll_question/poll = p + if((poll.admin_only && !client.holder) || poll.future_poll) + continue + output += "" + output += "
[poll.question]
" + src << browse(output,"window=playerpolllist;size=500x300") // I'll do TGUI later -Beeb + +/** + * Redirects a player to the correct poll window based on poll type. + * + */ +/mob/new_player/proc/poll_player(datum/poll_question/poll) + if(!poll) + return + if(!SSdbcore.Connect()) + to_chat(src, span_danger("Failed to establish database connection.")) + return + switch(poll.poll_type) + if(POLLTYPE_OPTION) + poll_player_option(poll) + if(POLLTYPE_TEXT) + poll_player_text(poll) + if(POLLTYPE_RATING) + poll_player_rating(poll) + if(POLLTYPE_MULTI) + poll_player_multi(poll) + +/** + * Shows voting window for an option type poll, listing its options and relevant details. + * + * If already voted on, the option a player voted for is pre-selected. + * + */ +/mob/new_player/proc/poll_player_option(datum/poll_question/poll) + var/datum/db_query/query_option_get_voted = SSdbcore.NewQuery({" + SELECT optionid FROM [format_table_name("poll_vote")] + WHERE pollid = :pollid AND ckey = :ckey AND deleted = 0 + "}, list("pollid" = poll.poll_id, "ckey" = ckey)) + if(!query_option_get_voted.warn_execute()) + qdel(query_option_get_voted) + return + var/voted_option_id = 0 + if(query_option_get_voted.NextRow()) + voted_option_id = text2num(query_option_get_voted.item[1]) + qdel(query_option_get_voted) + var/list/output = {"
Player poll
Question: [poll.question]
"} + if(poll.subtitle) + output += "[poll.subtitle]
" + output += "Poll runs from [poll.start_datetime] until [poll.end_datetime]
" + if(poll.allow_revoting) + output += "Revoting is enabled." + if(!voted_option_id || poll.allow_revoting) + output += {"
+ + + "} + output += "
" + for(var/o in poll.options) + var/datum/poll_option/option = o + output += "