From cc35a2b1c87f108e8b13ee37db445c75cad12151 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 12:51:38 +0100 Subject: [PATCH 01/26] Refactor membership checking --- utils/msl/__init__.py | 14 +++++ utils/msl/core.py | 83 ++++++++++++++++++++++++++++++ utils/msl/memberships.py | 108 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 utils/msl/__init__.py create mode 100644 utils/msl/core.py create mode 100644 utils/msl/memberships.py diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py new file mode 100644 index 000000000..4f53df443 --- /dev/null +++ b/utils/msl/__init__.py @@ -0,0 +1,14 @@ +"""MSL utility classes & functions provided for use across the whole of the project.""" + +from typing import TYPE_CHECKING + +from .memberships import get_full_membership_list, get_membership_count, is_student_id_member + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__: "Sequence[str]" = ( + "get_full_membership_list", + "get_membership_count", + "is_student_id_member", +) diff --git a/utils/msl/core.py b/utils/msl/core.py new file mode 100644 index 000000000..827daa176 --- /dev/null +++ b/utils/msl/core.py @@ -0,0 +1,83 @@ +"""Functions to enable interaction with MSL based SU websites.""" + +import datetime as dt +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +import aiohttp +from bs4 import BeautifulSoup + +from config import settings + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from datetime import timezone + from http.cookies import Morsel + from logging import Logger + from typing import Final + +__all__: "Sequence[str]" = () + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +DEFAULT_TIMEZONE: "Final[timezone]" = dt.UTC +TODAYS_DATE: "Final[datetime]" = datetime.now(tz=DEFAULT_TIMEZONE) + +CURRENT_YEAR_START_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, + month=7, + day=1, + tzinfo=DEFAULT_TIMEZONE, +) + +CURRENT_YEAR_END_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, + month=6, + day=30, + tzinfo=DEFAULT_TIMEZONE, +) + +BASE_HEADERS: "Final[Mapping[str, str]]" = { + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Expires": "0", +} + +BASE_COOKIES: "Final[Mapping[str, str]]" = { + ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], +} + +ORGANISATION_ID: "Final[str]" = settings["ORGANISATION_ID"] + +ORGANISATION_ADMIN_URL: "Final[str]" = ( + f"https://www.guildofstudents.com/organisation/admin/{ORGANISATION_ID}/" +) + + +async def get_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: + """Get the required context headers, data and cookies to make a request to MSL.""" + http_session: aiohttp.ClientSession = aiohttp.ClientSession( + headers=BASE_HEADERS, + cookies=BASE_COOKIES, + ) + data_fields: dict[str, str] = {} + cookies: dict[str, str] = {} + async with http_session, http_session.get(url=url) as field_data: + data_response = BeautifulSoup( + markup=await field_data.text(), + features="html.parser", + ) + + for field in data_response.find_all(name="input"): + if field.get("name") and field.get("value"): + data_fields[field.get("name")] = field.get("value") + + for cookie in field_data.cookies: + cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) + if cookie_morsel is not None: + cookies[cookie] = cookie_morsel.value + cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] + + return data_fields, cookies diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py new file mode 100644 index 000000000..66e39aaab --- /dev/null +++ b/utils/msl/memberships.py @@ -0,0 +1,108 @@ +"""Module for checking membership status.""" + +import logging +from typing import TYPE_CHECKING + +import aiohttp +import bs4 +from bs4 import BeautifulSoup + +from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID + +if TYPE_CHECKING: + from collections.abc import Sequence + from logging import Logger + from typing import Final + +__all__: "Sequence[str]" = ( + "get_full_membership_list", + "get_membership_count", + "is_student_id_member", +) + +MEMBERS_LIST_URL: "Final[str]" = ( + f"https://guildofstudents.com/organisation/memberlist/{ORGANISATION_ID}/?sort=groups" +) + +membership_list_cache: set[tuple[str, int]] = set() + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +async def get_full_membership_list() -> set[tuple[str, int]]: + """Get a list of tuples of student ID to names.""" + http_session: aiohttp.ClientSession = aiohttp.ClientSession( + headers=BASE_HEADERS, + cookies=BASE_COOKIES, + ) + async with http_session, http_session.get(url=MEMBERS_LIST_URL) as http_response: + response_html: str = await http_response.text() + + standard_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( + markup=response_html, + features="html.parser", + ).find( + name="table", + attrs={"id": "ctl00_Main_rptGroups_ctl03_gvMemberships"}, + ) + + all_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( + markup=response_html, + features="html.parser", + ).find( + name="table", + attrs={"id": "ctl00_Main_rptGroups_ctl05_gvMemberships"}, + ) + + if standard_members_table is None or all_members_table is None: + logger.warning("One or both of the membership tables could not be found!") + logger.debug(response_html) + return set() + + if isinstance(standard_members_table, bs4.NavigableString) or isinstance( + all_members_table, bs4.NavigableString + ): + logger.warning( + "Both membership tables were found but one or both are the wrong format!", + ) + logger.debug(standard_members_table) + logger.debug(all_members_table) + return set() + + standard_members: list[bs4.Tag] = standard_members_table.find_all(name="tr") + all_members: list[bs4.Tag] = all_members_table.find_all(name="tr") + + standard_members.pop(0) + all_members.pop(0) + + member_list: set[tuple[str, int]] = { + ( + member.find_all(name="td")[0].text.strip(), + member.find_all(name="td")[ + 1 + ].text.strip(), # NOTE: This will not properly handle external members who do not have an ID... There does not appear to be a solution to this other than simply checking manually. + ) + for member in standard_members + all_members + } + + membership_list_cache.clear() + membership_list_cache.update(member_list) + + return member_list + + +async def is_student_id_member(student_id: str | int) -> bool: + """Check if the student ID is a member of the society.""" + all_ids: set[str] = {str(member[1]) for member in membership_list_cache} + + if str(student_id) in all_ids: + return True + + new_ids: set[str] = {str(member[1]) for member in await get_full_membership_list()} + + return str(student_id) in new_ids + + +async def get_membership_count() -> int: + """Return the total number of members.""" + return len(await get_full_membership_list()) From 0917ae5c962b7057ca3e9c36ec72097cd1353cd6 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:11:01 +0100 Subject: [PATCH 02/26] Refactor and Reformat --- cogs/make_member.py | 108 +++------------------------------------ utils/__init__.py | 3 ++ utils/msl/core.py | 3 +- utils/msl/memberships.py | 7 ++- 4 files changed, 17 insertions(+), 104 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 670d4d015..e043bceb0 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -4,16 +4,13 @@ import re from typing import TYPE_CHECKING -import aiohttp -import bs4 import discord -from bs4 import BeautifulSoup from django.core.exceptions import ValidationError from config import settings from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError -from utils import GLOBAL_SSL_CONTEXT, CommandChecks, TeXBotBaseCog +from utils import CommandChecks, TeXBotBaseCog, get_membership_count, is_student_id_member if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -154,56 +151,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - guild_member_ids: set[str] = set() - - async with ( - aiohttp.ClientSession( - headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES - ) as http_session, - http_session.get( - url=GROUPED_MEMBERS_URL, ssl=GLOBAL_SSL_CONTEXT - ) as http_response, - ): - response_html: str = await http_response.text() - - MEMBER_HTML_TABLE_IDS: Final[frozenset[str]] = frozenset( - { - "ctl00_Main_rptGroups_ctl05_gvMemberships", - "ctl00_Main_rptGroups_ctl03_gvMemberships", - "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships", - "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships", - } - ) - table_id: str - for table_id in MEMBER_HTML_TABLE_IDS: - parsed_html: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - response_html, "html.parser" - ).find("table", {"id": table_id}) - - if parsed_html is None or isinstance(parsed_html, bs4.NavigableString): - continue - - guild_member_ids.update( - row.contents[2].text - for row in parsed_html.find_all("tr", {"class": ["msl_row", "msl_altrow"]}) - ) - - guild_member_ids.discard("") - guild_member_ids.discard("\n") - guild_member_ids.discard(" ") - - if not guild_member_ids: - await self.command_send_error( - ctx, - error_code="E1041", - logging_message=OSError( - "The guild member IDs could not be retrieved from " - "the MEMBERS_LIST_URL." - ), - ) - return - - if group_member_id not in guild_member_ids: + if not await is_student_id_member(student_id=group_member_id): await self.command_send_error( ctx, message=( @@ -276,53 +224,9 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: await ctx.defer(ephemeral=False) async with ctx.typing(): - async with ( - aiohttp.ClientSession( - headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES - ) as http_session, - http_session.get( - url=BASE_MEMBERS_URL, ssl=GLOBAL_SSL_CONTEXT - ) as http_response, - ): - response_html: str = await http_response.text() - - member_list_div: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - response_html, "html.parser" - ).find("div", {"class": "memberlistcol"}) - - if member_list_div is None or isinstance(member_list_div, bs4.NavigableString): - await self.command_send_error( - ctx=ctx, - error_code="E1041", - logging_message=OSError( - "The member count could not be retrieved from the MEMBERS_LIST_URL." - ), - ) - return - - if "showing 100 of" in member_list_div.text.lower(): - member_count: str = member_list_div.text.split(" ")[3] - await ctx.followup.send( - content=f"{self.bot.group_full_name} has {member_count} members! :tada:" - ) - return - - member_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - response_html, "html.parser" - ).find("table", {"id": "ctl00_ctl00_Main_AdminPageContent_gvMembers"}) - - if member_table is None or isinstance(member_table, bs4.NavigableString): - await self.command_send_error( - ctx=ctx, - error_code="E1041", - logging_message=OSError( - "The member count could not be retrieved from the MEMBERS_LIST_URL." - ), - ) - return - await ctx.followup.send( - content=f"{self.bot.group_full_name} has { - len(member_table.find_all('tr', {'class': ['msl_row', 'msl_altrow']})) - } members! :tada:" + content=( + f"{self.bot.group_full_name} has " + f"{await get_membership_count()} members! :tada:" + ) ) diff --git a/utils/__init__.py b/utils/__init__.py index 62c3eade4..6850d212e 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -9,6 +9,7 @@ from .command_checks import CommandChecks from .message_sender_components import MessageSavingSenderComponent +from .msl import get_membership_count, is_student_id_member from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot from .tex_bot_base_cog import TeXBotBaseCog @@ -29,8 +30,10 @@ "TeXBotAutocompleteContext", "TeXBotBaseCog", "generate_invite_url", + "get_membership_count", "is_member_inducted", "is_running_in_async", + "is_student_id_member", ) if TYPE_CHECKING: diff --git a/utils/msl/core.py b/utils/msl/core.py index 827daa176..a6c528e81 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -9,6 +9,7 @@ from bs4 import BeautifulSoup from config import settings +from utils import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -64,7 +65,7 @@ async def get_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: ) data_fields: dict[str, str] = {} cookies: dict[str, str] = {} - async with http_session, http_session.get(url=url) as field_data: + async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: data_response = BeautifulSoup( markup=await field_data.text(), features="html.parser", diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 66e39aaab..69df00df7 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,6 +7,8 @@ import bs4 from bs4 import BeautifulSoup +from utils import GLOBAL_SSL_CONTEXT + from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID if TYPE_CHECKING: @@ -35,7 +37,10 @@ async def get_full_membership_list() -> set[tuple[str, int]]: headers=BASE_HEADERS, cookies=BASE_COOKIES, ) - async with http_session, http_session.get(url=MEMBERS_LIST_URL) as http_response: + async with ( + http_session, + http_session.get(url=MEMBERS_LIST_URL, ssl=GLOBAL_SSL_CONTEXT) as http_response, + ): response_html: str = await http_response.text() standard_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( From 2270e9cfb363a8767c7c45f6f22215319ac52667 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:14:52 +0100 Subject: [PATCH 03/26] Fix import error --- tests/test_utils.py | 70 ++------------------------------------------- 1 file changed, 2 insertions(+), 68 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 7d27e4aee..cdbba412d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,7 +4,7 @@ import re from typing import TYPE_CHECKING -import utils +from utils import generate_invite_url if TYPE_CHECKING: from collections.abc import Sequence @@ -12,72 +12,6 @@ __all__: "Sequence[str]" = () -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 -# class TestPlotBarChart: -# """Test case to unit-test the plot_bar_chart function.""" -# -# def test_bar_chart_generates(self) -> None: -# """Test that the bar chart generates successfully when valid arguments are passed.""" # noqa: ERA001, E501, W505 -# FILENAME: Final[str] = "output_chart.png" # noqa: ERA001 -# DESCRIPTION: Final[str] = "Bar chart of the counted value of different roles." # noqa: ERA001, E501, W505 -# -# bar_chart_image: discord.File = plot_bar_chart( -# data={"role1": 5, "role2": 7}, # noqa: ERA001 -# x_label="Role Name", # noqa: ERA001 -# y_label="Counted value", # noqa: ERA001 -# title="Counted Value Of Each Role", # noqa: ERA001 -# filename=FILENAME, # noqa: ERA001 -# description=DESCRIPTION, # noqa: ERA001 -# extra_text="This is extra text" # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# -# assert bar_chart_image.filename == FILENAME # noqa: ERA001 -# assert bar_chart_image.description == DESCRIPTION # noqa: ERA001 -# assert bool(bar_chart_image.fp.read()) is True # noqa: ERA001 - - -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 -# class TestAmountOfTimeFormatter: -# """Test case to unit-test the amount_of_time_formatter function.""" -# -# @pytest.mark.parametrize( -# "time_value", -# (1, 1.0, 0.999999, 1.000001) # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# def test_format_unit_value(self, time_value: float) -> None: -# """Test that a value of one only includes the time_scale.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# formatted_amount_of_time: str = amount_of_time_formatter(time_value, TIME_SCALE) # noqa: ERA001, E501, W505 -# -# assert formatted_amount_of_time == TIME_SCALE # noqa: ERA001 -# assert not formatted_amount_of_time.endswith("s") # noqa: ERA001 -# -# @pytest.mark.parametrize( -# "time_value", -# (*range(2, 21), 2.00, 0, 0.0, 25.0, -0, -0.0, -25.0) # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# def test_format_integer_value(self, time_value: float) -> None: -# """Test that an integer value includes the value and time_scale pluralized.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# assert amount_of_time_formatter( -# time_value, -# TIME_SCALE -# ) == f"{int(time_value)} {TIME_SCALE}s" -# -# @pytest.mark.parametrize("time_value", (3.14159, 0.005, 25.0333333)) -# def test_format_float_value(self, time_value: float) -> None: -# """Test that a float value includes the rounded value and time_scale pluralized.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# assert amount_of_time_formatter( -# time_value, -# TIME_SCALE -# ) == f"{time_value:.2f} {TIME_SCALE}s" - class TestGenerateInviteURL: """Test case to unit-test the generate_invite_url utility function.""" @@ -92,7 +26,7 @@ def test_url_generates() -> None: 10000000000000000, 99999999999999999999 ) - invite_url: str = utils.generate_invite_url( + invite_url: str = generate_invite_url( DISCORD_BOT_APPLICATION_ID, DISCORD_MAIN_GUILD_ID ) From fdda120eba038e46f17d98ddc41df94c6ea6fb19 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:25:10 +0100 Subject: [PATCH 04/26] Bit of a mess --- utils/__init__.py | 9 +-------- utils/msl/__init__.py | 8 ++++++++ utils/msl/core.py | 2 +- utils/msl/memberships.py | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/utils/__init__.py b/utils/__init__.py index 6850d212e..d2c7ecbdc 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,15 +1,13 @@ """Utility classes & functions provided for use across the whole of the project.""" import asyncio -import ssl from typing import TYPE_CHECKING -import certifi import discord from .command_checks import CommandChecks from .message_sender_components import MessageSavingSenderComponent -from .msl import get_membership_count, is_student_id_member +from .msl import get_membership_count, is_student_id_member, GLOBAL_SSL_CONTEXT from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot from .tex_bot_base_cog import TeXBotBaseCog @@ -17,7 +15,6 @@ if TYPE_CHECKING: from collections.abc import Sequence - from typing import Final __all__: "Sequence[str]" = ( "GLOBAL_SSL_CONTEXT", @@ -46,10 +43,6 @@ | None ) -GLOBAL_SSL_CONTEXT: "Final[ssl.SSLContext]" = ssl.create_default_context( - cafile=certifi.where() -) - def generate_invite_url(discord_bot_application_id: int, discord_guild_id: int) -> str: """Execute the logic that this util function provides.""" diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 4f53df443..495a6133a 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -1,14 +1,22 @@ """MSL utility classes & functions provided for use across the whole of the project.""" +import certifi +import certifi +import ssl + from typing import TYPE_CHECKING +GLOBAL_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) + from .memberships import get_full_membership_list, get_membership_count, is_student_id_member if TYPE_CHECKING: from collections.abc import Sequence + from typing import Final __all__: "Sequence[str]" = ( "get_full_membership_list", "get_membership_count", "is_student_id_member", + "GLOBAL_SSL_CONTEXT", ) diff --git a/utils/msl/core.py b/utils/msl/core.py index a6c528e81..09f84d930 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -9,7 +9,7 @@ from bs4 import BeautifulSoup from config import settings -from utils import GLOBAL_SSL_CONTEXT +from . import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 69df00df7..2e7f8d64c 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,10 +7,10 @@ import bs4 from bs4 import BeautifulSoup -from utils import GLOBAL_SSL_CONTEXT - from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID +from . import GLOBAL_SSL_CONTEXT + if TYPE_CHECKING: from collections.abc import Sequence from logging import Logger From 1ed9d9f28b8988e3b7b21ec12e7363f0daac6d35 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:26:57 +0100 Subject: [PATCH 05/26] Formatting --- utils/__init__.py | 2 +- utils/msl/__init__.py | 10 ++++------ utils/msl/core.py | 1 + utils/msl/memberships.py | 3 +-- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/utils/__init__.py b/utils/__init__.py index d2c7ecbdc..d342ebbd2 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -7,7 +7,7 @@ from .command_checks import CommandChecks from .message_sender_components import MessageSavingSenderComponent -from .msl import get_membership_count, is_student_id_member, GLOBAL_SSL_CONTEXT +from .msl import GLOBAL_SSL_CONTEXT, get_membership_count, is_student_id_member from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot from .tex_bot_base_cog import TeXBotBaseCog diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 495a6133a..610465b33 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -1,22 +1,20 @@ """MSL utility classes & functions provided for use across the whole of the project.""" -import certifi -import certifi import ssl - from typing import TYPE_CHECKING -GLOBAL_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) +import certifi + +GLOBAL_SSL_CONTEXT: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) from .memberships import get_full_membership_list, get_membership_count, is_student_id_member if TYPE_CHECKING: from collections.abc import Sequence - from typing import Final __all__: "Sequence[str]" = ( + "GLOBAL_SSL_CONTEXT", "get_full_membership_list", "get_membership_count", "is_student_id_member", - "GLOBAL_SSL_CONTEXT", ) diff --git a/utils/msl/core.py b/utils/msl/core.py index 09f84d930..7dbc014d9 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -9,6 +9,7 @@ from bs4 import BeautifulSoup from config import settings + from . import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 2e7f8d64c..6f55f9e7e 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,9 +7,8 @@ import bs4 from bs4 import BeautifulSoup -from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID - from . import GLOBAL_SSL_CONTEXT +from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID if TYPE_CHECKING: from collections.abc import Sequence From 797f94d96a66a9320d620d13c6556137b073e668 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:39:29 +0100 Subject: [PATCH 06/26] Fix --- cogs/make_member.py | 3 ++- utils/__init__.py | 9 +++++---- utils/msl/__init__.py | 5 ----- utils/msl/core.py | 2 +- utils/msl/memberships.py | 2 +- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index e043bceb0..e69581aec 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -10,7 +10,8 @@ from config import settings from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError -from utils import CommandChecks, TeXBotBaseCog, get_membership_count, is_student_id_member +from utils import CommandChecks, TeXBotBaseCog +from utils.msl import is_student_id_member, get_membership_count if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/utils/__init__.py b/utils/__init__.py index d342ebbd2..4cb08ccca 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -2,12 +2,12 @@ import asyncio from typing import TYPE_CHECKING - +import certifi +import ssl import discord from .command_checks import CommandChecks from .message_sender_components import MessageSavingSenderComponent -from .msl import GLOBAL_SSL_CONTEXT, get_membership_count, is_student_id_member from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot from .tex_bot_base_cog import TeXBotBaseCog @@ -27,10 +27,8 @@ "TeXBotAutocompleteContext", "TeXBotBaseCog", "generate_invite_url", - "get_membership_count", "is_member_inducted", "is_running_in_async", - "is_student_id_member", ) if TYPE_CHECKING: @@ -44,6 +42,9 @@ ) +GLOBAL_SSL_CONTEXT: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) + + def generate_invite_url(discord_bot_application_id: int, discord_guild_id: int) -> str: """Execute the logic that this util function provides.""" return discord.utils.oauth_url( diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 610465b33..34b0d6eaa 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -1,12 +1,7 @@ """MSL utility classes & functions provided for use across the whole of the project.""" -import ssl from typing import TYPE_CHECKING -import certifi - -GLOBAL_SSL_CONTEXT: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) - from .memberships import get_full_membership_list, get_membership_count, is_student_id_member if TYPE_CHECKING: diff --git a/utils/msl/core.py b/utils/msl/core.py index 7dbc014d9..056950746 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -10,7 +10,7 @@ from config import settings -from . import GLOBAL_SSL_CONTEXT +from utils import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 6f55f9e7e..bd89fd24c 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,7 +7,7 @@ import bs4 from bs4 import BeautifulSoup -from . import GLOBAL_SSL_CONTEXT +from utils import GLOBAL_SSL_CONTEXT from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID if TYPE_CHECKING: From 2ac933da4b5fcb26c01061cd900988f66a86e839 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:40:24 +0100 Subject: [PATCH 07/26] Reformat --- cogs/make_member.py | 2 +- utils/__init__.py | 3 ++- utils/msl/core.py | 1 - utils/msl/memberships.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index e69581aec..1d1b754db 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -11,7 +11,7 @@ from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError from utils import CommandChecks, TeXBotBaseCog -from utils.msl import is_student_id_member, get_membership_count +from utils.msl import get_membership_count, is_student_id_member if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/utils/__init__.py b/utils/__init__.py index 4cb08ccca..f9991cd35 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,9 +1,10 @@ """Utility classes & functions provided for use across the whole of the project.""" import asyncio +import ssl from typing import TYPE_CHECKING + import certifi -import ssl import discord from .command_checks import CommandChecks diff --git a/utils/msl/core.py b/utils/msl/core.py index 056950746..a6c528e81 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -9,7 +9,6 @@ from bs4 import BeautifulSoup from config import settings - from utils import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index bd89fd24c..69df00df7 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -8,6 +8,7 @@ from bs4 import BeautifulSoup from utils import GLOBAL_SSL_CONTEXT + from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID if TYPE_CHECKING: From 74e57a6a781bb401acd72758a96c70f57f2e5ae8 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:43:35 +0100 Subject: [PATCH 08/26] Revert accidental change --- utils/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/utils/__init__.py b/utils/__init__.py index f9991cd35..62c3eade4 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from collections.abc import Sequence + from typing import Final __all__: "Sequence[str]" = ( "GLOBAL_SSL_CONTEXT", @@ -42,8 +43,9 @@ | None ) - -GLOBAL_SSL_CONTEXT: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) +GLOBAL_SSL_CONTEXT: "Final[ssl.SSLContext]" = ssl.create_default_context( + cafile=certifi.where() +) def generate_invite_url(discord_bot_application_id: int, discord_guild_id: int) -> str: From fc6c1aa852f0aed3da3dd9da37428102299aebc5 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:44:13 +0100 Subject: [PATCH 09/26] Revert --- tests/test_utils.py | 70 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index cdbba412d..7d27e4aee 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,7 +4,7 @@ import re from typing import TYPE_CHECKING -from utils import generate_invite_url +import utils if TYPE_CHECKING: from collections.abc import Sequence @@ -12,6 +12,72 @@ __all__: "Sequence[str]" = () +# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 +# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 +# class TestPlotBarChart: +# """Test case to unit-test the plot_bar_chart function.""" +# +# def test_bar_chart_generates(self) -> None: +# """Test that the bar chart generates successfully when valid arguments are passed.""" # noqa: ERA001, E501, W505 +# FILENAME: Final[str] = "output_chart.png" # noqa: ERA001 +# DESCRIPTION: Final[str] = "Bar chart of the counted value of different roles." # noqa: ERA001, E501, W505 +# +# bar_chart_image: discord.File = plot_bar_chart( +# data={"role1": 5, "role2": 7}, # noqa: ERA001 +# x_label="Role Name", # noqa: ERA001 +# y_label="Counted value", # noqa: ERA001 +# title="Counted Value Of Each Role", # noqa: ERA001 +# filename=FILENAME, # noqa: ERA001 +# description=DESCRIPTION, # noqa: ERA001 +# extra_text="This is extra text" # noqa: ERA001 +# ) # noqa: ERA001, RUF100 +# +# assert bar_chart_image.filename == FILENAME # noqa: ERA001 +# assert bar_chart_image.description == DESCRIPTION # noqa: ERA001 +# assert bool(bar_chart_image.fp.read()) is True # noqa: ERA001 + + +# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 +# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 +# class TestAmountOfTimeFormatter: +# """Test case to unit-test the amount_of_time_formatter function.""" +# +# @pytest.mark.parametrize( +# "time_value", +# (1, 1.0, 0.999999, 1.000001) # noqa: ERA001 +# ) # noqa: ERA001, RUF100 +# def test_format_unit_value(self, time_value: float) -> None: +# """Test that a value of one only includes the time_scale.""" +# TIME_SCALE: Final[str] = "day" # noqa: ERA001 +# +# formatted_amount_of_time: str = amount_of_time_formatter(time_value, TIME_SCALE) # noqa: ERA001, E501, W505 +# +# assert formatted_amount_of_time == TIME_SCALE # noqa: ERA001 +# assert not formatted_amount_of_time.endswith("s") # noqa: ERA001 +# +# @pytest.mark.parametrize( +# "time_value", +# (*range(2, 21), 2.00, 0, 0.0, 25.0, -0, -0.0, -25.0) # noqa: ERA001 +# ) # noqa: ERA001, RUF100 +# def test_format_integer_value(self, time_value: float) -> None: +# """Test that an integer value includes the value and time_scale pluralized.""" +# TIME_SCALE: Final[str] = "day" # noqa: ERA001 +# +# assert amount_of_time_formatter( +# time_value, +# TIME_SCALE +# ) == f"{int(time_value)} {TIME_SCALE}s" +# +# @pytest.mark.parametrize("time_value", (3.14159, 0.005, 25.0333333)) +# def test_format_float_value(self, time_value: float) -> None: +# """Test that a float value includes the rounded value and time_scale pluralized.""" +# TIME_SCALE: Final[str] = "day" # noqa: ERA001 +# +# assert amount_of_time_formatter( +# time_value, +# TIME_SCALE +# ) == f"{time_value:.2f} {TIME_SCALE}s" + class TestGenerateInviteURL: """Test case to unit-test the generate_invite_url utility function.""" @@ -26,7 +92,7 @@ def test_url_generates() -> None: 10000000000000000, 99999999999999999999 ) - invite_url: str = generate_invite_url( + invite_url: str = utils.generate_invite_url( DISCORD_BOT_APPLICATION_ID, DISCORD_MAIN_GUILD_ID ) From f3248429689c3f1023e37f12b4632ece58e07f9e Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:56:34 +0100 Subject: [PATCH 10/26] Add logging --- utils/msl/memberships.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 69df00df7..aefe79f8b 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -48,7 +48,7 @@ async def get_full_membership_list() -> set[tuple[str, int]]: features="html.parser", ).find( name="table", - attrs={"id": "ctl00_Main_rptGroups_ctl03_gvMemberships"}, + attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships"}, ) all_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( @@ -56,7 +56,7 @@ async def get_full_membership_list() -> set[tuple[str, int]]: features="html.parser", ).find( name="table", - attrs={"id": "ctl00_Main_rptGroups_ctl05_gvMemberships"}, + attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships"}, ) if standard_members_table is None or all_members_table is None: @@ -103,6 +103,8 @@ async def is_student_id_member(student_id: str | int) -> bool: if str(student_id) in all_ids: return True + logger.debug("Student ID %s not found in cache, fetching updated list.", student_id) + new_ids: set[str] = {str(member[1]) for member in await get_full_membership_list()} return str(student_id) in new_ids From 00a71219b802e3ea022fe223eeab9b66fabfbaa2 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Mon, 1 Sep 2025 11:47:56 +0100 Subject: [PATCH 11/26] Simplify logic --- utils/msl/memberships.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index aefe79f8b..076914cfa 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -33,28 +33,20 @@ async def get_full_membership_list() -> set[tuple[str, int]]: """Get a list of tuples of student ID to names.""" - http_session: aiohttp.ClientSession = aiohttp.ClientSession( - headers=BASE_HEADERS, - cookies=BASE_COOKIES, - ) async with ( - http_session, + aiohttp.ClientSession(headers=BASE_HEADERS, cookies=BASE_COOKIES) as http_session, http_session.get(url=MEMBERS_LIST_URL, ssl=GLOBAL_SSL_CONTEXT) as http_response, ): response_html: str = await http_response.text() - standard_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - markup=response_html, - features="html.parser", - ).find( + parsed_html: BeautifulSoup = BeautifulSoup(markup=response_html, features="html.parser") + + standard_members_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( name="table", attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships"}, ) - all_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - markup=response_html, - features="html.parser", - ).find( + all_members_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( name="table", attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships"}, ) From e548fd1c587c0c6577a9a96f32cfc4fc263b3dff Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:47:58 +0100 Subject: [PATCH 12/26] Fixes --- utils/msl/__init__.py | 1 - utils/msl/core.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 34b0d6eaa..4f53df443 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -8,7 +8,6 @@ from collections.abc import Sequence __all__: "Sequence[str]" = ( - "GLOBAL_SSL_CONTEXT", "get_full_membership_list", "get_membership_count", "is_student_id_member", diff --git a/utils/msl/core.py b/utils/msl/core.py index a6c528e81..fc8b0efe9 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -26,15 +26,15 @@ DEFAULT_TIMEZONE: "Final[timezone]" = dt.UTC TODAYS_DATE: "Final[datetime]" = datetime.now(tz=DEFAULT_TIMEZONE) -CURRENT_YEAR_START_DATE: "Final[datetime]" = datetime( +CURRENT_ACADEMIC_YEAR_START_DATE: "Final[datetime]" = datetime( year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, month=7, day=1, tzinfo=DEFAULT_TIMEZONE, ) -CURRENT_YEAR_END_DATE: "Final[datetime]" = datetime( - year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, +CURRENT_ACADEMIC_YEAR_END_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year + 1 if TODAYS_DATE.month >= 7 else TODAYS_DATE.year, month=6, day=30, tzinfo=DEFAULT_TIMEZONE, From 2657358ed28c62fdf4ec93c05444374b08789e81 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:39:23 +0100 Subject: [PATCH 13/26] Fixes --- cogs/make_member.py | 33 +++++++------ utils/msl/__init__.py | 12 +++-- utils/msl/core.py | 84 ------------------------------- utils/msl/memberships.py | 103 +++++++++++++++++++++++++++------------ 4 files changed, 98 insertions(+), 134 deletions(-) delete mode 100644 utils/msl/core.py diff --git a/cogs/make_member.py b/cogs/make_member.py index 1d1b754db..a50b578fc 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -1,7 +1,6 @@ """Contains cog classes for any make_member interactions.""" import logging -import re from typing import TYPE_CHECKING import discord @@ -11,7 +10,7 @@ from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError from utils import CommandChecks, TeXBotBaseCog -from utils.msl import get_membership_count, is_student_id_member +from utils.msl import fetch_community_group_members_count, is_id_a_community_group_member if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -114,6 +113,20 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st member_role: discord.Role = await self.bot.member_role interaction_member: discord.Member = await ctx.bot.get_main_guild_member(ctx.user) + INVALID_GUILD_MEMBER_ID: Final[str] = ( + f"{group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." + ) + + try: + group_member_id_int: int = int(group_member_id) + except ValueError: + await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID) + return + + if group_member_id_int < 1000000 or group_member_id_int > 99999999: + await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID) + return + await ctx.defer(ephemeral=True) async with ctx.typing(): if member_role in interaction_member.roles: @@ -126,19 +139,9 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - if not re.fullmatch(r"\A\d{7}\Z", group_member_id): - await self.command_send_error( - ctx, - message=( - f"{group_member_id!r} is not a valid " - f"{self.bot.group_member_id_type} ID." - ), - ) - return - if await GroupMadeMember.objects.filter( hashed_group_member_id=GroupMadeMember.hash_group_member_id( - group_member_id, self.bot.group_member_id_type + group_member_id_int, self.bot.group_member_id_type ) ).aexists(): await ctx.followup.send( @@ -152,7 +155,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - if not await is_student_id_member(student_id=group_member_id): + if not await is_id_a_community_group_member(group_member_id_int): await self.command_send_error( ctx, message=( @@ -228,6 +231,6 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: await ctx.followup.send( content=( f"{self.bot.group_full_name} has " - f"{await get_membership_count()} members! :tada:" + f"{await fetch_community_group_members_count()} members! :tada:" ) ) diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 4f53df443..99e985e17 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -2,13 +2,17 @@ from typing import TYPE_CHECKING -from .memberships import get_full_membership_list, get_membership_count, is_student_id_member +from .memberships import ( + fetch_community_group_members_count, + fetch_community_group_members_list, + is_id_a_community_group_member, +) if TYPE_CHECKING: from collections.abc import Sequence __all__: "Sequence[str]" = ( - "get_full_membership_list", - "get_membership_count", - "is_student_id_member", + "fetch_community_group_members_count", + "fetch_community_group_members_list", + "is_id_a_community_group_member", ) diff --git a/utils/msl/core.py b/utils/msl/core.py deleted file mode 100644 index fc8b0efe9..000000000 --- a/utils/msl/core.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Functions to enable interaction with MSL based SU websites.""" - -import datetime as dt -import logging -from datetime import datetime -from typing import TYPE_CHECKING - -import aiohttp -from bs4 import BeautifulSoup - -from config import settings -from utils import GLOBAL_SSL_CONTEXT - -if TYPE_CHECKING: - from collections.abc import Mapping, Sequence - from datetime import timezone - from http.cookies import Morsel - from logging import Logger - from typing import Final - -__all__: "Sequence[str]" = () - -logger: "Final[Logger]" = logging.getLogger("TeX-Bot") - - -DEFAULT_TIMEZONE: "Final[timezone]" = dt.UTC -TODAYS_DATE: "Final[datetime]" = datetime.now(tz=DEFAULT_TIMEZONE) - -CURRENT_ACADEMIC_YEAR_START_DATE: "Final[datetime]" = datetime( - year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, - month=7, - day=1, - tzinfo=DEFAULT_TIMEZONE, -) - -CURRENT_ACADEMIC_YEAR_END_DATE: "Final[datetime]" = datetime( - year=TODAYS_DATE.year + 1 if TODAYS_DATE.month >= 7 else TODAYS_DATE.year, - month=6, - day=30, - tzinfo=DEFAULT_TIMEZONE, -) - -BASE_HEADERS: "Final[Mapping[str, str]]" = { - "Cache-Control": "no-cache", - "Pragma": "no-cache", - "Expires": "0", -} - -BASE_COOKIES: "Final[Mapping[str, str]]" = { - ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], -} - -ORGANISATION_ID: "Final[str]" = settings["ORGANISATION_ID"] - -ORGANISATION_ADMIN_URL: "Final[str]" = ( - f"https://www.guildofstudents.com/organisation/admin/{ORGANISATION_ID}/" -) - - -async def get_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: - """Get the required context headers, data and cookies to make a request to MSL.""" - http_session: aiohttp.ClientSession = aiohttp.ClientSession( - headers=BASE_HEADERS, - cookies=BASE_COOKIES, - ) - data_fields: dict[str, str] = {} - cookies: dict[str, str] = {} - async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: - data_response = BeautifulSoup( - markup=await field_data.text(), - features="html.parser", - ) - - for field in data_response.find_all(name="input"): - if field.get("name") and field.get("value"): - data_fields[field.get("name")] = field.get("value") - - for cookie in field_data.cookies: - cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) - if cookie_morsel is not None: - cookies[cookie] = cookie_morsel.value - cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] - - return data_fields, cookies diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 076914cfa..5b8f8168f 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,34 +7,80 @@ import bs4 from bs4 import BeautifulSoup +from config import settings from utils import GLOBAL_SSL_CONTEXT -from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID - if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence + from http.cookies import Morsel from logging import Logger from typing import Final + __all__: "Sequence[str]" = ( - "get_full_membership_list", - "get_membership_count", - "is_student_id_member", + "fetch_community_group_members_count", + "fetch_community_group_members_list", + "is_id_a_community_group_member", ) -MEMBERS_LIST_URL: "Final[str]" = ( - f"https://guildofstudents.com/organisation/memberlist/{ORGANISATION_ID}/?sort=groups" -) -membership_list_cache: set[tuple[str, int]] = set() +BASE_SU_PLATFORM_WEB_HEADERS: "Final[Mapping[str, str]]" = { + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Expires": "0", +} + + +BASE_SU_PLATFORM_WEB_COOKIES: "Final[Mapping[str, str]]" = { + ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], +} + + +MEMBERS_LIST_URL: "Final[str]" = f"https://guildofstudents.com/organisation/memberlist/{settings['ORGANISATION_ID']}/?sort=groups" + +_membership_list_cache: "Final[dict[int, str]]" = {} # NOTE: Mapping of IDs to names + logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -async def get_full_membership_list() -> set[tuple[str, int]]: - """Get a list of tuples of student ID to names.""" +async def fetch_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: + """Get the required context headers, data and cookies to make a request to MSL.""" + http_session: aiohttp.ClientSession = aiohttp.ClientSession( + headers=BASE_SU_PLATFORM_WEB_HEADERS, + cookies=BASE_SU_PLATFORM_WEB_COOKIES, + ) + data_fields: dict[str, str] = {} + cookies: dict[str, str] = {} + async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: + data_response = BeautifulSoup( + markup=await field_data.text(), + features="html.parser", + ) + + for field in data_response.find_all(name="input"): + if field.get("name") and field.get("value"): + data_fields[field.get("name")] = field.get("value") + + for cookie in field_data.cookies: + cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) + if cookie_morsel is not None: + cookies[cookie] = cookie_morsel.value + cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] + + return data_fields, cookies + + +async def fetch_community_group_members_list() -> set[tuple[str, int]]: + """ + Make a web request to fetch your community group's full membership list. + + Returns a mapping of IDs to names. + """ async with ( - aiohttp.ClientSession(headers=BASE_HEADERS, cookies=BASE_COOKIES) as http_session, + aiohttp.ClientSession( + headers=BASE_SU_PLATFORM_WEB_HEADERS, cookies=BASE_SU_PLATFORM_WEB_COOKIES + ) as http_session, http_session.get(url=MEMBERS_LIST_URL, ssl=GLOBAL_SSL_CONTEXT) as http_response, ): response_html: str = await http_response.text() @@ -66,11 +112,8 @@ async def get_full_membership_list() -> set[tuple[str, int]]: logger.debug(all_members_table) return set() - standard_members: list[bs4.Tag] = standard_members_table.find_all(name="tr") - all_members: list[bs4.Tag] = all_members_table.find_all(name="tr") - - standard_members.pop(0) - all_members.pop(0) + standard_members: list[bs4.Tag] = standard_members_table.find_all(name="tr")[1:] + all_members: list[bs4.Tag] = all_members_table.find_all(name="tr")[1:] member_list: set[tuple[str, int]] = { ( @@ -82,26 +125,24 @@ async def get_full_membership_list() -> set[tuple[str, int]]: for member in standard_members + all_members } - membership_list_cache.clear() - membership_list_cache.update(member_list) + _membership_list_cache.clear() + _membership_list_cache.update({member[1]: member[0] for member in member_list}) return member_list -async def is_student_id_member(student_id: str | int) -> bool: +async def is_id_a_community_group_member(_id: int) -> bool: """Check if the student ID is a member of the society.""" - all_ids: set[str] = {str(member[1]) for member in membership_list_cache} - - if str(student_id) in all_ids: + if _id in _membership_list_cache: return True - logger.debug("Student ID %s not found in cache, fetching updated list.", student_id) - - new_ids: set[str] = {str(member[1]) for member in await get_full_membership_list()} + logger.debug( + "ID %s not found in community group membership list cache; Fetching updated list.", _id + ) - return str(student_id) in new_ids + return _id in await fetch_community_group_members_list() # type: ignore[comparison-overlap] -async def get_membership_count() -> int: - """Return the total number of members.""" - return len(await get_full_membership_list()) +async def fetch_community_group_members_count() -> int: + """Return the total number of members in your community group.""" + return len(await fetch_community_group_members_list()) From a6d17353c77b8fdf6308a40e372fae9ab7b36c01 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:41:18 +0100 Subject: [PATCH 14/26] Docs --- utils/msl/memberships.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 5b8f8168f..887bb1d79 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -132,7 +132,7 @@ async def fetch_community_group_members_list() -> set[tuple[str, int]]: async def is_id_a_community_group_member(_id: int) -> bool: - """Check if the student ID is a member of the society.""" + """Check if the given ID is a member of your community group.""" if _id in _membership_list_cache: return True From 6f7c436a591d5ae16ed9e727dae0a6c02a715ac0 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 7 Sep 2025 16:50:38 +0100 Subject: [PATCH 15/26] do some stuff --- cogs/make_member.py | 4 ++-- utils/msl/memberships.py | 40 ++++++++++++++++++---------------------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index a50b578fc..ea5377495 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -155,9 +155,9 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - if not await is_id_a_community_group_member(group_member_id_int): + if not await is_id_a_community_group_member(student_id=group_member_id_int): await self.command_send_error( - ctx, + ctx=ctx, message=( f"You must be a member of {self.bot.group_full_name} " "to use this command.\n" diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 887bb1d79..c468d11d2 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -1,5 +1,6 @@ """Module for checking membership status.""" +import contextlib import logging from typing import TYPE_CHECKING @@ -38,7 +39,8 @@ MEMBERS_LIST_URL: "Final[str]" = f"https://guildofstudents.com/organisation/memberlist/{settings['ORGANISATION_ID']}/?sort=groups" -_membership_list_cache: "Final[dict[int, str]]" = {} # NOTE: Mapping of IDs to names + +_membership_list_cache: set[int] = set() logger: "Final[Logger]" = logging.getLogger("TeX-Bot") @@ -71,11 +73,11 @@ async def fetch_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: return data_fields, cookies -async def fetch_community_group_members_list() -> set[tuple[str, int]]: +async def fetch_community_group_members_list() -> set[int]: """ Make a web request to fetch your community group's full membership list. - Returns a mapping of IDs to names. + Returns a set of IDs. """ async with ( aiohttp.ClientSession( @@ -112,36 +114,30 @@ async def fetch_community_group_members_list() -> set[tuple[str, int]]: logger.debug(all_members_table) return set() - standard_members: list[bs4.Tag] = standard_members_table.find_all(name="tr")[1:] - all_members: list[bs4.Tag] = all_members_table.find_all(name="tr")[1:] - - member_list: set[tuple[str, int]] = { - ( - member.find_all(name="td")[0].text.strip(), - member.find_all(name="td")[ - 1 - ].text.strip(), # NOTE: This will not properly handle external members who do not have an ID... There does not appear to be a solution to this other than simply checking manually. + with contextlib.suppress(IndexError): + all_rows: list[bs4.Tag] = ( + standard_members_table.find_all(name="tr")[1:] + + all_members_table.find_all(name="tr")[1:] ) - for member in standard_members + all_members - } - _membership_list_cache.clear() - _membership_list_cache.update({member[1]: member[0] for member in member_list}) + for member in all_rows: + with contextlib.suppress(ValueError): + _membership_list_cache.add(int(member.find_all(name="td")[1].text.strip())) - return member_list + return _membership_list_cache -async def is_id_a_community_group_member(_id: int) -> bool: +async def is_id_a_community_group_member(student_id: int) -> bool: """Check if the given ID is a member of your community group.""" - if _id in _membership_list_cache: + if student_id in _membership_list_cache: return True logger.debug( - "ID %s not found in community group membership list cache; Fetching updated list.", _id + "ID %s not found in community group membership list cache; Fetching updated list.", + student_id, ) - return _id in await fetch_community_group_members_list() # type: ignore[comparison-overlap] - + return student_id in await fetch_community_group_members_list() async def fetch_community_group_members_count() -> int: """Return the total number of members in your community group.""" From 01c9e14dcd4e7e437044cb3f2c0aba6c1410d0aa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 15:55:25 +0000 Subject: [PATCH 16/26] [pre-commit.ci lite] apply automatic fixes --- utils/msl/memberships.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index c468d11d2..fa8640b78 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -139,6 +139,7 @@ async def is_id_a_community_group_member(student_id: int) -> bool: return student_id in await fetch_community_group_members_list() + async def fetch_community_group_members_count() -> int: """Return the total number of members in your community group.""" return len(await fetch_community_group_members_list()) From 72c98760b8d144249ba40e090e544d2e60cf4de6 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:27:13 +0100 Subject: [PATCH 17/26] Implement custom exception --- exceptions/__init__.py | 2 ++ exceptions/msl.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 exceptions/msl.py diff --git a/exceptions/__init__.py b/exceptions/__init__.py index 4fc98909d..dc3c44ced 100644 --- a/exceptions/__init__.py +++ b/exceptions/__init__.py @@ -24,6 +24,7 @@ MessagesJSONFileMissingKeyError, MessagesJSONFileValueError, ) +from .msl import MSLMembershipError from .strike import NoAuditLogsStrikeTrackingError, StrikeTrackingError if TYPE_CHECKING: @@ -44,6 +45,7 @@ "InvalidActionDescriptionError", "InvalidActionTargetError", "InvalidMessagesJSONFileError", + "MSLMembershipError", "MemberRoleDoesNotExistError", "MessagesJSONFileMissingKeyError", "MessagesJSONFileValueError", diff --git a/exceptions/msl.py b/exceptions/msl.py new file mode 100644 index 000000000..6f7680758 --- /dev/null +++ b/exceptions/msl.py @@ -0,0 +1,26 @@ +"""Custom exception classes raised when errors occur during use of MSL features.""" + +from typing import TYPE_CHECKING, override + +from typed_classproperties import classproperty + +from .base import BaseTeXBotError + +if TYPE_CHECKING: + from collections.abc import Sequence + + +__all__: "Sequence[str]" = ("MSLMembershipError",) + + +class MSLMembershipError(BaseTeXBotError, RuntimeError): + """ + Exception class to raise when any error occurs while checking MSL membership. + + If this error occurs, it is likely that MSL features will not work correctly. + """ + + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "An error occurred while trying to fetch membership data from MSL." From b0f165ac6297144ae26b131fc0c7c6a9d971b060 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:31:22 +0100 Subject: [PATCH 18/26] Use the new exception --- utils/msl/memberships.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index fa8640b78..3e098bcd2 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -10,6 +10,7 @@ from config import settings from utils import GLOBAL_SSL_CONTEXT +from exceptions import MSLMembershipError if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -100,19 +101,21 @@ async def fetch_community_group_members_list() -> set[int]: ) if standard_members_table is None or all_members_table is None: - logger.warning("One or both of the membership tables could not be found!") + MEMBER_TABLE_ERROR: Final[str] = "One or both membership tables could not be found." + logger.warning(MEMBER_TABLE_ERROR) logger.debug(response_html) - return set() + raise MSLMembershipError(message=MEMBER_TABLE_ERROR) if isinstance(standard_members_table, bs4.NavigableString) or isinstance( all_members_table, bs4.NavigableString ): - logger.warning( - "Both membership tables were found but one or both are the wrong format!", + MEMBER_TABLE_FORMAT_ERROR: Final[str] = ( + "Both membership tables were found but one or both were in the wrong format." ) + logger.warning(MEMBER_TABLE_FORMAT_ERROR) logger.debug(standard_members_table) logger.debug(all_members_table) - return set() + raise MSLMembershipError(message=MEMBER_TABLE_FORMAT_ERROR) with contextlib.suppress(IndexError): all_rows: list[bs4.Tag] = ( From 2df510a33ee3f4566e585ea45da8b3fc977963eb Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:46:11 +0100 Subject: [PATCH 19/26] Refactor --- utils/msl/memberships.py | 71 +++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 3e098bcd2..a0a3bb1dc 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -9,8 +9,8 @@ from bs4 import BeautifulSoup from config import settings -from utils import GLOBAL_SSL_CONTEXT from exceptions import MSLMembershipError +from utils import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -90,42 +90,47 @@ async def fetch_community_group_members_list() -> set[int]: parsed_html: BeautifulSoup = BeautifulSoup(markup=response_html, features="html.parser") - standard_members_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( - name="table", - attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships"}, - ) - - all_members_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( - name="table", - attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships"}, - ) - - if standard_members_table is None or all_members_table is None: - MEMBER_TABLE_ERROR: Final[str] = "One or both membership tables could not be found." - logger.warning(MEMBER_TABLE_ERROR) - logger.debug(response_html) - raise MSLMembershipError(message=MEMBER_TABLE_ERROR) + member_ids: set[int] = set() - if isinstance(standard_members_table, bs4.NavigableString) or isinstance( - all_members_table, bs4.NavigableString + table_id: str + for table_id in ( + "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships", + "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships", ): - MEMBER_TABLE_FORMAT_ERROR: Final[str] = ( - "Both membership tables were found but one or both were in the wrong format." - ) - logger.warning(MEMBER_TABLE_FORMAT_ERROR) - logger.debug(standard_members_table) - logger.debug(all_members_table) - raise MSLMembershipError(message=MEMBER_TABLE_FORMAT_ERROR) - - with contextlib.suppress(IndexError): - all_rows: list[bs4.Tag] = ( - standard_members_table.find_all(name="tr")[1:] - + all_members_table.find_all(name="tr")[1:] + filtered_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( + name="table", attrs={"id": table_id} ) - for member in all_rows: - with contextlib.suppress(ValueError): - _membership_list_cache.add(int(member.find_all(name="td")[1].text.strip())) + if filtered_table is None: + MEMBER_TABLE_ERROR: str = ( + f"Membership table with ID {table_id} could not be found." + ) + logger.warning(MEMBER_TABLE_ERROR) + logger.debug(response_html) + continue + + if isinstance(filtered_table, bs4.NavigableString): + MEMBER_TABLE_FORMAT_ERROR: str = ( + f"Membership table with ID {table_id} was found but is in the wrong format." + ) + logger.warning(MEMBER_TABLE_FORMAT_ERROR) + logger.debug(filtered_table) + raise MSLMembershipError(message=MEMBER_TABLE_FORMAT_ERROR) + + with contextlib.suppress(IndexError): + rows: list[bs4.Tag] = filtered_table.find_all(name="tr")[1:] + for member in rows: + with contextlib.suppress(ValueError): + member_ids.add(int(member.find_all(name="td")[1].text.strip())) + + if not member_ids: # NOTE: this should never be possible, because to fetch the page you need to have admin access, which requires being a member. + NO_MEMBERS_ERROR: str = "No members were found in either membership table." + logger.warning(NO_MEMBERS_ERROR) + logger.debug(response_html) + raise MSLMembershipError(message=NO_MEMBERS_ERROR) + + _membership_list_cache.clear() + _membership_list_cache.update(member_ids) return _membership_list_cache From 23082734c5e9da932de6ed7dec6342f21df563fa Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:31:57 +0100 Subject: [PATCH 20/26] Fixes from review --- cogs/make_member.py | 42 +++++++++++---------------- utils/msl/memberships.py | 62 ++++++++++++---------------------------- 2 files changed, 34 insertions(+), 70 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index ea5377495..a3c5c7d7a 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -13,16 +13,19 @@ from utils.msl import fetch_community_group_members_count, is_id_a_community_group_member if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Sequence from logging import Logger from typing import Final from utils import TeXBotApplicationContext + __all__: "Sequence[str]" = ("MakeMemberCommandCog", "MemberCountCommandCog") + logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + _GROUP_MEMBER_ID_ARGUMENT_DESCRIPTIVE_NAME: "Final[str]" = f"""{ "Student" if ( @@ -46,21 +49,6 @@ _GROUP_MEMBER_ID_ARGUMENT_DESCRIPTIVE_NAME.lower().replace(" ", "") ) -REQUEST_HEADERS: "Final[Mapping[str, str]]" = { - "Cache-Control": "no-cache", - "Pragma": "no-cache", - "Expires": "0", -} - -REQUEST_COOKIES: "Final[Mapping[str, str]]" = { - ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"] -} - -BASE_MEMBERS_URL: "Final[str]" = ( - f"https://guildofstudents.com/organisation/memberlist/{settings['ORGANISATION_ID']}" -) -GROUPED_MEMBERS_URL: "Final[str]" = f"{BASE_MEMBERS_URL}/?sort=groups" - class MakeMemberCommandCog(TeXBotBaseCog): """Cog class that defines the "/make-member" command and its call-back method.""" @@ -101,7 +89,9 @@ class MakeMemberCommandCog(TeXBotBaseCog): parameter_name="group_member_id", ) @CommandChecks.check_interaction_user_in_main_guild - async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: str) -> None: # type: ignore[misc] + async def make_member( # type: ignore[misc] + self, ctx: "TeXBotApplicationContext", raw_group_member_id: str + ) -> None: """ Definition & callback response of the "make_member" command. @@ -113,18 +103,18 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st member_role: discord.Role = await self.bot.member_role interaction_member: discord.Member = await ctx.bot.get_main_guild_member(ctx.user) - INVALID_GUILD_MEMBER_ID: Final[str] = ( - f"{group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." + INVALID_GUILD_MEMBER_ID_MESSAGE: Final[str] = ( + f"{raw_group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." ) try: - group_member_id_int: int = int(group_member_id) + group_member_id: int = int(raw_group_member_id) except ValueError: - await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID) + await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) return - if group_member_id_int < 1000000 or group_member_id_int > 99999999: - await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID) + if group_member_id < 1000000 or group_member_id > 99999999: + await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) return await ctx.defer(ephemeral=True) @@ -141,7 +131,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st if await GroupMadeMember.objects.filter( hashed_group_member_id=GroupMadeMember.hash_group_member_id( - group_member_id_int, self.bot.group_member_id_type + group_member_id, self.bot.group_member_id_type ) ).aexists(): await ctx.followup.send( @@ -155,7 +145,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - if not await is_id_a_community_group_member(student_id=group_member_id_int): + if not await is_id_a_community_group_member(member_id=group_member_id): await self.command_send_error( ctx=ctx, message=( @@ -174,7 +164,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) try: - await GroupMadeMember.objects.acreate(group_member_id=group_member_id) # type: ignore[misc] + await GroupMadeMember.objects.acreate(group_member_id=raw_group_member_id) # type: ignore[misc] except ValidationError as create_group_made_member_error: error_is_already_exists: bool = ( "hashed_group_member_id" in create_group_made_member_error.message_dict diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index a0a3bb1dc..bbe5703d4 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: from collections.abc import Mapping, Sequence - from http.cookies import Morsel from logging import Logger from typing import Final @@ -47,33 +46,6 @@ logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -async def fetch_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: - """Get the required context headers, data and cookies to make a request to MSL.""" - http_session: aiohttp.ClientSession = aiohttp.ClientSession( - headers=BASE_SU_PLATFORM_WEB_HEADERS, - cookies=BASE_SU_PLATFORM_WEB_COOKIES, - ) - data_fields: dict[str, str] = {} - cookies: dict[str, str] = {} - async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: - data_response = BeautifulSoup( - markup=await field_data.text(), - features="html.parser", - ) - - for field in data_response.find_all(name="input"): - if field.get("name") and field.get("value"): - data_fields[field.get("name")] = field.get("value") - - for cookie in field_data.cookies: - cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) - if cookie_morsel is not None: - cookies[cookie] = cookie_morsel.value - cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] - - return data_fields, cookies - - async def fetch_community_group_members_list() -> set[int]: """ Make a web request to fetch your community group's full membership list. @@ -102,32 +74,34 @@ async def fetch_community_group_members_list() -> set[int]: ) if filtered_table is None: - MEMBER_TABLE_ERROR: str = ( - f"Membership table with ID {table_id} could not be found." - ) - logger.warning(MEMBER_TABLE_ERROR) + logger.warning("Membership table with ID %s could not be found.", table_id) logger.debug(response_html) continue if isinstance(filtered_table, bs4.NavigableString): - MEMBER_TABLE_FORMAT_ERROR: str = ( + INVALID_MEMBER_TABLE_FORMAT_MESSAGE: str = ( f"Membership table with ID {table_id} was found but is in the wrong format." ) - logger.warning(MEMBER_TABLE_FORMAT_ERROR) + logger.warning(INVALID_MEMBER_TABLE_FORMAT_MESSAGE) logger.debug(filtered_table) - raise MSLMembershipError(message=MEMBER_TABLE_FORMAT_ERROR) + raise MSLMembershipError(message=INVALID_MEMBER_TABLE_FORMAT_MESSAGE) with contextlib.suppress(IndexError): rows: list[bs4.Tag] = filtered_table.find_all(name="tr")[1:] for member in rows: - with contextlib.suppress(ValueError): - member_ids.add(int(member.find_all(name="td")[1].text.strip())) + raw_id: str = member.find_all(name="td")[1].text.strip() + try: + member_ids.add(int(raw_id)) + except ValueError: + logger.warning( + "Failed to convert ID '%s' in membership table to an integer", raw_id + ) if not member_ids: # NOTE: this should never be possible, because to fetch the page you need to have admin access, which requires being a member. - NO_MEMBERS_ERROR: str = "No members were found in either membership table." - logger.warning(NO_MEMBERS_ERROR) + NO_MEMBERS_MESSAGE: Final[str] = "No members were found in either membership table." + logger.warning(NO_MEMBERS_MESSAGE) logger.debug(response_html) - raise MSLMembershipError(message=NO_MEMBERS_ERROR) + raise MSLMembershipError(message=NO_MEMBERS_MESSAGE) _membership_list_cache.clear() _membership_list_cache.update(member_ids) @@ -135,17 +109,17 @@ async def fetch_community_group_members_list() -> set[int]: return _membership_list_cache -async def is_id_a_community_group_member(student_id: int) -> bool: +async def is_id_a_community_group_member(member_id: int) -> bool: """Check if the given ID is a member of your community group.""" - if student_id in _membership_list_cache: + if member_id in _membership_list_cache: return True logger.debug( "ID %s not found in community group membership list cache; Fetching updated list.", - student_id, + member_id, ) - return student_id in await fetch_community_group_members_list() + return member_id in await fetch_community_group_members_list() async def fetch_community_group_members_count() -> int: From 07f8b8e6ae2c94b8669014eef8181aeab9e43614 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:57:18 +0100 Subject: [PATCH 21/26] Fix spaces --- utils/msl/memberships.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index bbe5703d4..5402fd2a3 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -31,15 +31,12 @@ "Expires": "0", } - BASE_SU_PLATFORM_WEB_COOKIES: "Final[Mapping[str, str]]" = { ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], } - MEMBERS_LIST_URL: "Final[str]" = f"https://guildofstudents.com/organisation/memberlist/{settings['ORGANISATION_ID']}/?sort=groups" - _membership_list_cache: set[int] = set() From 0e530bacebf16af486302922a42eb29b24e107cb Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:58:14 +0100 Subject: [PATCH 22/26] Move logger up --- utils/msl/memberships.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 5402fd2a3..3a97360e0 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -25,6 +25,9 @@ ) +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + BASE_SU_PLATFORM_WEB_HEADERS: "Final[Mapping[str, str]]" = { "Cache-Control": "no-cache", "Pragma": "no-cache", @@ -40,9 +43,6 @@ _membership_list_cache: set[int] = set() -logger: "Final[Logger]" = logging.getLogger("TeX-Bot") - - async def fetch_community_group_members_list() -> set[int]: """ Make a web request to fetch your community group's full membership list. From 01490c9cf3d7b67c350c9efee6a227eb9e5888c3 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:07:57 +0100 Subject: [PATCH 23/26] yeet args --- cogs/make_member.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index a3c5c7d7a..6eee69eae 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -110,11 +110,11 @@ async def make_member( # type: ignore[misc] try: group_member_id: int = int(raw_group_member_id) except ValueError: - await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) + await self.command_send_error(ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) return if group_member_id < 1000000 or group_member_id > 99999999: - await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) + await self.command_send_error(ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) return await ctx.defer(ephemeral=True) @@ -147,7 +147,7 @@ async def make_member( # type: ignore[misc] if not await is_id_a_community_group_member(member_id=group_member_id): await self.command_send_error( - ctx=ctx, + ctx, message=( f"You must be a member of {self.bot.group_full_name} " "to use this command.\n" From ca2f66c8bc1356a2c93e1b8120304796f5666cf4 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:19:07 +0100 Subject: [PATCH 24/26] Fix regex check --- cogs/make_member.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 6eee69eae..180801109 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -1,6 +1,7 @@ """Contains cog classes for any make_member interactions.""" import logging +import re from typing import TYPE_CHECKING import discord @@ -107,16 +108,19 @@ async def make_member( # type: ignore[misc] f"{raw_group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." ) + if not re.fullmatch(r"\A\d{7}\Z", raw_group_member_id): + await self.command_send_error( + ctx, + message=(INVALID_GUILD_MEMBER_ID_MESSAGE) + ) + return + try: group_member_id: int = int(raw_group_member_id) except ValueError: await self.command_send_error(ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) return - if group_member_id < 1000000 or group_member_id > 99999999: - await self.command_send_error(ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) - return - await ctx.defer(ephemeral=True) async with ctx.typing(): if member_role in interaction_member.roles: From a3673e4b6596e67a37a4405b0164df709b3d9070 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:19:54 +0000 Subject: [PATCH 25/26] [pre-commit.ci lite] apply automatic fixes --- cogs/make_member.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 180801109..245c13576 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -109,11 +109,8 @@ async def make_member( # type: ignore[misc] ) if not re.fullmatch(r"\A\d{7}\Z", raw_group_member_id): - await self.command_send_error( - ctx, - message=(INVALID_GUILD_MEMBER_ID_MESSAGE) - ) - return + await self.command_send_error(ctx, message=(INVALID_GUILD_MEMBER_ID_MESSAGE)) + return try: group_member_id: int = int(raw_group_member_id) From bfc561f26a0c95560f8e5635f373ece897c278bd Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:01:59 +0100 Subject: [PATCH 26/26] review fixes --- cogs/make_member.py | 8 ++++---- utils/msl/memberships.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 245c13576..ad3ace56b 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -87,7 +87,7 @@ class MakeMemberCommandCog(TeXBotBaseCog): required=True, max_length=7, min_length=7, - parameter_name="group_member_id", + parameter_name="raw_group_member_id", ) @CommandChecks.check_interaction_user_in_main_guild async def make_member( # type: ignore[misc] @@ -104,18 +104,18 @@ async def make_member( # type: ignore[misc] member_role: discord.Role = await self.bot.member_role interaction_member: discord.Member = await ctx.bot.get_main_guild_member(ctx.user) - INVALID_GUILD_MEMBER_ID_MESSAGE: Final[str] = ( + INVALID_GROUP_MEMBER_ID_MESSAGE: Final[str] = ( f"{raw_group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." ) if not re.fullmatch(r"\A\d{7}\Z", raw_group_member_id): - await self.command_send_error(ctx, message=(INVALID_GUILD_MEMBER_ID_MESSAGE)) + await self.command_send_error(ctx, message=(INVALID_GROUP_MEMBER_ID_MESSAGE)) return try: group_member_id: int = int(raw_group_member_id) except ValueError: - await self.command_send_error(ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) + await self.command_send_error(ctx, message=INVALID_GROUP_MEMBER_ID_MESSAGE) return await ctx.defer(ephemeral=True) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 3a97360e0..99ceb920e 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -84,8 +84,8 @@ async def fetch_community_group_members_list() -> set[int]: raise MSLMembershipError(message=INVALID_MEMBER_TABLE_FORMAT_MESSAGE) with contextlib.suppress(IndexError): - rows: list[bs4.Tag] = filtered_table.find_all(name="tr")[1:] - for member in rows: + member: bs4.Tag + for member in filtered_table.find_all(name="tr")[1:]: raw_id: str = member.find_all(name="td")[1].text.strip() try: member_ids.add(int(raw_id)) @@ -107,7 +107,7 @@ async def fetch_community_group_members_list() -> set[int]: async def is_id_a_community_group_member(member_id: int) -> bool: - """Check if the given ID is a member of your community group.""" + """Check whether the given ID is a member of your community group.""" if member_id in _membership_list_cache: return True