aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Leon Sandøy <[email protected]>2018-07-28 20:17:54 +0200
committerGravatar Leon Sandøy <[email protected]>2018-07-28 20:17:54 +0200
commitb4d024dc69e9084184f628daeb48d6601a3996e2 (patch)
tree06e5ca6f40dc57b0b90a12b2a20592e1b748ccf9
parentNow doing role colorization instead of random colors for the cleanlog template. (diff)
parentFix typo in infractions table definition (diff)
Merge branch 'master' into clean_command
-rw-r--r--pysite/decorators.py4
-rw-r--r--pysite/tables.py15
-rw-r--r--pysite/views/api/bot/infractions.py572
-rw-r--r--pysite/views/api/bot/user.py37
-rw-r--r--templates/main/about/privacy.html77
-rw-r--r--tests/test_api_bot_infractions.py134
-rw-r--r--tests/test_api_bot_users.py6
7 files changed, 810 insertions, 35 deletions
diff --git a/pysite/decorators.py b/pysite/decorators.py
index de914c6f..fbfb90f8 100644
--- a/pysite/decorators.py
+++ b/pysite/decorators.py
@@ -141,8 +141,8 @@ def api_params(
try:
schema.validate(data)
- except SchemaError:
- return self.error(ErrorCodes.incorrect_parameters)
+ except SchemaError as e:
+ return self.error(ErrorCodes.incorrect_parameters, str(e))
return f(self, data, *args, **kwargs)
diff --git a/pysite/tables.py b/pysite/tables.py
index adc8c409..65a4db16 100644
--- a/pysite/tables.py
+++ b/pysite/tables.py
@@ -267,6 +267,21 @@ TABLES = {
])
),
+ "bot_infractions": Table(
+ primary_key="id",
+ keys=sorted([
+ "id", # str
+ "user_id", # str
+ "actor_id", # str
+ "reason", # str
+ "type", # str
+ "inserted_at", # datetime
+ "expires_at", # datetime
+ "closed", # bool
+ "legacy_rowboat_id" # str
+ ])
+ ),
+
"watched_users": Table( # Users being monitored by the bot's BigBrother cog
primary_key="user_id",
keys=sorted([
diff --git a/pysite/views/api/bot/infractions.py b/pysite/views/api/bot/infractions.py
new file mode 100644
index 00000000..eee40b82
--- /dev/null
+++ b/pysite/views/api/bot/infractions.py
@@ -0,0 +1,572 @@
+"""
+INFRACTIONS API
+
+"GET" endpoints in this API may take the following optional parameters, depending on the endpoint:
+ - active: filters infractions that are active (true), expired (false), or either (not present/any)
+ - expand: expands the result data with the information about the users (slower)
+ - dangling: filters infractions that are active, or inactive infractions that have not been closed manually.
+ - search: filters the "reason" field to match the given RE2 query.
+
+Infraction Schema:
+ This schema is used when an infraction's data is returned.
+
+ Root object:
+ "id" (str): the UUID of the infraction.
+ "inserted_at" (str): the date and time of the creation of this infraction (RFC1123 format).
+ "expires_at" (str): the date and time of the expiration of this infraction (RC1123 format), may be null.
+ The significance of this field being null depends on the type of infraction. Duration-based infractions
+ have a "null" expiration if they are permanent. Other infraction types do not have expirations.
+ "active" (bool): whether the infraction is still active. Note that the check for expiration of
+ duration-based infractions is done by the API, so you should check for expiration using this "active" field.
+ "user" (object): the user to which the infraction was applied.
+ "user_id" (str): the Discord ID of the user.
+ "username" (optional str): the username of the user. This field is only present if the query was expanded.
+ "discriminator" (optional int): the username discriminator of the user. This field is only present if the
+ query was expanded.
+ "avatar" (optional str): the avatar URL of the user. This field is only present if the query was expanded.
+ "actor" (object): the user which applied the infraction.
+ This object uses the same schema as the "user" field.
+ "type" (str): the type of the infraction.
+ "reason" (str): the reason for the infraction.
+
+
+Endpoints:
+
+ GET /bot/infractions
+ Gets a list of all infractions, regardless of type or user.
+ Parameters: "active", "expand", "dangling", "search".
+ This endpoint returns an array of infraction objects.
+
+ GET /bot/infractions/user/<user_id>
+ Gets a list of all infractions for a user.
+ Parameters: "active", "expand", "search".
+ This endpoint returns an array of infraction objects.
+
+ GET /bot/infractions/type/<type>
+ Gets a list of all infractions of the given type (ban, mute, etc.)
+ Parameters: "active", "expand", "search".
+ This endpoint returns an array of infraction objects.
+
+ GET /bot/infractions/user/<user_id>/<type>
+ Gets a list of all infractions of the given type for a user.
+ Parameters: "active", "expand", "search".
+ This endpoint returns an array of infraction objects.
+
+ GET /bot/infractions/user/<user_id>/<type>/current
+ Gets the active infraction (if any) of the given type for a user.
+ Parameters: "expand".
+ This endpoint returns an object with the "infraction" key, which is either set to null (no infraction)
+ or the query's corresponding infraction. It will not return an infraction if the type of the infraction
+ isn't duration-based (e.g. kick, warning, etc.)
+
+ GET /bot/infractions/id/<infraction_id>
+ Gets the infraction (if any) for the given ID.
+ Parameters: "expand".
+ This endpoint returns an object with the "infraction" key, which is either set to null (no infraction)
+ or the infraction corresponding to the ID.
+
+ POST /bot/infractions
+ Creates an infraction for a user.
+ Parameters (JSON payload):
+ "type" (str): the type of the infraction (must be a valid infraction type).
+ "reason" (str): the reason of the infraction.
+ "user_id" (str): the Discord ID of the user who is being given the infraction.
+ "actor_id" (str): the Discord ID of the user who submitted the infraction.
+ "duration" (optional str): the duration of the infraction. This is ignored for infractions
+ which are not duration-based. For other infraction types, omitting this field may imply permanence.
+ "expand" (optional bool): whether to expand the infraction user data once the infraction is inserted and returned.
+
+ PATCH /bot/infractions
+ Updates an infractions.
+ Parameters (JSON payload):
+ "id" (str): the ID of the infraction to update.
+ "reason" (optional str): if provided, the new reason for the infraction.
+ "duration" (optional str): if provided, updates the expiration of the infraction to the time of UPDATING
+ plus the duration. If set to null, the expiration is also set to null (may imply permanence).
+ "active" (optional bool): if provided, activates or deactivates the infraction. This does not do anything
+ if the infraction isn't duration-based, or if the infraction has already expired. This marks the infraction
+ as closed.
+ "expand" (optional bool): whether to expand the infraction user data once the infraction is updated and returned.
+"""
+
+import datetime
+from typing import NamedTuple
+
+import rethinkdb
+from flask import jsonify
+from schema import Optional, Or, Schema
+
+from pysite.base_route import APIView
+from pysite.constants import ErrorCodes, ValidationTypes
+from pysite.decorators import api_key, api_params
+from pysite.mixins import DBMixin
+from pysite.utils.time import parse_duration
+
+
+class InfractionType(NamedTuple):
+ timed_infraction: bool # whether the infraction is active until it expires.
+
+
+RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
+EXCLUDED_FIELDS = "user_id", "actor_id", "closed", "_timed"
+INFRACTION_ORDER = rethinkdb.desc("active"), rethinkdb.desc("inserted_at")
+
+INFRACTION_TYPES = {
+ "warning": InfractionType(timed_infraction=False),
+ "mute": InfractionType(timed_infraction=True),
+ "ban": InfractionType(timed_infraction=True),
+ "kick": InfractionType(timed_infraction=False),
+ "superstar": InfractionType(timed_infraction=True) # hiphopify
+}
+
+GET_SCHEMA = Schema({
+ Optional("active"): str,
+ Optional("expand"): str,
+ Optional("dangling"): str,
+ Optional("search"): str
+})
+
+GET_ACTIVE_SCHEMA = Schema({
+ Optional("expand"): str
+})
+
+CREATE_INFRACTION_SCHEMA = Schema({
+ "type": lambda tp: tp in INFRACTION_TYPES,
+ "reason": Or(str, None),
+ "user_id": str, # Discord user ID
+ "actor_id": str, # Discord user ID
+ Optional("duration"): str, # If not provided, may imply permanence depending on the infraction
+ Optional("expand"): bool
+})
+
+UPDATE_INFRACTION_SCHEMA = Schema({
+ "id": str,
+ Optional("reason"): Or(str, None),
+ Optional("duration"): Or(str, None),
+ Optional("active"): bool
+})
+
+IMPORT_INFRACTIONS_SCHEMA = Schema([
+ {
+ "id": str,
+ "active": bool,
+ "actor": {
+ "id": str
+ },
+ "created_at": str,
+ "expires_at": Or(str, None),
+ "reason": Or(str, None),
+ "type": {
+ "name": str
+ },
+ "user": {
+ "id": str
+ }
+ }
+], ignore_extra_keys=True)
+
+
+class InfractionsView(APIView, DBMixin):
+ path = "/bot/infractions"
+ name = "bot.infractions"
+ table_name = "bot_infractions"
+
+ @api_key
+ @api_params(schema=GET_SCHEMA, validation_type=ValidationTypes.params)
+ def get(self, params: dict = None):
+ if "dangling" in params:
+ return _infraction_list_filtered(self, params, {"_timed": True, "closed": False})
+ else:
+ return _infraction_list_filtered(self, params, {})
+
+ @api_key
+ @api_params(schema=CREATE_INFRACTION_SCHEMA, validation_type=ValidationTypes.json)
+ def post(self, data):
+ deactivate_infraction_query = None
+
+ infraction_type = data["type"]
+ user_id = data["user_id"]
+ actor_id = data["actor_id"]
+ reason = data["reason"]
+ duration_str = data.get("duration")
+ expand = data.get("expand")
+ expires_at = None
+ inserted_at = datetime.datetime.now(tz=datetime.timezone.utc)
+
+ if infraction_type not in INFRACTION_TYPES:
+ return self.error(ErrorCodes.incorrect_parameters, "Invalid infraction type.")
+
+ # check if the user already has an active infraction of this type
+ # if so, we need to disable that infraction and create a new infraction
+ if INFRACTION_TYPES[infraction_type].timed_infraction:
+ active_infraction_query = \
+ self.db.query(self.table_name).merge(_merge_active_check()) \
+ .filter({"user_id": user_id, "type": infraction_type, "active": True}) \
+ .limit(1).nth(0).default(None)
+
+ active_infraction = self.db.run(active_infraction_query)
+ if active_infraction:
+ deactivate_infraction_query = \
+ self.db.query(self.table_name) \
+ .get(active_infraction["id"]) \
+ .update({"active": False, "closed": True})
+
+ if duration_str:
+ try:
+ expires_at = parse_duration(duration_str)
+ except ValueError:
+ return self.error(
+ ErrorCodes.incorrect_parameters,
+ "Invalid duration format."
+ )
+
+ infraction_insert_doc = {
+ "actor_id": actor_id,
+ "user_id": user_id,
+ "type": infraction_type,
+ "reason": reason,
+ "inserted_at": inserted_at,
+ "expires_at": expires_at
+ }
+
+ infraction_id = self.db.insert(self.table_name, infraction_insert_doc)["generated_keys"][0]
+
+ if deactivate_infraction_query:
+ self.db.run(deactivate_infraction_query)
+
+ query = self.db.query(self.table_name).get(infraction_id) \
+ .merge(_merge_expand_users(self, expand)) \
+ .merge(_merge_active_check()) \
+ .without(*EXCLUDED_FIELDS).default(None)
+ return jsonify({
+ "infraction": self.db.run(query)
+ })
+
+ @api_key
+ @api_params(schema=UPDATE_INFRACTION_SCHEMA, validation_type=ValidationTypes.json)
+ def patch(self, data):
+ expand = data.get("expand")
+ update_collection = {
+ "id": data["id"]
+ }
+
+ if "reason" in data:
+ update_collection["reason"] = data["reason"]
+
+ if "active" in data:
+ update_collection["active"] = data["active"]
+ update_collection["closed"] = not data["active"]
+
+ if "duration" in data:
+ duration_str = data["duration"]
+ if duration_str is None:
+ update_collection["expires_at"] = None
+ else:
+ try:
+ update_collection["expires_at"] = parse_duration(duration_str)
+ except ValueError:
+ return self.error(
+ ErrorCodes.incorrect_parameters,
+ "Invalid duration format."
+ )
+
+ query_update = self.db.query(self.table_name).update(update_collection)
+ result_update = self.db.run(query_update)
+
+ if not result_update["replaced"]:
+ return jsonify({
+ "success": False,
+ "error_message": "Unknown infraction / nothing was changed."
+ })
+
+ # return the updated infraction
+ query = self.db.query(self.table_name).get(data["id"]) \
+ .merge(_merge_expand_users(self, expand)) \
+ .merge(_merge_active_check()) \
+ .without(*EXCLUDED_FIELDS).default(None)
+ infraction = self.db.run(query)
+
+ return jsonify({
+ "infraction": infraction,
+ "success": True
+ })
+
+
+class InfractionById(APIView, DBMixin):
+ path = "/bot/infractions/id/<string:infraction_id>"
+ name = "bot.infractions.id"
+ table_name = "bot_infractions"
+
+ @api_key
+ @api_params(schema=GET_ACTIVE_SCHEMA, validation_type=ValidationTypes.params)
+ def get(self, params, infraction_id):
+ params = params or {}
+ expand = parse_bool(params.get("expand"), default=False)
+
+ query = self.db.query(self.table_name).get(infraction_id) \
+ .merge(_merge_expand_users(self, expand)) \
+ .merge(_merge_active_check()) \
+ .without(*EXCLUDED_FIELDS).default(None)
+ return jsonify({
+ "infraction": self.db.run(query)
+ })
+
+
+class ListInfractionsByUserView(APIView, DBMixin):
+ path = "/bot/infractions/user/<string:user_id>"
+ name = "bot.infractions.user"
+ table_name = "bot_infractions"
+
+ @api_key
+ @api_params(schema=GET_SCHEMA, validation_type=ValidationTypes.params)
+ def get(self, params, user_id):
+ return _infraction_list_filtered(self, params, {
+ "user_id": user_id
+ })
+
+
+class ListInfractionsByTypeView(APIView, DBMixin):
+ path = "/bot/infractions/type/<string:type>"
+ name = "bot.infractions.type"
+ table_name = "bot_infractions"
+
+ @api_key
+ @api_params(schema=GET_SCHEMA, validation_type=ValidationTypes.params)
+ def get(self, params, type):
+ return _infraction_list_filtered(self, params, {
+ "type": type
+ })
+
+
+class ListInfractionsByTypeAndUserView(APIView, DBMixin):
+ path = "/bot/infractions/user/<string:user_id>/<string:type>"
+ name = "bot.infractions.user.type"
+ table_name = "bot_infractions"
+
+ @api_key
+ @api_params(schema=GET_SCHEMA, validation_type=ValidationTypes.params)
+ def get(self, params, user_id, type):
+ return _infraction_list_filtered(self, params, {
+ "user_id": user_id,
+ "type": type
+ })
+
+
+class CurrentInfractionByTypeAndUserView(APIView, DBMixin):
+ path = "/bot/infractions/user/<string:user_id>/<string:infraction_type>/current"
+ name = "bot.infractions.user.type.current"
+ table_name = "bot_infractions"
+
+ @api_key
+ @api_params(schema=GET_ACTIVE_SCHEMA, validation_type=ValidationTypes.params)
+ def get(self, params, user_id, infraction_type):
+ params = params or {}
+ expand = parse_bool(params.get("expand"), default=False)
+
+ query_filter = {
+ "user_id": user_id,
+ "type": infraction_type
+ }
+ query = _merged_query(self, expand, query_filter).filter({
+ "active": True
+ }).order_by(rethinkdb.desc("data")).limit(1).nth(0).default(None)
+ return jsonify({
+ "infraction": self.db.run(query)
+ })
+
+
+class ImportRowboatInfractionsView(APIView, DBMixin):
+ path = "/bot/infractions/import"
+ name = "bot.infractions.import"
+ table_name = "bot_infractions"
+
+ @api_key
+ @api_params(schema=IMPORT_INFRACTIONS_SCHEMA, validation_type=ValidationTypes.json)
+ def post(self, data):
+ # keep track of the un-bans, to apply after the import is complete.
+ unbans = []
+ infractions = []
+
+ # previously imported infractions
+ imported_infractions = self.db.run(
+ self.db.query(self.table_name).filter(
+ lambda row: row.has_fields("legacy_rowboat_id")
+ ).fold([], lambda acc, row: acc.append(row["legacy_rowboat_id"])).coerce_to("array")
+ )
+
+ for rowboat_infraction_data in data:
+ legacy_rowboat_id = rowboat_infraction_data["id"]
+ if legacy_rowboat_id in imported_infractions:
+ continue
+ infraction_type = rowboat_infraction_data["type"]["name"]
+ if infraction_type == "unban":
+ unbans.append(rowboat_infraction_data)
+ continue
+ # adjust infraction types
+ if infraction_type == "tempmute":
+ infraction_type = "mute"
+ if infraction_type == "tempban":
+ infraction_type = "ban"
+ if infraction_type not in INFRACTION_TYPES:
+ # unknown infraction type
+ continue
+ active = rowboat_infraction_data["active"]
+ reason = rowboat_infraction_data["reason"] or "<No reason>"
+ user_id = rowboat_infraction_data["user"]["id"]
+ actor_id = rowboat_infraction_data["actor"]["id"]
+ inserted_at_str = rowboat_infraction_data["created_at"]
+ try:
+ inserted_at = parse_rfc1123(inserted_at_str)
+ except ValueError:
+ continue
+ expires_at_str = rowboat_infraction_data["expires_at"]
+ if expires_at_str is not None:
+ try:
+ expires_at = parse_rfc1123(expires_at_str)
+ except ValueError:
+ continue
+ else:
+ expires_at = None
+ infractions.append({
+ "legacy_rowboat_id": legacy_rowboat_id,
+ "active": active,
+ "reason": reason,
+ "user_id": user_id,
+ "actor_id": actor_id,
+ "inserted_at": inserted_at,
+ "expires_at": expires_at,
+ "type": infraction_type
+ })
+
+ insertion_query = self.db.query(self.table_name).insert(infractions)
+ inserted_count = self.db.run(insertion_query)["inserted"]
+
+ # apply unbans
+ for unban_data in unbans:
+ inserted_at_str = unban_data["created_at"]
+ user_id = unban_data["user"]["id"]
+ try:
+ inserted_at = parse_rfc1123(inserted_at_str)
+ except ValueError:
+ continue
+ self.db.run(
+ self.db.query(self.table_name).filter(
+ lambda row: (row["user_id"].eq(user_id)) &
+ (row["type"].eq("ban")) &
+ (row["inserted_at"] < inserted_at)
+ ).pluck("id").merge(lambda row: {
+ "active": False
+ }).coerce_to("array").for_each(lambda doc: self.db.query(self.table_name).get(doc["id"]).update(doc))
+ )
+
+ return jsonify({
+ "success": True,
+ "inserted_count": inserted_count
+ })
+
+
+def _infraction_list_filtered(view, params=None, query_filter=None):
+ params = params or {}
+ query_filter = query_filter or {}
+ active = parse_bool(params.get("active"))
+ expand = parse_bool(params.get("expand"), default=False)
+ search = params.get("search")
+
+ if active is not None:
+ query_filter["active"] = active
+
+ query = _merged_query(view, expand, query_filter)
+
+ if search is not None:
+ query = query.filter(
+ lambda row: rethinkdb.branch(
+ row["reason"].eq(None),
+ False,
+ row["reason"].match(search)
+ )
+ )
+
+ query = query.order_by(*INFRACTION_ORDER)
+
+ return jsonify(view.db.run(query.coerce_to("array")))
+
+
+def _merged_query(view, expand, query_filter):
+ return view.db.query(view.table_name).merge(_merge_active_check()).filter(query_filter) \
+ .merge(_merge_expand_users(view, expand)).without(*EXCLUDED_FIELDS)
+
+
+def _merge_active_check():
+ # Checks if the "closed" field has been set to true (manual infraction removal).
+ # If not, the "active" field is set to whether the infraction has expired.
+ def _merge(row):
+ return {
+ "active":
+ rethinkdb.branch(
+ _is_timed_infraction(row["type"]),
+ rethinkdb.branch(
+ (row["closed"].default(False).eq(True)) | (row["active"].default(True).eq(False)),
+ False,
+ rethinkdb.branch(
+ row["expires_at"].eq(None),
+ True,
+ row["expires_at"] > rethinkdb.now()
+ )
+ ),
+ False
+ ),
+ "closed": row["closed"].default(False),
+ "_timed": _is_timed_infraction(row["type"])
+ }
+
+ return _merge
+
+
+def _merge_expand_users(view, expand):
+ def _do_expand(user_id):
+ if not user_id:
+ return None
+ # Expands the user information, if it is in the database.
+
+ if expand:
+ return view.db.query("users").get(user_id).default({
+ "user_id": user_id
+ })
+
+ return {
+ "user_id": user_id
+ }
+
+ def _merge(row):
+ return {
+ "user": _do_expand(row["user_id"].default(None)),
+ "actor": _do_expand(row["actor_id"].default(None))
+ }
+
+ return _merge
+
+
+def _is_timed_infraction(type_var):
+ # this method generates an ReQL expression to check if the given type
+ # is a "timed infraction" (i.e it can expire or be permanent)
+
+ timed_infractions = filter(lambda key: INFRACTION_TYPES[key].timed_infraction, INFRACTION_TYPES.keys())
+ expr = rethinkdb.expr(False)
+ for infra_type in timed_infractions:
+ expr = expr | type_var.eq(infra_type)
+ return expr
+
+
+def parse_rfc1123(time_str):
+ return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+
+
+def parse_bool(a_string, default=None):
+ # Not present, null or any: returns default (defaults to None)
+ # false, no, or 0: returns False
+ # anything else: True
+ if a_string is None or a_string == "null" or a_string == "any":
+ return default
+ if a_string.lower() == "false" or a_string.lower() == "no" or a_string == "0":
+ return False
+ return True
diff --git a/pysite/views/api/bot/user.py b/pysite/views/api/bot/user.py
index 189dd1f8..a3a0c7a8 100644
--- a/pysite/views/api/bot/user.py
+++ b/pysite/views/api/bot/user.py
@@ -1,5 +1,6 @@
import logging
+import rethinkdb
from flask import jsonify, request
from schema import Optional, Schema
@@ -18,6 +19,12 @@ SCHEMA = Schema([
}
])
+GET_SCHEMA = Schema([
+ {
+ "user_id": str
+ }
+])
+
DELETE_SCHEMA = Schema([
{
"user_id": str,
@@ -46,6 +53,24 @@ class UserView(APIView, DBMixin):
teams_table = "code_jam_teams"
@api_key
+ @api_params(schema=GET_SCHEMA, validation_type=ValidationTypes.params)
+ def get(self, data):
+ logging.getLogger(__name__).debug(f"Size of request: {len(request.data)} bytes")
+
+ if not data:
+ return self.error(ErrorCodes.bad_data_format, "No user IDs supplied")
+
+ data = [x["user_id"] for x in data]
+
+ result = self.db.run(
+ self.db.query(self.table_name)
+ .filter(lambda document: rethinkdb.expr(data).contains(document["user_id"])),
+ coerce=list
+ )
+
+ return jsonify({"data": result}) # pragma: no cover
+
+ @api_key
@api_params(schema=SCHEMA, validation_type=ValidationTypes.json)
def post(self, data):
logging.getLogger(__name__).debug(f"Size of request: {len(request.data)} bytes")
@@ -72,11 +97,13 @@ class UserView(APIView, DBMixin):
def delete(self, data):
user_ids = [user["user_id"] for user in data]
- changes = self.db.run(
- self.db.query(self.table_name)
- .get_all(*user_ids)
- .delete()
- )
+ changes = {}
+
+ # changes = self.db.run(
+ # self.db.query(self.table_name)
+ # .get_all(*user_ids)
+ # .delete()
+ # )
oauth_deletions = self.db.run(
self.db.query(self.oauth_table_name)
diff --git a/templates/main/about/privacy.html b/templates/main/about/privacy.html
index b1d778c2..fa4e2aab 100644
--- a/templates/main/about/privacy.html
+++ b/templates/main/about/privacy.html
@@ -31,49 +31,62 @@
<h3>Data collection</h3>
+ <div class="uk-alert uk-alert-warning">
+ <p>
+ Please note that data <span class="uk-text-primary">marked with blurple text below</span> is not
+ automatically removed. We need to hold onto this information in order to maintain infraction records
+ and ensure the smooth running of our community.
+ </p>
+ <p>
+ We do not store any data until you have verified yourself in <code>#checkpoint</code> on the server,
+ and certified that you agree to our rules and privacy policy. If you are leaving the server and would
+ like us to remove this data as well, please contact a member of staff directly.
+ </p>
+ </div>
+
<table class="uk-table uk-table-divider uk-table-striped uk-table-small table-bordered" id="data-collection-table">
<thead>
<tr class="thick-bottom-border">
- <th>What we collect</th>
- <th class="uk-table-shrink">When</th>
- <th>What it's used for</th>
- <th>Who can access it</th>
+ <th class="uk-width-1-4">What we collect</th>
+ <th class="uk-width-1-4">When it's collected</th>
+ <th class="uk-width-1-4">What it's used for</th>
+ <th class="uk-width-1-4">Who can access it</th>
</tr>
</thead>
<tbody>
<tr>
- <td>Discord user ID</td>
- <td class="uk-table-shrink"><strong>self.accept()</strong> run on Discord</td>
+ <td class="uk-text-primary">Discord user ID</td>
+ <td class="uk-table-shrink"><strong>!accept</strong> run on Discord</td>
<td>Statistics, data association (infractions, code jam applications, etc)</td>
<td>Administrative staff</td>
</tr>
<tr>
- <td>Discord username and discriminator</td>
- <td class="uk-table-shrink"><strong>self.accept()</strong> run on Discord</td>
+ <td class="uk-text-primary">Discord username and discriminator</td>
+ <td class="uk-table-shrink"><strong>!accept</strong> run on Discord</td>
<td>Display purposes (alongside ID in staff areas, public profiles)</td>
- <td>Public, for code jam team listings and winner info</td>
+ <td>Public (for code jam team listings and winner info) and staff areas</td>
</tr>
<tr>
- <td>Discord avatar URLs</td>
- <td class="uk-table-shrink"><strong>self.accept()</strong> run on Discord</td>
- <td>Display purposes (public profiles)</td>
- <td>Public, for code jam team listings and winner info</td>
+ <td class="uk-text-primary">Discord avatar URLs</td>
+ <td class="uk-table-shrink"><strong>!accept</strong> run on Discord</td>
+ <td>Display purposes (alongside ID in staff areas, public profiles)</td>
+ <td>Public (for code jam team listings and winner info) and staff areas</td>
</tr>
<tr>
- <td>Assigned roles on Discord</td>
- <td class="uk-table-shrink"><strong>self.accept()</strong> run on Discord</td>
- <td>Access control for the site</td>
+ <td class="uk-text-primary">Assigned roles on Discord</td>
+ <td class="uk-table-shrink"><strong>!accept</strong> run on Discord</td>
+ <td>Access control for the site, infractions, role restoration after kicks</td>
<td>Administrative staff</td>
</tr>
<tr class="thick-bottom-border">
- <td>Messages sent on Discord</td>
- <td class="uk-table-shrink"><strong>self.accept()</strong> run on Discord</td>
+ <td class="uk-text-primary">Messages sent on Discord</td>
+ <td class="uk-table-shrink"><strong>!accept</strong> run on Discord</td>
<td>
- Stored in memory by the bot for processing temporarily, no message content reaches
- the database unless you're using a bot command that interfaces with the site - May be
- temporarily written to a log file for debugging purposes
+ Stored in memory by the bot for processing temporarily, may also end up in
+ staff-only logging channels for the purposes of accountability and infraction
+ management
</td>
- <td>N/A</td>
+ <td>Administrative staff</td>
</tr>
<tr class="thick-bottom-border">
@@ -136,7 +149,7 @@
</p>
<p>
If you joined the community after the <strong>20th of May, 2018</strong>, you will have been greeted with the
- <code>#checkpoint</code> channel. In this channel, you must run the <code>self.accept()</code>
+ <code>#checkpoint</code> channel. In this channel, you must run the <code>!accept</code>
command to signify that you accept both our rules and this privacy policy. This will also have been
detailed in a message in that channel.
</p>
@@ -161,7 +174,7 @@
</p>
<div class="uk-grid uk-grid-match" uk-grid>
- <div class="uk-width-1-2@m">
+ <div class="uk-width-3-5@m">
<div class="uk-card uk-card-default uk-card-small">
<div class="uk-card-header">
<h3 class="uk-card-title">Complete data removal</h3>
@@ -174,6 +187,12 @@
our community, we are unable to offer you community membership with zero data collection.
</p>
<p>
+ Please note that data <span class="uk-text-primary">marked with blurple text in the table above</span>
+ is not automatically removed. We need to hold onto this information in order to maintain infraction records
+ and ensure the smooth running of our community. If you are leaving the server and would like us to remove
+ this data as well, please contact a member of staff directly.
+ </p>
+ <p>
Once you've left the Discord server, your data is removed automatically. Please note that
for the sake of data integrity and moderation purposes, we do not remove your Discord
user ID from our database - but we do anonymize your data as far as possible.
@@ -186,7 +205,7 @@
</div>
</div>
</div>
- <div class="uk-width-1-2@m">
+ <div class="uk-width-2-5@m">
<div class="uk-card uk-card-default uk-card-small">
<div class="uk-card-header">
<h3 class="uk-card-title">Code jam profile removal</h3>
@@ -253,6 +272,14 @@
<ul class="uk-list uk-list-divider">
<li>
+ <h4>July 27th, 2018</h4>
+ <p>
+ As we're replacing Rowboat (the bot we use for moderation), we need to hold onto some of
+ your data - even after you've left the server. This is necessary to ensure the smooth
+ running and security of our community.
+ </p>
+ </li>
+ <li>
<h4>July 3rd, 2018</h4>
<p>
While we don't collect your email addresses, they are visible on GitLab if you email an
diff --git a/tests/test_api_bot_infractions.py b/tests/test_api_bot_infractions.py
new file mode 100644
index 00000000..58453e9b
--- /dev/null
+++ b/tests/test_api_bot_infractions.py
@@ -0,0 +1,134 @@
+import json
+
+from tests import SiteTest, app
+
+TEST_USER_ID = "test"
+
+
+class ApiBotInfractionsEndpoint(SiteTest):
+
+ def test_infraction_create_invalid(self):
+ # Invalid infraction type
+ post_data_invalid_type = json.dumps(
+ {"type": "not_a_type", "reason": "test", "user_id": TEST_USER_ID, "actor_id": TEST_USER_ID}
+ )
+ response = self.client.post("/bot/infractions", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"],
+ data=post_data_invalid_type)
+ self.assert400(response)
+
+ def test_infraction_kick(self):
+ post_data_valid = json.dumps(
+ {"type": "kick", "reason": "test", "user_id": TEST_USER_ID, "actor_id": TEST_USER_ID}
+ )
+ response = self.client.post("/bot/infractions", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"],
+ data=post_data_valid)
+ self.assert200(response)
+ self.assertTrue("infraction" in response.json)
+ self.assertTrue("id" in response.json["infraction"])
+ infraction_id = response.json["infraction"]["id"]
+ response = self.client.get(f"/bot/infractions/id/{infraction_id}", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"])
+ self.assert200(response)
+ self.assertTrue("infraction" in response.json)
+ self.assertTrue("id" in response.json["infraction"])
+ self.assertEqual(response.json["infraction"]["id"], infraction_id)
+ self.assertTrue("active" in response.json["infraction"])
+ self.assertFalse(response.json["infraction"]["active"])
+
+ def test_infraction_ban(self):
+ post_data_valid = json.dumps(
+ {"type": "ban", "reason": "baddie", "user_id": TEST_USER_ID, "actor_id": TEST_USER_ID}
+ )
+ response = self.client.post("/bot/infractions", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"],
+ data=post_data_valid)
+ self.assert200(response)
+ self.assertTrue("infraction" in response.json)
+ self.assertTrue("id" in response.json["infraction"])
+ infraction_id = response.json["infraction"]["id"]
+
+ # Check if the ban is currently applied
+ response = self.client.get(f"/bot/infractions/user/{TEST_USER_ID}/ban/current", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"])
+ self.assert200(response)
+ self.assertTrue("infraction" in response.json)
+ self.assertIsNotNone(response.json["infraction"])
+ self.assertTrue("id" in response.json["infraction"])
+ self.assertEqual(response.json["infraction"]["id"], infraction_id)
+ self.assertIsNone(response.json["infraction"]["expires_at"])
+ self.assertTrue(response.json["infraction"]["active"])
+
+ # Update the expiration to 1d
+ patch_data_valid = json.dumps(
+ {"id": infraction_id, "duration": "1d"}
+ )
+ response = self.client.patch("/bot/infractions", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"],
+ data=patch_data_valid)
+ self.assert200(response)
+ self.assertTrue("success" in response.json)
+ self.assertTrue("infraction" in response.json)
+ self.assertTrue(response.json["success"])
+ self.assertIsNotNone(response.json["infraction"]["expires_at"])
+ self.assertTrue(response.json["infraction"]["active"])
+
+ # Disable the ban
+ patch_data_valid = json.dumps(
+ {"id": infraction_id, "active": False}
+ )
+ response = self.client.patch("/bot/infractions", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"],
+ data=patch_data_valid)
+ self.assert200(response)
+ self.assertTrue("success" in response.json)
+ self.assertTrue("infraction" in response.json)
+ self.assertTrue(response.json["success"])
+ self.assertFalse(response.json["infraction"]["active"])
+
+ # Check if there is no active ban anymore
+ response = self.client.get(f"/bot/infractions/user/{TEST_USER_ID}/ban/current", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"])
+ self.assert200(response)
+ self.assertTrue("infraction" in response.json)
+ self.assertIsNone(response.json["infraction"])
+
+ # Re-activate the ban
+ patch_data_valid = json.dumps(
+ {"id": infraction_id, "active": True}
+ )
+ response = self.client.patch("/bot/infractions", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"],
+ data=patch_data_valid)
+ self.assert200(response)
+ self.assertTrue("success" in response.json)
+ self.assertTrue("infraction" in response.json)
+ self.assertTrue(response.json["success"])
+ self.assertTrue(response.json["infraction"]["active"])
+
+ # Create a new ban
+ post_data_valid = json.dumps(
+ {"type": "ban", "reason": "baddie v2.0", "user_id": TEST_USER_ID, "actor_id": TEST_USER_ID}
+ )
+ response = self.client.post("/bot/infractions", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"],
+ data=post_data_valid)
+ self.assert200(response)
+ self.assertTrue("infraction" in response.json)
+ self.assertTrue("id" in response.json["infraction"])
+ new_infraction_id = response.json["infraction"]["id"]
+
+ # Check if the old ban is now disabled
+ response = self.client.get(f"/bot/infractions/id/{infraction_id}", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"])
+ self.assert200(response)
+ self.assertTrue("infraction" in response.json)
+ self.assertFalse(response.json["infraction"]["active"])
+
+ # Check if the current ban infraction is the new infraction
+ response = self.client.get(f"/bot/infractions/user/{TEST_USER_ID}/ban/current", app.config["API_SUBDOMAIN"],
+ headers=app.config["TEST_HEADER"])
+ self.assert200(response)
+ self.assertTrue("infraction" in response.json)
+ self.assertEqual(response.json["infraction"]["id"], new_infraction_id)
diff --git a/tests/test_api_bot_users.py b/tests/test_api_bot_users.py
index 9ad46071..eda3713e 100644
--- a/tests/test_api_bot_users.py
+++ b/tests/test_api_bot_users.py
@@ -1,7 +1,7 @@
-import os
import json
from tests import SiteTest, app
+
class ApiBotUsersEndpoint(SiteTest):
def test_api_user(self):
""" Check insert user """
@@ -12,8 +12,8 @@ class ApiBotUsersEndpoint(SiteTest):
{'user_id': "1234", 'roles': ["5678"], "username": "test", "discriminator": "0000", "avatar": "http://some/url"}
])
- response = self.client.get('/bot/users', app.config['API_SUBDOMAIN'], headers=app.config['TEST_HEADER'])
- self.assertEqual(response.status_code, 405)
+ response = self.client.get('/bot/users?user_id=1234', app.config['API_SUBDOMAIN'], headers=app.config['TEST_HEADER'])
+ self.assertTrue("data" in response.json)
response = self.client.post('/bot/users', app.config['API_SUBDOMAIN'], headers=app.config['TEST_HEADER'], data=single_data)
self.assertTrue("success" in response.json)