diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 52e4245..4a88f0a 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -20,9 +20,19 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.13' + python-version: '3.14' + + - name: Install dependencies + run: | + pip install uv + uv pip install -r src/requirements.txt --system + uv pip install -r tests/requirements.txt --system + + - name: Run tests with coverage + run: | + pytest -v - uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} \ No newline at end of file + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} diff --git a/.gitignore b/.gitignore index cac8df1..4e1b3ca 100644 --- a/.gitignore +++ b/.gitignore @@ -185,9 +185,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..81e2bac --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.6 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json diff --git a/.python-version b/.python-version index 3767b4b..6324d40 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14 \ No newline at end of file +3.14 diff --git a/Dockerfile b/Dockerfile index 36e221c..c1a8241 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ RUN addgroup -g 2000 jumpgroup && adduser -S -u 1001 -G jumpgroup jumpstart && \ USER jumpstart -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-config", "/jumpstart/logging_config.yaml", "--proxy-headers","--forwarded-allow-ips","*"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-config", "/jumpstart/logging_config.yaml", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/README.md b/README.md index 21ec2b2..c8347b6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ All information displayed has been authorized to been shown. Documentation for the project can be found be appended /docs to the url All HTML requests that are sent in the project can be seen by appending /swag -This project uses Python, [FastAPI](https://fastapi.tiangolo.com/), HTML/CSS, and Javascript. +This project uses Python, [FastAPI](https://fastapi.tiangolo.com/), HTML/CSS, and Javascript. See it live [here](http://jumpstart-cubed.cs.house/)! ## Installing @@ -46,3 +46,28 @@ Jumpstart also has support for Docker Compose, a extended version of docker that docker compose up ``` +## Development + +### Setup +1. Install uv on your system if not already on it (this just makes it easy) +2. Run: `uv venv .venv` +3. Activate the virtual environment + * Bash: `source .venv/bin/activate` + * Fish: `source .venv/bin/activate.fish` + * Windows: `.venv\Scripts\activate` + * Other: Good luck! +4. Run: + * `uv pip install -r dev-requirements.txt` + * `uv pip install -r src/requirements.txt` + * `uv pip install -r tests/requirements,txt` + * `uv pip install -r docs/requirements.txt` +5. Run: `pre-commit install` +6. You're all set! + +### Testing + +We're using the pytest framework to create tests. A good minimum coverage requirement is about <=90%. + +To run the tests just run: `pytest` + +`coverage.xml` and `htmlcov` should be generated. `coverage.xml` is used for Sonarqube, while `htmlcov` is a local html view into code coverage. The easiest way to view the coverage site is to enter the directory and run: `python -m http.server` and visit the site! diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..89303c8 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,2 @@ +pre-commit==4.5.1 +ruff==0.15.6 diff --git a/docs/core/CSH Calendar.md b/docs/core/csh_calendar.md similarity index 90% rename from docs/core/CSH Calendar.md rename to docs/core/csh_calendar.md index 986b1d9..c9e63e0 100644 --- a/docs/core/CSH Calendar.md +++ b/docs/core/csh_calendar.md @@ -1,5 +1,3 @@ -## Overview - This core component is used for all the fetching, formatting and structuring for the CSH calendar portion of Jumpstart. --- @@ -11,4 +9,4 @@ During a fetch of the calendar, if any other clients attempt to connect, they wi --- ### Documentation Overview -::: core.cshcalendar \ No newline at end of file +::: core.cshcalendar diff --git a/docs/core/Slack.md b/docs/core/slack.md similarity index 88% rename from docs/core/Slack.md rename to docs/core/slack.md index f2f2e20..bb00ab4 100644 --- a/docs/core/Slack.md +++ b/docs/core/slack.md @@ -1,8 +1,6 @@ -## Overview - This component handles the Slack Bot and it's related functions. Such as responding to announcments, requesting to upload to Jumpstart, and the other handles with Slack. --- ### Documentation Overview -::: core.slack \ No newline at end of file +::: core.slack diff --git a/docs/core/Wiki-thoughts.md b/docs/core/wikithoughts.md similarity index 97% rename from docs/core/Wiki-thoughts.md rename to docs/core/wikithoughts.md index 8842c2f..b38acf5 100644 --- a/docs/core/Wiki-thoughts.md +++ b/docs/core/wikithoughts.md @@ -11,4 +11,4 @@ This component handles the fetching, caching and cycling of the CSH Wikithoughts --- ### Documentation Overview -::: core.wikithoughts \ No newline at end of file +::: core.wikithoughts diff --git a/docs/endpoints/announcements.md b/docs/endpoints/announcements.md index e37ec0d..c9c7e35 100644 --- a/docs/endpoints/announcements.md +++ b/docs/endpoints/announcements.md @@ -1,3 +1,3 @@ -::: api.endpoints.get_announcement \ No newline at end of file +::: api.endpoints.get_announcement diff --git a/docs/endpoints/calendar_endpoint.md b/docs/endpoints/csh_calendar.md similarity index 95% rename from docs/endpoints/calendar_endpoint.md rename to docs/endpoints/csh_calendar.md index 34704cc..e9d8ff0 100644 --- a/docs/endpoints/calendar_endpoint.md +++ b/docs/endpoints/csh_calendar.md @@ -25,4 +25,4 @@ Example error response: ``` ### Endpoint Overview -::: api.endpoints.get_calendar \ No newline at end of file +::: api.endpoints.get_calendar diff --git a/docs/endpoints/slack.md b/docs/endpoints/slack.md new file mode 100644 index 0000000..aa6a463 --- /dev/null +++ b/docs/endpoints/slack.md @@ -0,0 +1,4 @@ + +::: api.endpoints.get_announcement +::: api.endpoints.slack_events +::: api.endpoints.message_actions \ No newline at end of file diff --git a/docs/endpoints/slack_bot.md b/docs/endpoints/slack_bot.md index 5766bbc..063cbf9 100644 --- a/docs/endpoints/slack_bot.md +++ b/docs/endpoints/slack_bot.md @@ -1,4 +1,4 @@ ::: api.endpoints.slack_events -::: api.endpoints.message_actions \ No newline at end of file +::: api.endpoints.message_actions diff --git a/docs/endpoints/wikithoughts.md b/docs/endpoints/wikithoughts.md index fb48abd..f6f46b6 100644 --- a/docs/endpoints/wikithoughts.md +++ b/docs/endpoints/wikithoughts.md @@ -18,4 +18,4 @@ In the event of an Error, the API will log an error and display default text } ``` -::: api.endpoints.wikithought \ No newline at end of file +::: api.endpoints.wikithought diff --git a/docs/getting-started/getting-started.md b/docs/getting-started/getting-started.md index 4cd1770..0805d77 100644 --- a/docs/getting-started/getting-started.md +++ b/docs/getting-started/getting-started.md @@ -2,7 +2,7 @@ ## Installing 1. Clone and cd into the repo: git clone https://github.com/WeatherGod3218/jumpstartV2 >> Make another branch if you are working on a large PR -2. +2. ## Setup 1. Make sure you have docker installed @@ -10,7 +10,7 @@ 2. Copy the .env.template file, rename it to .env and place it in the root folder 3. Ask an RTP for jumpstart secrets, add them to the .env accordingly -## Run +## Run 1. Build the docker file ``` docker build -t Jumpstart . @@ -23,4 +23,4 @@ ### Alternatively, you can run the docker compose file as well ``` docker compose up -``` \ No newline at end of file +``` diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/index.md b/docs/index.md index e32af29..64481aa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,10 +5,10 @@ ![Static Badge](https://img.shields.io/badge/%40gravy-made_by?style=flat-square&logo=github&labelColor=%230d1117&color=%23E11C52&link=https%3A%2F%2Fgithub.com%2FNikolaiStrong) -A graphical interface that displays information in the elevator lobby of Computer Science House. +A graphical interface that displays information in the elevator lobby of Computer Science House. All information displayed has been authorized to been shown. -This project uses Python, [FastAPI](https://fastapi.tiangolo.com/), HTML/CSS, and Javascript. +This project uses Python, [FastAPI](https://fastapi.tiangolo.com/), HTML/CSS, and Javascript. See it live [here](http://jumpstart-squared.cs.house/)! The application has multiple features: @@ -23,7 +23,7 @@ The application has multiple features: 5. An informational that displays real-time status information from CSH’s server room. 6. Calendar module that uses the Python ICalendar to display a countdown to the next 10 events from the CSH calendar. - + 7. Displays a daily forecast. ### AUTHORS: diff --git a/docs/requirements.txt b/docs/requirements.txt index 0eff80d..9044f17 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,4 +3,4 @@ mkdocstrings mkdocstrings-python mkdocs-minify-plugin mkdocs-git-revision-date-localized-plugin -pymdown-extensions \ No newline at end of file +pymdown-extensions diff --git a/docs/themes/how_to.md b/docs/themes/how_to.md new file mode 100644 index 0000000..a3903f3 --- /dev/null +++ b/docs/themes/how_to.md @@ -0,0 +1,22 @@ +Jumpstart has many different themes displayed on the website, this is a step by step process on how to implement your own! + +**Any images that need to be added should be put in src/static/img** + +### Adding Background + +1. Go to the file: **src/static/js/main.js** +2. In the function long update, figure out the day, month, and hour of your theme. + - hour is in 24H format +3. Add it into the if statement (please change this in the future) + - Make sure it follows the bgImage = "url(../static/img/{**YOUR FILE HERE**})" + +### Adding CSS Theme +1. Go to the file **src/static/css/style.css** +2. Add a new class for the colors **MAKE SURE IT STARTS WITH {theme-}!!!** +3. Change the colors in this new class +4. Repeat the steps in the "Adding Background" +5. Add a new index into "allThemes" with your css theme, along with any changes to weatherwidget or datadog +6. change the themeToLoad in the if statement to load your new index + + + diff --git a/docs/themes/overview.md b/docs/themes/overview.md new file mode 100644 index 0000000..8cf5595 --- /dev/null +++ b/docs/themes/overview.md @@ -0,0 +1,30 @@ +Jumpstart has many different themes displayed on the website. Ranging from the light - dark mode, to full background and color shifts fo events. + +### All Themes +- Light Mode + - Default theme between 9 AM and 6 PM +- Dark Mode + - Default theme between 6 PM and 9 AM +- Valentine's Day + - Feburary 12th, 13th and 14th + - Changes title to "Constantly Smooching House" + - Adds decorative hearts around the logo +- BANG! + - March 13th + - Changes title to "BANG! Science" + - Gives an orange gradient to the background +- CSH 50th Anniversary + - April 9th, 10th, 11th, and 12 + - Replaces title with the CSH 50th Anniversary logo + - Replaces color scheme with a Golden / Black theme +- Halloween + - October 29th, 30th, and 31st + - Changes title to "Computer Spooky House" + - Logo is given orange and purple colors +- Duck! + - November 2nd + - Duck +- Winter + - November and December + - Changes title to "Christmas Season" + - Gives a red and green logo with a bright blue background diff --git a/mkdocs.yml b/mkdocs.yml index acafb5b..1b7673e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,15 +38,17 @@ use_directory_urls: false nav: - Home: index.md - Getting Started: getting-started/getting-started.md - - Backend: - - Wikithoughts: core/Wiki-thoughts.md - - Calendar: core/CSH Calendar.md - - Slack: core/Slack.md + - Backend: + - Calendar: core/csh_calendar.md + - Slack: core/slack.md + - Wikithoughts: core/wikithoughts.md - Endpoints: - - Calendar: endpoints/calendar_endpoint.md - - Announcements: endpoints/announcements.md - - Slack Bot: endpoints/slack_bot.md - - Wiki Thoughts: endpoints/wikithoughts.md + - Calendar: endpoints/csh_calendar.md + - Slack: endpoints/slack.md + - Wikithoughts: endpoints/wikithoughts.md + - Themes: + - Themes: themes/overview.md + - Adding Your Own: themes/how_to.md plugins: - search diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c152b9f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts = -ra +python_files = test_*.py +pythonpath = src diff --git a/ruff.toml b/ruff.toml index d141539..aa66445 100644 --- a/ruff.toml +++ b/ruff.toml @@ -10,4 +10,4 @@ line-length = 88 [format] quote-style = "double" indent-style = "tab" -skip-magic-trailing-comma = false \ No newline at end of file +skip-magic-trailing-comma = false diff --git a/sonar-project.properties b/sonar-project.properties index 995bea0..e2bc34b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1 +1,3 @@ -sonar.projectKey=jumpstart-v2 \ No newline at end of file +sonar.projectKey=jumpstart-v2 +sonar.python.coverage.reportPaths=coverage.xml +sonar.coverage.exclusions=**/tests/**,**/*test*.py,**/test_*.py diff --git a/src/__init__.py b/src/__init__.py index 8b13789..e69de29 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1 +0,0 @@ - diff --git a/src/api/endpoints.py b/src/api/endpoints.py index 887d28d..b5985a6 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -2,8 +2,6 @@ import json import httpx -import random -import textwrap from fastapi import APIRouter, Request, Form from fastapi.responses import JSONResponse @@ -14,8 +12,11 @@ logger: Logger = getLogger(__name__) router: APIRouter = APIRouter() +ACCEPT_MESSAGE: str = "Posting right now :^)" +DENY_MESSAGE: str = "Okay :( maybe next time" -@router.get("/api/calendar") + +@router.get("/calendar") async def get_calendar() -> JSONResponse: """ Returns calendar data. @@ -24,15 +25,21 @@ async def get_calendar() -> JSONResponse: JSONResponse: A JSON response containing the calendar data. """ - get_future_events_ical: tuple[ - cshcalendar.CalendarInfo - ] = await cshcalendar.get_future_events() - formatted_events: dict = cshcalendar.format_events(get_future_events_ical) + events: list[dict[str, str]] = [] + + try: + get_future_events_ical: list[ + cshcalendar.CalendarInfo + ] = await cshcalendar.get_future_events() + events = cshcalendar.format_events(get_future_events_ical) + except Exception as e: + logger.error(f"Error fetching calendar events: {e}") + return JSONResponse({"status": "error", "message": str(e)}, status_code=500) - return JSONResponse(formatted_events) + return JSONResponse({"data": events}) -@router.get("/api/announcement") +@router.get("/announcement") def get_announcement() -> JSONResponse: """ Returns announcement data. @@ -41,7 +48,7 @@ def get_announcement() -> JSONResponse: JSONResponse: A JSON response containing the announcement data. """ - return JSONResponse({"data": slack.get_announcement()}) + return JSONResponse(slack.get_announcement()) @router.post("/slack/events") @@ -57,18 +64,17 @@ async def slack_events(request: Request) -> JSONResponse: """ try: - logger.info("Received Slack event!") + logger.debug(f"Received Slack event: {await request.body()}") body: dict = await request.json() - logger.info(body) - logger.info("\n") + if request.headers.get("content-type") == "application/json": if body.get("type") == "url_verification": logger.info("SLACK EVENT: Was a challenge!") return JSONResponse({"challenge": body.get("challenge")}) if not body: - logger.info("SLACK EVENT: Was a challenge, with no body") + logger.debug("SLACK EVENT: Was a challenge, with no body") return JSONResponse({"challenge": body.get("challenge")}) event: dict = body.get("event", {}) @@ -79,8 +85,9 @@ async def slack_events(request: Request) -> JSONResponse: return JSONResponse({"status": "ignored"}) if event.get("channel", None) not in WATCHED_CHANNELS: - logger.info("SLACK EVENT: Message was not in a Watched Channel, returning!") - logger.info(WATCHED_CHANNELS) + logger.info( + "SLACK EVENT: Message was not in a Watched Channel, ignoring it" + ) return JSONResponse({"status": "ignored"}) logger.info("SLACK EVENT: Requesting upload via dm!") @@ -112,25 +119,34 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse: return JSONResponse({}, status_code=200) if slack.convert_user_response_to_bool(form_json): - logger.info("User approved the announcement!") - logger.info(f"{form_json}\n\n") + logger.info( + "User approved the announcement, Adding it to the announcement list!" + ) + message_object: dict[str, dict] = json.loads( form_json.get("actions", [{}])[0].get("value", '{text:""}') ).get("text", None) - logger.info(f"Display Object {message_object}") - slack.add_announcement(message_object) + + user_id = form_json.get("user", {}).get("id") + + username: str = await slack.get_username(user_id=user_id) + username = username[:40] + + slack.add_announcement(message_object, username) if response_url: - await httpx.post( - response_url, - json={"text": "Posting right now :^)", "replace_original": True}, - ) + async with httpx.AsyncClient() as client: + await client.post( + response_url, + json={"text": ACCEPT_MESSAGE, "replace_original": True}, + ) else: if response_url: - await httpx.post( - response_url, - json={"text": "Okay :( maybe next time", "replace_original": True}, - ) + async with httpx.AsyncClient() as client: + await client.post( + response_url, + json={"text": DENY_MESSAGE, "replace_original": True}, + ) except Exception as e: logger.error(f"Error in message_actions: {e}") @@ -139,7 +155,7 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse: return JSONResponse({"status": "success"}, status_code=200) -@router.get("/api/wikithought") +@router.get("/wikithought") async def wikithought() -> JSONResponse: """ Returns a random CSH wiki thought from the MediaWiki API. @@ -147,5 +163,13 @@ async def wikithought() -> JSONResponse: Returns: JSONResponse: A JSON response containing a random Wiki thought. """ - returned_page_data: dict[str, str] = await wikithoughts.get_next_display() - return JSONResponse(returned_page_data) + + page_data: dict | None = None + + try: + page_data = await wikithoughts.get_next_display() + except Exception as e: + logger.error(f"Error fetching wiki thought: {e}") + return JSONResponse({"status": "error", "message": str(e)}, status_code=500) + + return JSONResponse(page_data) diff --git a/src/config.py b/src/config.py index b641377..3e3eb81 100644 --- a/src/config.py +++ b/src/config.py @@ -1,30 +1,59 @@ import os import json +import logging from dotenv import load_dotenv - load_dotenv() +logger: logging.Logger = logging.getLogger(__name__) + + +def _get_env_variable(name: str, default: str | None = None) -> str | None: + """ + Retrieves an environment variable, with an optional default value. + + Args: + name (str): The name of the environment variable to retrieve. + default (str | None): An optional default value to return if the environment variable is not set. + + Returns: + str | None: The value of the environment variable, or the default value if it is not set. + """ + + try: + value: str = os.getenv(name, default) + + if value in (None, ""): + logger.warning( + f"Environment variable '{name}' is not set, using default value: '{default if default is not None else 'None'}'" + ) + return default + + return value + except Exception as e: + logger.error(f"Error retrieving environment variable '{name}': {e}") + return default + + BASE_DIR: str = os.path.dirname(os.path.abspath(__file__)) -SLACK_API_TOKEN: str | None = os.getenv("SLACK_API_TOKEN", None) +SLACK_API_TOKEN: str | None = _get_env_variable("SLACK_API_TOKEN", None) SLACK_JUMPSTART_MESSAGE: str = "Would you like to post this message to Jumpstart?" -WATCHED_CHANNELS: tuple[str] = tuple(os.getenv("WATCHED_CHANNELS", "").split(",")) +WATCHED_CHANNELS: tuple[str] = tuple( + _get_env_variable("WATCHED_CHANNELS", "").split(",") +) SLACK_DM_TEMPLATE: dict | None = None -CALENDAR_URL: str | None = os.getenv("CALENDAR_URL", None) -CALENDAR_OUTLOOK_DAYS: int = int(os.getenv("CALENDAR_OUTLOOK_DAYS", "7")) -CALENDAR_EVENT_MAXIMUM: int = int(os.getenv("CALENDAR_EVENT_MAXIMUM", "10")) -CALENDAR_TIMEZONE: str = os.getenv("CALENDAR_TIMEZONE", "America/New_York") -CALENDAR_CACHE_REFRESH: int = int(os.getenv("CALENDAR_CACHE_REFRESH", "10")) - -WIKI_API: str | None = os.getenv("WIKI_API", None) -WIKIBOT_USER: str | None = os.getenv("WIKIBOT_USER", None) -WIKIBOT_PASSWORD: str | None = os.getenv("WIKIBOT_PASSWORD", None) -WIKI_CATEGORY: str = os.getenv("WIKI_CATEGORY", "JobAdvice") +CALENDAR_URL: str | None = _get_env_variable("CALENDAR_URL", None) +CALENDAR_OUTLOOK_DAYS: int = int(_get_env_variable("CALENDAR_OUTLOOK_DAYS", "7")) +CALENDAR_EVENT_MAXIMUM: int = int(_get_env_variable("CALENDAR_EVENT_MAXIMUM", "10")) +CALENDAR_TIMEZONE: str = _get_env_variable("CALENDAR_TIMEZONE", "America/New_York") +CALENDAR_CACHE_REFRESH: int = int(_get_env_variable("CALENDAR_CACHE_REFRESH", "10")) -if SLACK_API_TOKEN in (None, ""): - raise ValueError("Missing SLACK_API_TOKEN") +WIKI_API: str | None = _get_env_variable("WIKI_API", None) +WIKIBOT_USER: str | None = _get_env_variable("WIKIBOT_USER", None) +WIKIBOT_PASSWORD: str | None = _get_env_variable("WIKIBOT_PASSWORD", None) +WIKI_CATEGORY: str = _get_env_variable("WIKI_CATEGORY", "JobAdvice") with open(os.path.join(BASE_DIR, "static", "slack", "dm_request_template.json")) as f: SLACK_DM_TEMPLATE = json.load(f) diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py index 3434ff8..01b7c34 100644 --- a/src/core/cshcalendar.py +++ b/src/core/cshcalendar.py @@ -54,7 +54,7 @@ (WEEK - DAY): f"In %{DAY}% Days", } -BORDER_STRING: str = "
" +BORDER_STRING: str = '
' TIME_PATTERN = re.compile(r"%([^%]+)%") @@ -89,6 +89,7 @@ def ceil_division(num: int, den: int) -> int: Returns: int: the result of the operation """ + return (num + den - 1) // den @@ -113,6 +114,7 @@ def repl(match: re.Match[str]) -> str: Returns: str: The newly formatted string """ + num = int(match.group(1)) return str(round(time_before_event / num)) @@ -134,30 +136,7 @@ def repl(match: re.Match[str]) -> str: return TIME_PATTERN.sub(repl, unformatted_string) - -def calendar_to_html(seg_header: str, seg_content: str) -> str: - """ - Formats a header and content into the HTML for the calendar front end - - Args: - seg_header (str): The header of the calendar segment - seg_content (str): The content in the calendar segment - - Returns: - str: - """ - ret_string: str = ( - """
""" - + seg_header - + """
""" - ) - ret_string += ( - "" + seg_content + "
" - ) - return ret_string - - -def format_events(events: tuple[CalendarInfo]) -> dict[str, str]: +def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: """ Formats a parsed list of CalendarInfos, and returns the HTML required for front end @@ -165,22 +144,19 @@ def format_events(events: tuple[CalendarInfo]) -> dict[str, str]: events: The list of CalendarInfos to be formatted Returns: - dict: Returns a dictionary with the "data" key mapping to the HTML data. + list[dict[str, str]]: Returns a dictionary with the "data" key mapping to a list of dictionarys of each event. """ current_date: date = datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) - final_events: str = "
" if not events: - final_events += BORDER_STRING - - final_events += calendar_to_html(":(", "No Events on the Calendar") + return {"data": [{"header": ":(", "content": "No Events on the Calendar"}]} - final_events += BORDER_STRING - - return {"data": final_events} + formatted_list: list[dict[str, str]] = [] for event in events: + content_dict: dict[str, str] = {} + event_cur_happening: bool = event.date < current_date if event_cur_happening: formatted: str = ( @@ -188,21 +164,24 @@ def format_events(events: tuple[CalendarInfo]) -> dict[str, str]: if event.location else "Happening Now!" ) - final_events += calendar_to_html(formatted, event.name) + content_dict["header"] = formatted + content_dict["content"] = str(event.name) else: - final_events += calendar_to_html( - time_humanizer(current_date, event.date), event.name - ) + content_dict["header"] = time_humanizer(current_date, event.date) + content_dict["content"] = str(event.name) - final_events += BORDER_STRING - return {"data": final_events} + formatted_list.append(content_dict) + return formatted_list async def rebuild_calendar() -> None: """ Fetches and rebuilds the global calendar cache. This does NOT return a new cache, but changes the global calendar cache """ + global calendar_cache, cal_last_update, cal_constructed_event + + current_time: datetime = datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) try: cal_constructed_event.clear() found_events: set[CalendarInfo] = set() @@ -211,8 +190,6 @@ async def rebuild_calendar() -> None: cal: Calendar = Calendar.from_ical(response.content) - current_time: datetime = datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) - fetched_daily_events: list[Event] = recurring_ical_events.of(cal).between( current_time, current_time + timedelta(days=CALENDAR_OUTLOOK_DAYS) ) @@ -257,7 +234,7 @@ async def get_future_events() -> list[CalendarInfo]: custom object has name, date and the location Returns: - list: A list of CalendarInfo objects + list[CalendarInfo]: A list of CalendarInfo objects """ global \ @@ -314,10 +291,9 @@ async def get_future_events() -> list[CalendarInfo]: if cal_correct_length: logger.info("Calendar cache is full length, rebuilding async!") - asyncio.create_task( - rebuild_calendar() - ) # Calendar is correct length, we can just run this in the background - # Make it a variable for GC purposes? idk sonarqube told me to do it + async with asyncio.TaskGroup() as taskGroup: + taskGroup.create_task(rebuild_calendar()) + # Calendar is correct length, we can just run this in the background else: logger.info("Calendar cache is NOT full length, yielding rebuild!") await rebuild_calendar() @@ -333,6 +309,7 @@ async def close_client() -> None: Closes the calendar's HTTPX client, logs if the event loops has been closed prior to the function being called """ + global cshcal_client try: await cshcal_client.aclose() diff --git a/src/core/slack.py b/src/core/slack.py index b7fda91..1b410b3 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -5,20 +5,36 @@ from logging import getLogger, Logger from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.slack_response import SlackResponse from slack_sdk.errors import SlackApiError -from config import SLACK_API_TOKEN, SLACK_JUMPSTART_MESSAGE, SLACK_DM_TEMPLATE - +from config import ( + SLACK_API_TOKEN, + SLACK_JUMPSTART_MESSAGE, + SLACK_DM_TEMPLATE, + CALENDAR_TIMEZONE, +) +from datetime import datetime +from zoneinfo import ZoneInfo logger: Logger = getLogger(__name__) + client: AsyncWebClient | None = None + try: client = AsyncWebClient(token=SLACK_API_TOKEN) except Exception as e: logger.error(f"Failed to initialize Slack client: {e}") -announcements: list[str] = ["Welcome to Jumpstart!"] +current_announcement: dict[str, str] = { + "content": "Welcome to Jumpstart!", + "user": "Jumpstart", + "timestamp": datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) + .strftime("%I:%M %p") + .lstrip("0") +} + def clean_text(raw: str) -> str: @@ -45,17 +61,45 @@ async def gather_emojis() -> dict: dict: A mapping of emoji names to their URLs. """ - logger.info("Gathering emojis from slack!") + emojis: dict = {} try: + if client is None: + raise ValueError("Slack client is not initialized") + emoji_request: dict = await client.emoji_list() assert emoji_request.get("ok", False) - return emoji_request.get("emoji", {}) + emojis = emoji_request.get("emoji", {}) except Exception as e: logger.error(f"Error gathering emojis: {e}") - return {} + return emojis + + +async def get_username(user_id: str) -> str: + """ + Attempts to retrieve a slack username relating to a user id + + Args: + user_id (str): The ID of the user to retrieve + + Returns: + str: The username, or an empty string if not applicable + """ + + response = await client.users_info(user=user_id) + user = response.get("user", None) + + if user is None: + logger.warning(f"Unable to find a user under the id {user_id}") + return "Unknown" + + display_name = user.get("profile", {}).get("display_name", None) + real_name = user.get("real_name", None) + username = user.get("name", None) + + return real_name or display_name or username or "Unknown" async def request_upload_via_dm(user_id: str, announcement_text: str) -> None: @@ -67,9 +111,10 @@ async def request_upload_via_dm(user_id: str, announcement_text: str) -> None: announcement_text (str): The text of the announcement to be posted. """ - logger.info("Requesting upload announcement permission!") - try: + if client is None: + raise ValueError("Slack client is not initialized") + message: dict = copy.deepcopy(SLACK_DM_TEMPLATE) message[0]["text"]["text"] += announcement_text @@ -83,6 +128,9 @@ async def request_upload_via_dm(user_id: str, announcement_text: str) -> None: await client.chat_postMessage( channel=user_id, text=SLACK_JUMPSTART_MESSAGE, blocks=message ) + except SlackApiError as e: + logger.error(f"Slack Error: {e.response['error']}\n") + logger.error(f"Full Slack Error: {e.response}") except Exception as e: logger.error(f"Error messaging user {user_id}: {e}") @@ -110,33 +158,38 @@ def convert_user_response_to_bool(message_data: dict) -> bool: return user_response -def get_announcement() -> str | None: +def get_announcement() -> dict[str, str] | None: """ Returns the next announcement in the queue. Returns: - str | None: The next announcement text, or None if there are no announcements. + dict[str,str]: The next announcement text and user, or None if there are no announcements. """ - if len(announcements) == 0: - return None - - if len(announcements) == 1: - return announcements[0] + return current_announcement - return announcements.pop(0) - -def add_announcement(announcement_text: str) -> None: +def add_announcement(announcement_text: str, username: str) -> None: """ Adds an announcement to the queue. Args: announcement_text (str): The text of the announcement to be added. + user_id (str): The user_id of the person """ + global current_announcement if announcement_text is None or announcement_text.strip() == "": logger.warning("Attempted to add empty announcement, skipping!") return - announcements.append(announcement_text) + current_time = ( + datetime.now(tz=ZoneInfo(CALENDAR_TIMEZONE)).strftime("%I:%M %p").lstrip("0") + ) + new_addition: dict[str, str] = { + "content": announcement_text, + "user": username, + "timestamp": current_time, + } + + current_announcement = new_addition diff --git a/src/core/wikithoughts.py b/src/core/wikithoughts.py index 4173fab..1a3e715 100644 --- a/src/core/wikithoughts.py +++ b/src/core/wikithoughts.py @@ -1,12 +1,15 @@ +import re import httpx +import random import asyncio -from datetime import datetime, timedelta -from itertools import islice +import logging + from typing import Pattern +from itertools import islice +from datetime import datetime, timedelta + from config import WIKIBOT_PASSWORD, WIKIBOT_USER, WIKI_CATEGORY, WIKI_API -import logging -import random -import re + CYCLE_DEBOUNCE_TIME: int = 12 # How long it takes to resfresh wiki titles BATCH_SIZE: int = 50 # max titles per request @@ -17,10 +20,15 @@ HEADERS: dict[str, str] = {"User-Agent": "JumpstartFetcher/1.0"} AUTH: tuple[str] = (WIKIBOT_USER, WIKIBOT_PASSWORD) -client: httpx.AsyncClient = httpx.AsyncClient(headers=HEADERS, auth=AUTH) - logger: logging.Logger = logging.getLogger(__name__) +client: httpx.AsyncClient | None = None + +try: + client = httpx.AsyncClient(headers=HEADERS, auth=AUTH) +except Exception as e: + logger.warning(f"Failed to initialize HTTP client for wiki: {e}") + bot_authenticated: bool = False last_updated_time: datetime | None = None @@ -61,9 +69,9 @@ def clean_wikitext(text: str) -> str: """ reg_operations: tuple[Pattern[str]] = ( - RE_LINK, RE_FILE, RE_IMAGE, + RE_LINK, RE_PAGE_TEXT, RE_PAGE, RE_CSH, @@ -96,10 +104,13 @@ def batch_iterable(iterable: list, size: int): Args: iterable (list): The iterable to be split up for batches size (int): the size of the batches + Yields: A batch split by the requested size """ + it = iter(iterable) + while True: batch = list(islice(it, size)) if not batch: @@ -111,6 +122,17 @@ async def auth_bot() -> None: """ Authenticates the CSH Wiki bot, logging if it was successful or not """ + + if not WIKI_API or not WIKIBOT_USER or not WIKIBOT_PASSWORD: + logger.warning("Missing wiki API credentials, unable to authenticate the bot!") + return + + if not client: + logger.warning( + "HTTP client for wiki is not initialized, unable to authenticate the bot!" + ) + return + token_req: httpx.Response = await client.get( WIKI_API, params={"action": "query", "meta": "tokens", "type": "login", "format": "json"}, @@ -138,7 +160,7 @@ async def auth_bot() -> None: logger.info("Bot was authenticated successfully!") else: bot_authenticated = False - logger.warning("Bot was unable to authenticate!") + logger.warning(f"Bot was unable to authenticate! Response: {returned_json}") def headers_formatting( @@ -154,6 +176,7 @@ def headers_formatting( Returns: dict[str,str]: The new headers to be applied """ + global etag, last_modifed headers: dict[str, str] = {} @@ -166,6 +189,7 @@ def headers_formatting( if etag: headers["If-None-Match"] = etag + if last_modifed: headers["If-Modified-Since"] = last_modifed @@ -204,7 +228,9 @@ def process_category_page(r_json: dict[str, str]) -> tuple[list[str], bool | str Returns: tuple[list[str], bool | str]: The list of titles from the request, along with a possible continutation if needed """ + titles: list[str] = [] + if "query" in r_json: for page in r_json["query"]["categorymembers"]: titles.append(page["title"]) @@ -219,6 +245,68 @@ def process_category_page(r_json: dict[str, str]) -> tuple[list[str], bool | str return (titles, False) +async def fetch_category_pages(response: httpx.Response) -> list[str]: + """ + Loops through and gets the list of every page for Jumpstart Curated + + Args: + response (httpx.Response): The response to be converted and searched through + + Returns: + list[str]: The list of titles to be fetched. + """ + + params: dict[str, str] = { + "action": "query", + "list": "categorymembers", + "cmtitle": f"Category:{WIKI_CATEGORY}", + "cmlimit": "500", + "format": "json", + } + + titles_found: list[str] = [] + + failed_authentication_attempts: int = 0 + + while True: + r_json: dict[str, str] = response.json() + + if "error" in r_json and r_json["error"].get("code") in ( + "readapidenied", + "notloggedin", + ): + if failed_authentication_attempts > REAUTHENTICATE_ATTEMPTS: + logger.warning( + "Reauthenticating the wikithought bot failed, sending empty response" + ) + return [] + + logger.info("Bot was unauthenticated, attempting to reauthenticate!") + await auth_bot() + if not (bot_authenticated): + logger.warning( + f"Failed to reauthenticate the bot! Attempt: {failed_authentication_attempts}" + ) + await asyncio.sleep(3) + else: + logger.info("Bot was able to re-auth during runtime!") + + failed_authentication_attempts += 1 + response = await client.get(WIKI_API, params=params) + continue + + added, repeat_req = process_category_page(r_json) + titles_found += added + + if repeat_req not in (None, False, ""): + params["cmcontinue"] = repeat_req + + response = await client.get(WIKI_API, params=params) + continue + break + return titles_found + + async def refresh_category_pages() -> list[str]: """ Refreshes all pages of the category @@ -227,8 +315,16 @@ async def refresh_category_pages() -> list[str]: category (str): The name of the category to search through Returns: - list[str]: All the page titles found in this category, None if the bot is not authorized + list[str] + : All the page titles found in this category, None if the bot is not authorized """ + + if not client: + logger.warning( + "HTTP client for wiki is not initialized, unable to refresh category pages!" + ) + return [] + global page_title_cache, last_updated_time, queued_pages, shown_pages time_now: datetime = datetime.now() @@ -245,53 +341,19 @@ async def refresh_category_pages() -> list[str]: } headers: dict[str, str] = headers_formatting() - # This needs to loop due to mediawiki limitations - - failed_authentication_attempts: int = 0 - - while True: - response: httpx.Response = await client.get( - WIKI_API, params=params, headers=headers - ) + response: httpx.Response = await client.get( + WIKI_API, params=params, headers=headers + ) - if response.status_code == 304: + match response.status_code: + case 304: + logger.info("Category pages not updated, refreshing last update!") last_updated_time = time_now return page_title_cache - - elif response.status_code == 200: - headers_formatting(etag, last_modifed) - r_json: dict[str, str] = response.json() - - if "error" in r_json and r_json["error"].get("code") in ( - "readapidenied", - "notloggedin", - ): - if failed_authentication_attempts > REAUTHENTICATE_ATTEMPTS: - logger.warning( - "Reauthenticating the wikithought bot failed, sending empty response" - ) - return [] - - logger.info(f"Both was unauthenticated, attempting to reauthenticate!") - await auth_bot() - if not (bot_authenticated): - logger.warning( - "Failed to reauthenticate the bot! Attempt: " - + failed_authentication_attempts - ) - - failed_authentication_attempts += 1 - continue - - added, repeat_req = process_category_page(r_json) - titles += added - - if repeat_req not in (None, False, ""): - params["cmcontinue"] = repeat_req - continue - break - else: - logger.warning("Failed to update the CSH wiki page!") + case 200: + titles = await fetch_category_pages(response=response) + case _: + logger.warning("Failed to update the wiki category pages!") return page_title_cache last_updated_time = time_now @@ -309,6 +371,13 @@ async def refresh_page_dictionary() -> None: """ Fetches the pages based off the cache of page titles, and updates the page cache accordingly """ + + if not client: + logger.warning( + "HTTP client for wiki is not initialized, unable to refresh page dictionary!" + ) + return + global page_dict_cache, page_title_cache if not page_title_cache: @@ -335,7 +404,6 @@ async def refresh_page_dictionary() -> None: if "query" in r_json: for page in r_json["query"]["pages"].values(): wikitext = page["revisions"][0]["slots"]["main"]["*"] - cleaned_text = clean_wikitext(wikitext) # unfuck the text paragraphs = cleaned_text.split("\n\n") # Cut the first line @@ -357,8 +425,11 @@ def reset_queues() -> None: """ Swaps Queued and Shown pages queued """ + global queued_pages, shown_pages + logger.info("RESETING QUEUES FOR WIKITHOUGHTS") + if len(queued_pages) > 0: return @@ -375,6 +446,7 @@ async def get_next_display() -> dict[str, str]: Returns: dict["page": str,"content": str]: The JSON of the page name and the first paragraph """ + global queued_pages, shown_pages, page_last_updated, current_page if page_last_updated and ( diff --git a/src/logging_config.yaml b/src/logging_config.yaml index 14be8f4..f58de81 100644 --- a/src/logging_config.yaml +++ b/src/logging_config.yaml @@ -23,4 +23,4 @@ loggers: propagate: no root: handlers: [console] - level: INFO \ No newline at end of file + level: INFO diff --git a/src/main.py b/src/main.py index 43b7f13..0fd0da7 100644 --- a/src/main.py +++ b/src/main.py @@ -27,8 +27,10 @@ @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Starting up the Jumpstart application!") - asyncio.create_task(cshcalendar.rebuild_calendar()) + async with asyncio.TaskGroup() as tg: + tg.create_task(cshcalendar.rebuild_calendar()) await wikithoughts.auth_bot() + yield logger.info("Shutting down the Jumpstart application!") await cshcalendar.close_client() @@ -63,8 +65,7 @@ async def docs_redirect(): logger.warning("Documentation directory not found, skipping documentation setup!") logger.info("Importing API endpoints!") -# app.include_router(endpoints.router, prefix="/api") -app.include_router(endpoints.router) +app.include_router(endpoints.router, prefix="/api") logger.info("Finished setting up the application!") diff --git a/src/requirements.txt b/src/requirements.txt index 0b1faf8..305684d 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -19,8 +19,6 @@ icalendar==7.0.3 recurring-ical-events==3.8.1 arrow==1.4.0 -# For Wikithoughts - # For the docker to run uvicorn==0.41.0 pyyaml==6.0.3 diff --git a/src/static/css/style.css b/src/static/css/style.css index 47b7d0a..f3f7066 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -1,3 +1,46 @@ +/* Creates a default theme, but can get overwritten */ +:root { + --panel-header-color: #B0197E; /* Standard color for panels header*/ + --panel-body-color: #FFFFFF; /* text body */ + --panel-header-text-color: #FFFFFF; /* Used for headers in panels, like Page - csh/Wikithoughts */ + --panel-body-text-color: #FFFFFF; /* Used for the text within said panels */ + --border-color: #34495E; /* Used for borders around elements */ + --shadow-color: rgb(176,25,126, 0.5); /* Used for the shadow in the calendar */ +} + +/* b { + display: flex; + align-items: center; + justify-content: center; +} */ + +.theme-dark { + --panel-header-color: #B0197E; + --panel-header-text-color: #FFFFFF; + --panel-body-color: #000000; + --panel-body-text-color: #FFFFFF; + --border-color: #34495E; + --shadow-color: rgba(255, 0, 170, 0.705); +} + +.theme-light { + --panel-header-color: #B0197E; + --panel-body-color: #FFFFFF; + --panel-header-text-color: #FFFFFF; + --panel-body-text-color: #000000; + --border-color: #34495E; + --shadow-color: rgb(176,25,126, 0.5); +} + +.theme-golden { + --panel-header-color: #FFC64A; + --panel-header-text-color: #000000; + --panel-body-color: #000000; + --panel-body-text-color: #FFFFFF; + --border-color: #34495E; + --shadow-color: rgb(255, 212, 94); +} + body{ background-image: url('/static/img/darkmodeF.png'); overflow: hidden; @@ -10,14 +53,21 @@ body{ } .panel-primary{ - background: #34495e; - border: 8px #34495e solid ; + background: var(--border-color); + border: 8px var(--border-color) solid ; border-radius: 10px; } +.panel-heading{ + background-color: var(--panel-header-color) !important; +} + +.panel-body{ + background-color: var(--panel-body-color) !important; +} #top-bar{ - border: 5px #B0197E solid; - background-color: #34495e; + border: 5px var(--panel-header-color) solid; + background-color: var(--border-color); } #timewidget-fake{ @@ -25,19 +75,18 @@ body{ } .file-title{ - color: #fcf6fa; + color: #FFFFFF; font-family: 'Courier New', Courier, monospace; font-size: 28px; - border-left: 5px #B0197E solid; + border-left: 5px var(--panel-header-color) solid; float: right; width: 29%; } .js-title{ - color: #fcf6fa; + color: #FFFFFF; font-family: 'Courier New', Courier, monospace; font-size: 30px; - border-left: 5px #B0197E solid; float: right; } @@ -49,18 +98,18 @@ body{ .weatherwidget{ float: right; width: 49%; - border: 8px #34495e solid; + border: 8px var(--border-color) solid; border-radius: 10px; - background-color: #34495e; + background-color: var(--border-color); margin-bottom: 1.5%; } .datadog{ margin-top: 20px; overflow: auto; - border: 8px #34495e solid; + border: 8px var(--border-color) solid; border-radius: 10px; - background-color: #34495e; + background-color: var(--border-color); width: 760px; height: 580px; float: right; @@ -78,14 +127,25 @@ body{ } .announcements-text-header{ - color: white; + color: var(--panel-header-text-color); font-family: 'Courier New', Courier, monospace; font-size: 20px; text-align: center; + white-space: pre-line; +} + +.announcements-stamp-header{ + color: var(--panel-header-text-color); + font-family: 'Courier New', Courier, monospace; + font-size: 20px; + text-align: left; + white-space: pre-line; + display: block; + font-style: italic; } .announcements-text-body{ - color: black; + color: var(--panel-body-text-color); font-family: 'Courier New', Courier, monospace; font-size: 18px; text-align: center; @@ -98,22 +158,23 @@ body{ } .wikithoughts-text-header{ - color: white; + color: var(--panel-header-text-color); font-family: 'Courier New', Courier, monospace; font-size: 20px; text-align: center; + white-space: pre-line; } .wikithoughts-text-body{ - color: black; + color:var(--panel-body-text-color); font-family: 'Courier New', Courier, monospace; font-size: 18px; text-align: center; } .calendar-frame-lvl1{ - background-color: #fcf6fa; - border: 8px #34495e solid; + background-color: var(--panel-body-color); + border: 8px var(--border-color) solid; border-radius: 10px; margin-top: 20px; height: 950px; @@ -127,19 +188,25 @@ body{ } .calendar-text-date{ - color: #4c4c4c; + color: var(--panel-body-text-color); font-family: 'Courier New', Courier, monospace; font-size: 20px; - text-shadow: 2px 2px 3px rgb(176,25,126, 0.5); + text-shadow: 2px 2px 3px var(--shadow-color); text-align: center; } .calendar-text{ - color: black; + color: var(--panel-body-text-color); font-family: 'Courier New', Courier, monospace; font-size: 19px; } +.calendar-border{ + border: 0px solid; + border-top: 2px solid var(--panel-header-color); + margin: 10px 0; +} + @media screen and (max-width: 1899px) { .wikithoughts{ width: 49%; diff --git a/src/static/img/goldenmode.png b/src/static/img/goldenmode.png new file mode 100644 index 0000000..31d7344 Binary files /dev/null and b/src/static/img/goldenmode.png differ diff --git a/src/static/js/main.js b/src/static/js/main.js index 92f0f18..457cb72 100644 --- a/src/static/js/main.js +++ b/src/static/js/main.js @@ -1,60 +1,148 @@ +let currentPageTheme = ""; +let currentDatadogTheme = ""; +let currentWeatherTheme = ""; + +/* + The struct of all the different themes. + - Page: The .css page theme to be loaded (make sure they start with theme-)! + - Datadog: The datadog theme to be loaded, can either be light or dark + - Weather: The theme of the weather widget, all the styles can be found here: https://weatherwidget.io/ +*/ +const allThemes = { + golden: { + page: "theme-golden", + datadog: "dark", + weather: "dark" + }, + dark: { + page: "theme-dark", + datadog: "dark", + weather: "kitty" + }, + light: { + page: "theme-light", + datadog: "light", + weather: "kitty" + } +}; + + +function setDatadogTheme(newTheme) { + if (newTheme === currentDatadogTheme) return; + currentDatadogTheme = newTheme; + + const iframe = document.getElementById("datadog"); + const url = new URL(iframe.src); + url.searchParams.set("theme", newTheme); + iframe.src = url.toString(); +} + +function setWeatherTheme(newTheme) { + if (newTheme === currentWeatherTheme) return; + currentWeatherTheme = newTheme; + + const oldWidget = document.getElementById("weather-image"); + + const newWidget = document.createElement("a"); + newWidget.className = "weatherwidget-io"; + newWidget.id = "weather-image"; + newWidget.href = "https://forecast7.com/en/43d16n77d61/rochester/?unit=us"; + + newWidget.setAttribute("data-label_1", "ROCHESTER"); + newWidget.setAttribute("data-label_2", "WEATHER"); + newWidget.setAttribute("data-font", "Fira Sans"); + newWidget.setAttribute("data-icons", "Climacons Animated"); + newWidget.setAttribute("data-days", "100"); + newWidget.setAttribute("data-theme", newTheme); + + newWidget.textContent = "ROCHESTER WEATHER"; + oldWidget.replaceWith(newWidget); +} + +function setNewPageTheme(newTheme) { + if (newTheme === currentPageTheme) return; + currentPageTheme = newTheme; + + document.body.classList.forEach(cls => { + if (cls.startsWith('theme-')) { + document.body.classList.remove(cls); + }}); + + document.body.classList.toggle(newTheme); +} + +function createCalendarEvent(headerText, contentText) { + const container = document.createElement("div"); + container.className = "calendar-event-container-lvl2"; + + const headerLabel = document.createElement("span"); + headerLabel.className = "calendar-text-date"; + headerLabel.textContent = headerText; + container.appendChild(headerLabel); + + container.appendChild(document.createElement("br")); + + const contentLabel = document.createElement("span"); + contentLabel.className = "calendar-text"; + contentLabel.textContent = contentText; + container.appendChild(contentLabel); + + return container; +} + +async function generateCalendar(calData) { + + const calendar = document.getElementById('calendar'); + calendar.replaceChildren(); + calendar.appendChild(document.createElement("br")); + + for (const event of calData) { + const newEvent = createCalendarEvent(event.header, event.content); + calendar.appendChild(newEvent); + + const bracket = document.createElement("hr"); + bracket.className = "calendar-border"; + calendar.appendChild(bracket); + } +} + async function longUpdate() { const date = new Date(); const hour = date.getHours(); const month = date.getMonth() + 1; const day = date.getDate(); + const isDay = (hour > 9 && hour < 18); - let bgImage = "url(../static/img/darkmodeF.png)"; + let bgImage = "url(../static/img/darkmodeF.png)"; + let themeToLoad = "dark"; if (month === 2 && [12, 13, 14].includes(day)) { bgImage = "url(../static/img/valentinemode.png)"; } else if (month === 3 && day === 13) { bgImage = "url(../static/img/jumpstartbang.png)"; + } else if (month === 4 && [9, 10, 11, 12].includes(day)) { + themeToLoad = "golden"; + bgImage = "url(../static/img/goldenmode.png)"; } else if (month === 10 && [29, 30, 31].includes(day)) { bgImage = "url(../static/img/spookymode.png)"; } else if (month === 11 && day === 2) { bgImage = "url(../static/img/duckymode2.png)"; } else if ([11, 12].includes(month)) { bgImage = "url(../static/img/wintermode.png)"; - } else if (hour > 9 && hour < 18) { + } else if (isDay) { + themeToLoad = "light"; bgImage = "url(../static/img/lightmodeF.png)"; } $("body").css("background-image", bgImage); try { + setNewPageTheme(allThemes[themeToLoad].page); + setDatadogTheme(allThemes[themeToLoad].datadog); + setWeatherTheme(allThemes[themeToLoad].weather); + const res = await fetch('/api/calendar', { method: 'GET', mode: 'cors' }); const data = await res.json(); - $("#calendar").html(data.data); - - const isDay = hour > 9 && hour < 18; - const panelBody = $(".panel-body"); - const plugBody = $(".plug-body"); - const wikiPages = $(".wikithoughts-text-body"); - const announcementsBody = $(".announcements-text-body"); - const calendarFrame = $(".calendar-frame-lvl1"); - const calendarTextDate = $(".calendar-text-date"); - const calendarText = $(".calendar-text"); - - if (isDay) { - panelBody.css("background-color", "white"); - plugBody.css("background-color", "white"); - wikiPages.css({ "background-color": "white", "color": "black" }); - announcementsBody.css("color", "black"); - calendarFrame.css("background-color", "white"); - calendarTextDate.css("color", "black"); - calendarText.css("color", "black"); - } else { - panelBody.css("background-color", "black"); - plugBody.css("background-color", "black"); - wikiPages.css({ "background-color": "black", "color": "white" }); - announcementsBody.css("color", "white"); - calendarFrame.css("background-color", "black"); - calendarTextDate.css("color", "white"); - calendarText.css("color", "white"); - } - - $("#datadog").attr('src', ddog_dashboard + new Date().now()); - + await generateCalendar(data.data); } catch (err) { console.log(err); } @@ -68,9 +156,11 @@ async function mediumUpdate() { ]); const wikiData = await wikiRes.json(); const announcementData = await announcementRes.json(); - $("#wikipageheader").text(wikiData.page + " - csh/Wikithoughts") + $("#wikipageheader").text("csh/Wikithoughts - " + wikiData.page) $("#wikipagetext").text(wikiData.content); - $("#announcement").text(announcementData.data.substring(0, 910)); + $("#announcement").text(announcementData.content.substring(0, 910)); + $("#announcement-stamp").text(announcementData.user + " - " + announcementData.timestamp) + /*$("#announcement-header").text("Announcements\n" + announcementData.user)*/ } catch (err) { console.log(err); } @@ -78,9 +168,15 @@ async function mediumUpdate() { -mediumUpdate(); -longUpdate(); +document.addEventListener("DOMContentLoaded", () => { + mediumUpdate(); + longUpdate(); + + setInterval(longUpdate, 60000); + setInterval(mediumUpdate, 22000); -setInterval(longUpdate, 60000); -setInterval(mediumUpdate, 22000); -setInterval(() => { if (globalThis.__weatherwidget_init) globalThis.__weatherwidget_init(); }, 1800000); \ No newline at end of file + setInterval(() => { + if (globalThis.__weatherwidget_init) + globalThis.__weatherwidget_init(); + }, 1800000); +}); diff --git a/src/static/slack/dm_request_template.json b/src/static/slack/dm_request_template.json index 998b881..a3e7554 100644 --- a/src/static/slack/dm_request_template.json +++ b/src/static/slack/dm_request_template.json @@ -14,7 +14,7 @@ "text": {"type": "plain_text", "text": "Yes"}, "style": "primary", "action_id": "yes_j", - "value": null + "value": "" }, { "type": "button", @@ -25,4 +25,4 @@ } ] } -] \ No newline at end of file +] diff --git a/src/templates/base.html b/src/templates/base.html index 05a4b66..29cf96f 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -10,10 +10,9 @@ - {% block head %}{% endblock %} {% block body %}{% endblock %} - \ No newline at end of file + diff --git a/src/templates/index.html b/src/templates/index.html index 1c48aa3..8388290 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -15,11 +15,13 @@
- +
- Announcements + Recent Announcement: + N/A/span> +
@@ -29,15 +31,15 @@
-{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..96dcc6a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,44 @@ +import coverage +import os + +# Get the absolute path to the src directory +src_path = os.path.join(os.path.dirname((os.path.abspath(__file__))), "..", "src") + +cov: coverage.Coverage = coverage.Coverage(source=[src_path], omit=["__init__.py"]) + + +def pytest_sessionstart(session) -> None: + """ + Start the coverage session. + + Args: + session: The pytest session. + + Returns: + None + """ + + cov.start() + + +def pytest_sessionfinish(session, exitstatus) -> None: + """ + Finish the coverage session. + + Args: + session: The pytest session. + exitstatus: The exit status of the session. + + Returns: + None + """ + + cov.stop() + cov.save() + + try: + cov.html_report(directory="htmlcov") + cov.xml_report(outfile="coverage.xml") + cov.report() + except coverage.exceptions.NoDataError: + print("Warning: No coverage data collected") diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..a6c6634 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +pytest==9.0.2 +coverage==7.13.4 diff --git a/tests/src/core/test_slack.py b/tests/src/core/test_slack.py new file mode 100644 index 0000000..ea952a4 --- /dev/null +++ b/tests/src/core/test_slack.py @@ -0,0 +1,155 @@ +import sys +import asyncio +import importlib + + +def import_slack_module(monkeypatch) -> object: + """ + Helper function to import the slack module after setting the SLACK_API_TOKEN environment variable. + + Args: + monkeypatch: The pytest monkeypatch fixture. + + Returns: + object: The imported config module. + """ + + monkeypatch.setenv("SLACK_API_TOKEN", "test-token") + + sys.modules.pop("config", None) + sys.modules.pop("core.slack", None) + + importlib.import_module("config") + + return importlib.import_module("core.slack") + + +def test_clean_text_and_convert_response(monkeypatch): + """ + Test the clean_text and convert_user_response_to_bool functions in the slack module. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + + slack = import_slack_module(monkeypatch) + + raw = "Hello *world* _there_ `code` <skip>" + cleaned = slack.clean_text(raw) + assert "<" not in cleaned + assert "*" not in cleaned + assert "_" not in cleaned + assert "`" not in cleaned + + yes_payload = {"actions": [{"action_id": "yes_j"}]} + no_payload = {"actions": [{"action_id": "no_j"}]} + assert slack.convert_user_response_to_bool(yes_payload) is True + assert slack.convert_user_response_to_bool(no_payload) is False + # malformed payload + assert slack.convert_user_response_to_bool({}) is False + + +def test_gather_emojis_success_and_failure(monkeypatch): + """ + Test the gather_emojis function in the slack module. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + + slack = import_slack_module(monkeypatch) + + class FakeClientSuccess: + async def emoji_list(self): + return {"ok": True, "emoji": {"smile": "url"}} + + monkeypatch.setattr(slack, "client", FakeClientSuccess()) + emojis = asyncio.run(slack.gather_emojis()) + assert emojis == {"smile": "url"} + + class FakeClientFail: + async def emoji_list(self): + raise RuntimeError("boom") + + monkeypatch.setattr(slack, "client", FakeClientFail()) + emojis = asyncio.run(slack.gather_emojis()) + assert emojis == {} + + monkeypatch.setattr(slack, "client", None) + emojis = asyncio.run(slack.gather_emojis()) + assert emojis == {} + + +def test_request_upload_via_dm_success_and_exception(monkeypatch): + """ + Test the request_upload_via_dm function in the slack module. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + + slack = import_slack_module(monkeypatch) + + monkeypatch.setattr( + slack, + "SLACK_DM_TEMPLATE", + [{"text": {"text": ""}}, {"elements": [{"value": ""}]}], + ) + + recorded = {} + + class FakeClient: + async def conversations_open(self, *, users): + return {"ok": True, "channel": {"id": "FAKE_CHANNEL"}} + + async def chat_postMessage(self, *, channel, text, blocks): + recorded["channel"] = channel + recorded["text"] = text + recorded["blocks"] = blocks + + monkeypatch.setattr(slack, "client", FakeClient()) + + asyncio.run(slack.request_upload_via_dm("U123", "Announcement!")) + assert recorded["channel"] == "U123" + assert recorded["text"] == slack.SLACK_JUMPSTART_MESSAGE + + assert isinstance(recorded["blocks"], list) + assert "Announcement!" in recorded["blocks"][0]["text"]["text"] + + class BrokenClient: + async def chat_postMessage(self, **_): + raise RuntimeError("nope") + + monkeypatch.setattr(slack, "client", BrokenClient()) + + asyncio.run(slack.request_upload_via_dm("U123", "Announcement!")) + + +def test_get_and_add_announcement(monkeypatch): + """ + Test the get_announcement and add_announcement functions in the slack module. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + + slack = import_slack_module(monkeypatch) + + slack.current_announcement = None + assert slack.current_announcement is None + + skip_announcements: list[str | None] = [None, "", " "] + for ann in skip_announcements: + slack.add_announcement(ann, "FAKE ID") + + assert slack.get_announcement() is None + + test_announcements: list[str] = ["First", "Second", "Third"] + + for ann in test_announcements: + slack.add_announcement(ann, "FAKE ID") + assert slack.get_announcement().get("content", "") == ann + + assert ( + slack.get_announcement().get("content", "") == test_announcements[-1] + ) # should return last announcement when queue is empty diff --git a/tests/src/test_config.py b/tests/src/test_config.py new file mode 100644 index 0000000..767b36f --- /dev/null +++ b/tests/src/test_config.py @@ -0,0 +1,58 @@ +import sys +import importlib + + +def import_config_module() -> object: + """ + Helper function to import the config module after modifying environment variables. + + Returns: + object: The imported config module. + """ + + if "config" in sys.modules: + del sys.modules["config"] + + return importlib.import_module("config") + + +def test_get_env_variable(monkeypatch) -> None: + """ + Test the _get_env_variable function to ensure it retrieves environment variables correctly. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + + monkeypatch.setenv("TEST_ENV_VAR", "test_value") + cfg = import_config_module() + + assert cfg._get_env_variable("TEST_ENV_VAR", None) == "test_value" + assert cfg._get_env_variable("NON_EXISTENT_VAR", "default_value") == "default_value" + assert cfg._get_env_variable("NON_EXISTENT_VAR", None) is None + + +def test_with_slack_token_parses_values(monkeypatch) -> None: + """ + Test that with a valid SLACK_API_TOKEN, the config values are parsed correctly. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + + monkeypatch.setenv("SLACK_API_TOKEN", "test-token") + monkeypatch.setenv("WATCHED_CHANNELS", "chan1,chan2") + monkeypatch.setenv("CALENDAR_OUTLOOK_DAYS", "5") + monkeypatch.setenv("CALENDAR_EVENT_MAXIMUM", "20") + monkeypatch.setenv("CALENDAR_TIMEZONE", "UTC") + monkeypatch.setenv("CALENDAR_CACHE_REFRESH", "15") + + sys.modules.pop("config", None) + cfg = import_config_module() + + assert cfg.SLACK_API_TOKEN == "test-token" + assert cfg.WATCHED_CHANNELS == ("chan1", "chan2") + assert cfg.CALENDAR_OUTLOOK_DAYS == 5 + assert cfg.CALENDAR_EVENT_MAXIMUM == 20 + assert cfg.CALENDAR_TIMEZONE == "UTC" + assert isinstance(cfg.SLACK_DM_TEMPLATE, list)