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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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 363e315930b8804d97094842cf0d71259a3e7065 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:35:20 +0100 Subject: [PATCH 12/15] Refactor auth token checking --- cogs/check_su_platform_authorisation.py | 122 ++------------------- utils/msl/__init__.py | 3 + utils/msl/authorisation.py | 135 ++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 115 deletions(-) create mode 100644 utils/msl/authorisation.py diff --git a/cogs/check_su_platform_authorisation.py b/cogs/check_su_platform_authorisation.py index fc8bc0a0a..d9e311c53 100644 --- a/cogs/check_su_platform_authorisation.py +++ b/cogs/check_su_platform_authorisation.py @@ -4,19 +4,18 @@ from enum import Enum from typing import TYPE_CHECKING, override -import aiohttp -import bs4 import discord from discord.ext import tasks from config import settings -from utils import GLOBAL_SSL_CONTEXT, CommandChecks, TeXBotBaseCog +from utils import CommandChecks, TeXBotBaseCog from utils.error_capture_decorators import ( capture_guild_does_not_exist_error, ) +from utils.msl import get_su_platform_access_cookie_status, get_su_platform_organisations if TYPE_CHECKING: - from collections.abc import Iterable, Mapping, Sequence + from collections.abc import Mapping, Sequence from collections.abc import Set as AbstractSet from logging import Logger from typing import Final @@ -72,114 +71,7 @@ class SUPlatformAccessCookieStatus(Enum): ) -class CheckSUPlatformAuthorisationBaseCog(TeXBotBaseCog): - """Cog class that defines the base functionality for cookie authorisation checks.""" - - async def _fetch_url_content_with_session(self, url: str) -> str: - """Fetch the HTTP content at the given URL, using a shared aiohttp session.""" - async with ( - aiohttp.ClientSession( - headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES - ) as http_session, - http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as http_response, - ): - return await http_response.text() - - async def get_su_platform_access_cookie_status(self) -> SUPlatformAccessCookieStatus: - """Retrieve the current validity status of the SU platform access cookie.""" - response_object: bs4.BeautifulSoup = bs4.BeautifulSoup( - await self._fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser" - ) - page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title") - if not page_title or "Login" in str(page_title): - logger.debug("Token is invalid or expired.") - return SUPlatformAccessCookieStatus.INVALID - - organisation_admin_url: str = ( - f"{SU_PLATFORM_ORGANISATION_URL}/{settings['ORGANISATION_ID']}" - ) - response_html: str = await self._fetch_url_content_with_session(organisation_admin_url) - - if "admin tools" in response_html.lower(): - return SUPlatformAccessCookieStatus.AUTHORISED - - if "You do not have any permissions for this organisation" in response_html.lower(): - return SUPlatformAccessCookieStatus.VALID - - logger.warning( - "Unexpected response when checking SU platform access cookie authorisation." - ) - return SUPlatformAccessCookieStatus.INVALID - - async def get_su_platform_organisations(self) -> "Iterable[str]": - """Retrieve the MSL organisations the current SU platform cookie has access to.""" - response_object: bs4.BeautifulSoup = bs4.BeautifulSoup( - await self._fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser" - ) - - page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title") - - if not page_title: - logger.warning( - "Profile page returned no content when checking " - "SU platform access cookie's authorisation." - ) - return () - - if "Login" in str(page_title): - logger.warning( - "Authentication redirected to login page. " - "SU platform access cookie is invalid or expired." - ) - return () - - profile_section_html: bs4.Tag | bs4.NavigableString | None = response_object.find( - "div", {"id": "profile_main"} - ) - - if profile_section_html is None: - logger.warning( - "Couldn't find the profile section of the user " - "when scraping the SU platform's website HTML." - ) - logger.debug("Retrieved HTML: %s", response_object.text) - return () - - user_name: bs4.Tag | bs4.NavigableString | int | None = profile_section_html.find("h1") - - if not isinstance(user_name, bs4.Tag): - logger.warning( - "Found user profile on the SU platform but couldn't find their name." - ) - logger.debug("Retrieved HTML: %s", response_object.text) - return () - - parsed_html: bs4.Tag | bs4.NavigableString | None = response_object.find( - "ul", {"id": "ulOrgs"} - ) - - if parsed_html is None or isinstance(parsed_html, bs4.NavigableString): - NO_ADMIN_TABLE_MESSAGE: Final[str] = ( - f"Failed to retrieve the admin table for user: {user_name.string}. " - "Please check you have used the correct SU platform access token!" - ) - logger.warning(NO_ADMIN_TABLE_MESSAGE) - return () - - organisations: Iterable[str] = [ - list_item.get_text(strip=True) for list_item in parsed_html.find_all("li") - ] - - logger.debug( - "SU platform access cookie has admin authorisation to: %s as user %s", - organisations, - user_name.text, - ) - - return organisations - - -class CheckSUPlatformAuthorisationCommandCog(CheckSUPlatformAuthorisationBaseCog): +class CheckSUPlatformAuthorisationCommandCog(TeXBotBaseCog): """Cog class that defines the "/check-su-platform-authorisation" command.""" @discord.slash_command( # type: ignore[no-untyped-call, misc] @@ -200,7 +92,7 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext") async with ctx.typing(): su_platform_access_cookie_organisations: AbstractSet[str] = set( - await self.get_su_platform_organisations() + await get_su_platform_organisations() ) await ctx.followup.send( @@ -223,7 +115,7 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext") ) -class CheckSUPlatformAuthorisationTaskCog(CheckSUPlatformAuthorisationBaseCog): +class CheckSUPlatformAuthorisationTaskCog(TeXBotBaseCog): """Cog class defining a repeated task for checking SU platform access cookie.""" @override @@ -255,7 +147,7 @@ async def su_platform_access_cookie_check_task(self) -> None: logger.debug("Running SU platform access cookie check task...") su_platform_access_cookie_status: tuple[int, str] = ( - await self.get_su_platform_access_cookie_status() + await get_su_platform_access_cookie_status() ).value logger.log( diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 34b0d6eaa..c7c8bd213 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from .authorisation import get_su_platform_access_cookie_status, get_su_platform_organisations from .memberships import get_full_membership_list, get_membership_count, is_student_id_member if TYPE_CHECKING: @@ -11,5 +12,7 @@ "GLOBAL_SSL_CONTEXT", "get_full_membership_list", "get_membership_count", + "get_su_platform_access_cookie_status", + "get_su_platform_organisations", "is_student_id_member", ) diff --git a/utils/msl/authorisation.py b/utils/msl/authorisation.py new file mode 100644 index 000000000..eeb63b469 --- /dev/null +++ b/utils/msl/authorisation.py @@ -0,0 +1,135 @@ +"""Module for authorisation checks.""" + +import logging +from typing import TYPE_CHECKING + +import aiohttp +import bs4 + +from cogs.check_su_platform_authorisation import SUPlatformAccessCookieStatus +from config import settings +from utils import GLOBAL_SSL_CONTEXT + +from .core import BASE_COOKIES, BASE_HEADERS + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from logging import Logger + from typing import Final + + +__all__: "Sequence[str]" = ( + "get_su_platform_access_cookie_status", + "get_su_platform_organisations", +) + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +SU_PLATFORM_PROFILE_URL: "Final[str]" = "https://guildofstudents.com/profile" +SU_PLATFORM_ORGANISATION_URL: "Final[str]" = ( + "https://www.guildofstudents.com/organisation/admin" +) + + +async def _fetch_url_content_with_session(url: str) -> str: + """Fetch the HTTP content at the given URL, using a shared aiohttp session.""" + async with ( + aiohttp.ClientSession(headers=BASE_HEADERS, cookies=BASE_COOKIES) as http_session, + http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as http_response, + ): + return await http_response.text() + + +async def get_su_platform_access_cookie_status() -> SUPlatformAccessCookieStatus: + """Retrieve the current validity status of the SU platform access cookie.""" + response_object: bs4.BeautifulSoup = bs4.BeautifulSoup( + await _fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser" + ) + page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title") + if not page_title or "Login" in str(page_title): + logger.debug("Token is invalid or expired.") + return SUPlatformAccessCookieStatus.INVALID + + organisation_admin_url: str = ( + f"{SU_PLATFORM_ORGANISATION_URL}/{settings['ORGANISATION_ID']}" + ) + response_html: str = await _fetch_url_content_with_session(organisation_admin_url) + + if "admin tools" in response_html.lower(): + return SUPlatformAccessCookieStatus.AUTHORISED + + if "You do not have any permissions for this organisation" in response_html.lower(): + return SUPlatformAccessCookieStatus.VALID + + logger.warning( + "Unexpected response when checking SU platform access cookie authorisation." + ) + return SUPlatformAccessCookieStatus.INVALID + + +async def get_su_platform_organisations() -> "Iterable[str]": + """Retrieve the MSL organisations the current SU platform cookie has access to.""" + response_object: bs4.BeautifulSoup = bs4.BeautifulSoup( + await _fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser" + ) + + page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title") + + if not page_title: + logger.warning( + "Profile page returned no content when checking " + "SU platform access cookie's authorisation." + ) + return () + + if "Login" in str(page_title): + logger.warning( + "Authentication redirected to login page. " + "SU platform access cookie is invalid or expired." + ) + return () + + profile_section_html: bs4.Tag | bs4.NavigableString | None = response_object.find( + "div", {"id": "profile_main"} + ) + + if profile_section_html is None: + logger.warning( + "Couldn't find the profile section of the user " + "when scraping the SU platform's website HTML." + ) + logger.debug("Retrieved HTML: %s", response_object.text) + return () + + user_name: bs4.Tag | bs4.NavigableString | int | None = profile_section_html.find("h1") + + if not isinstance(user_name, bs4.Tag): + logger.warning("Found user profile on the SU platform but couldn't find their name.") + logger.debug("Retrieved HTML: %s", response_object.text) + return () + + parsed_html: bs4.Tag | bs4.NavigableString | None = response_object.find( + "ul", {"id": "ulOrgs"} + ) + + if parsed_html is None or isinstance(parsed_html, bs4.NavigableString): + NO_ADMIN_TABLE_MESSAGE: Final[str] = ( + f"Failed to retrieve the admin table for user: {user_name.string}. " + "Please check you have used the correct SU platform access token!" + ) + logger.warning(NO_ADMIN_TABLE_MESSAGE) + return () + + organisations: Iterable[str] = [ + list_item.get_text(strip=True) for list_item in parsed_html.find_all("li") + ] + + logger.debug( + "SU platform access cookie has admin authorisation to: %s as user %s", + organisations, + user_name.text, + ) + + return organisations From 1c1722fa3b2a3c7e353cbe590627217c2eb9ce28 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:36:34 +0100 Subject: [PATCH 13/15] Remove unused variables --- cogs/check_su_platform_authorisation.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/cogs/check_su_platform_authorisation.py b/cogs/check_su_platform_authorisation.py index d9e311c53..7f0583e7d 100644 --- a/cogs/check_su_platform_authorisation.py +++ b/cogs/check_su_platform_authorisation.py @@ -15,7 +15,7 @@ from utils.msl import get_su_platform_access_cookie_status, get_su_platform_organisations if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Sequence from collections.abc import Set as AbstractSet from logging import Logger from typing import Final @@ -29,21 +29,6 @@ logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -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"] -} - -SU_PLATFORM_PROFILE_URL: "Final[str]" = "https://guildofstudents.com/profile" -SU_PLATFORM_ORGANISATION_URL: "Final[str]" = ( - "https://www.guildofstudents.com/organisation/admin" -) - class SUPlatformAccessCookieStatus(Enum): """Enum class defining the status of the SU Platform Access Cookie.""" From ca9f7fb092cd3d0bf107a84a34a052e5607bb572 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:45:27 +0100 Subject: [PATCH 14/15] Strip --- utils/msl/core.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/utils/msl/core.py b/utils/msl/core.py index a6c528e81..51ed8494c 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -1,8 +1,6 @@ """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 @@ -23,23 +21,6 @@ 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", From 6c976d3995d8c7d16faae17c4fe7ee085a9c9c94 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 3 Sep 2025 09:54:21 +0100 Subject: [PATCH 15/15] remove old import --- utils/msl/core.py | 1 - 1 file changed, 1 deletion(-) diff --git a/utils/msl/core.py b/utils/msl/core.py index 51ed8494c..09308aa22 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -11,7 +11,6 @@ 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