Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Lint and Test

on:
push:
pull_request:

permissions:
contents: read

jobs:
test:
name: Test
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v10.0.0

- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: Lint
run: uv run --locked --group dev ruff check src tests

- name: Check formatting
run: uv run --locked --group dev ruff format --check src tests

- name: Run tests
run: uv run --locked --group dev pytest tests/ -W error --cov=argus --cov-report=term-missing --cov-report=xml

- name: Upload coverage report
uses: codecov/codecov-action@v7
with:
files: coverage.xml
10 changes: 6 additions & 4 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: PR title
name: Validate PR Title

on:
pull_request:
Expand All @@ -20,12 +20,14 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v10.0.0
with:
enable-cache: false

- name: Check PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: uvx --from commitizen cz check --message "$PR_TITLE"
run: uv run --locked --group dev cz check --message "$PR_TITLE"
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ dist/
.env
*.db
.venv/
uv.lock
.ruff_cache/
.pytest_cache/

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package-dir = { "" = "src" }

[tool.setuptools.package-data]
"argus.dashboard" = ["templates/*.html"]
"argus.kktix" = ["templates/*.j2"]

[tool.setuptools.dynamic]
version = { attr = "argus.__version__" }
Expand All @@ -23,7 +24,7 @@ dependencies = [
"apscheduler",
"httpx",
"beautifulsoup4",
"authlib",
"authlib<1.7",
"itsdangerous",
"jinja2",
]
Expand All @@ -33,7 +34,9 @@ dev = [
"ruff",
"pytest",
"pytest-asyncio",
"pytest-cov",
"commitizen",
"oidc-provider-mock<0.4",
]

[tool.ruff]
Expand Down
5 changes: 4 additions & 1 deletion src/argus/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

# Module-level OAuth instance, lazily configured
_oauth: OAuth | None = None
_GOOGLE_SERVER_METADATA_URL = (
"https://accounts.google.com/.well-known/openid-configuration"
)


def get_oauth() -> OAuth:
Expand All @@ -15,7 +18,7 @@ def get_oauth() -> OAuth:
oauth = OAuth()
oauth.register(
name="google",
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
server_metadata_url=_GOOGLE_SERVER_METADATA_URL,
client_id=config.secrets.require_google_oauth_client_id(),
client_secret=config.secrets.require_google_oauth_client_secret(),
client_kwargs={"scope": "openid email profile"},
Expand Down
62 changes: 32 additions & 30 deletions src/argus/kktix/report.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
from datetime import datetime, timedelta, timezone
from pathlib import Path
import json
import logging
import sqlite3

from jinja2 import Environment, FileSystemLoader

from argus import discord
from argus.channels import resolve_webhook_url
from argus.database import get_conn
from argus.timeutil import utcnow_iso


logger = logging.getLogger(__name__)

_COLOR_INCREASE = 0x1D9E75
_COLOR_DECREASE = 0xE24B4A
_COLOR_NEUTRAL = 0x888780
_TEMPLATES_DIR = Path(__file__).parent / "templates"
_templates = Environment(loader=FileSystemLoader(_TEMPLATES_DIR), autoescape=False)


def build_payload(
rows: list[dict],
event_meta: list[dict],
prev_counts: dict[tuple[str, str], int],
) -> dict:
tw = timezone(timedelta(hours=8))
now_str = datetime.now(tw).strftime("%Y-%m-%d %H:%M")

first_report_slugs = {
e["event_slug"] for e in event_meta if e["last_reported_at"] is None
}
Expand All @@ -33,61 +36,60 @@ def build_payload(
event_map[slug] = {"name": row["event_name"], "tickets": []}
event_map[slug]["tickets"].append(row)

embeds = []
events = []
for slug, data in event_map.items():
total_now = 0
total_prev = 0
is_first = slug in first_report_slugs
lines = []
ticket_rows = []

for t in data["tickets"]:
ticket_name = t["ticket_name"]
count = t["cnt"]
total_now += count
if is_first:
lines.append(f"{ticket_name} {count}")
ticket_rows.append({"name": ticket_name, "count": count, "delta": None})
else:
prev = prev_counts.get((slug, ticket_name), 0)
total_prev += prev
diff = count - prev
delta = f"(+{diff})" if diff >= 0 else f"({diff})"
lines.append(f"{ticket_name} {count} {delta}")
delta = f"+{diff}" if diff >= 0 else str(diff)
ticket_rows.append(
{"name": ticket_name, "count": count, "delta": delta}
)

lines.append("─────────────")
if is_first:
lines.append(f"**Total {total_now}**")
color = _COLOR_NEUTRAL
total_delta = None
else:
total_diff = total_now - total_prev
total_delta = f"(+{total_diff})" if total_diff >= 0 else f"({total_diff})"
lines.append(f"**Total {total_now} {total_delta}**")
total_delta = f"+{total_diff}" if total_diff >= 0 else str(total_diff)
color = (
_COLOR_INCREASE
if total_diff > 0
else _COLOR_DECREASE if total_diff < 0 else _COLOR_NEUTRAL
else _COLOR_DECREASE
if total_diff < 0
else _COLOR_NEUTRAL
)

embeds.append(
events.append(
{
"title": f"🎟️ {data['name']}",
"description": "\n".join(lines),
"name": data["name"],
"tickets": ticket_rows,
"total": total_now,
"total_delta": total_delta,
"color": color,
}
)

if not embeds:
embeds.append(
{
"title": "📋 Argus Daily Registration Summary",
"description": "No active event registrations.",
"color": _COLOR_NEUTRAL,
}
# Keep Discord's payload schema and display text in the JSON template.
template = _templates.get_template("report.json.j2")
return json.loads(
template.render(
events=events,
)
)

return {
"content": f"📊 **Argus Daily Registration Summary** {now_str} (Asia/Taipei)",
"embeds": embeds,
}

def send_report() -> None:
# Only report on channels that have events whose start_at has not yet passed.
Expand All @@ -101,14 +103,14 @@ def send_report() -> None:
(now,),
).fetchall()

## channel_event_map example:
## channel_event_map example:
## {
## "channel1": {
## "event_slug1": {
## "event_slug": "event_slug1",
## "event_name": "Event 1",
## "last_reported_at": "2024-06-01T00:00:00Z"
## },
## },
## "event_slug2": {
## "event_slug": "event_slug2",
## "event_name": "Event 2",
Expand Down
32 changes: 32 additions & 0 deletions src/argus/kktix/templates/report.json.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{#
Required variables:
- events: A list of event dictionaries. Each event has:
- name: Event title.
- tickets: A list of ticket dictionaries with name, count, and delta.
delta is null for an event's first report; otherwise it is a signed string.
- total: Active ticket count.
- total_delta: null for an event's first report; otherwise a signed string.
- color: Discord embed color as an integer.
#}
{
"content": "📊 **Argus Daily Registration Summary**",
"embeds": [
{% for event in events %}
{% set description = namespace(value="") %}
{% for ticket in event.tickets %}
{% set description.value = description.value ~ ticket.name ~ " " ~ ticket.count ~ (" (" ~ ticket.delta ~ ")" if ticket.delta else "") ~ "\n" %}
{% endfor %}
{
"title": {{ ("🎟️ " ~ event.name) | tojson }},
"description": {{ (description.value ~ "─────────────\n**Total " ~ event.total ~ (" (" ~ event.total_delta ~ ")" if event.total_delta else "") ~ "**") | tojson }},
"color": {{ event.color }}
}{% if not loop.last %},{% endif %}
{% else %}
{
"title": "📋 Argus Daily Registration Summary",
"description": "No active event registrations.",
"color": 8947840
}
{% endfor %}
]
}
8 changes: 8 additions & 0 deletions tests/scisprint-202608-taipei.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!--
Extracted from https://sciwork.kktix.cc/events/scisprint-202608-taipei
on 2026-08-13. This is the minimal source markup used by the scraper.
-->
<script type="application/ld+json">
[{"@context":"http://schema.org","@type":"Event","name":"scisprint Taipei 2026 August","url":"https://sciwork.kktix.cc/events/scisprint-202608-taipei","startDate":"2026-08-15T10:00:00.000+08:00","endDate":"2026-08-15T17:00:00.000+08:00"}]
</script>
<span class="info-count"><i class="fa fa-male"></i>11 / 20</span>
93 changes: 93 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from dataclasses import replace
from urllib.parse import urlsplit

from fastapi import FastAPI
from oidc_provider_mock import User, run_server_in_thread
from starlette.middleware.sessions import SessionMiddleware
import httpx
import pytest

from argus import auth, config
from argus.dashboard import router


@pytest.fixture
def dashboard_app(monkeypatch):
"""Create a dashboard app configured to allow Chester's email."""
monkeypatch.setattr(
config,
"settings",
replace(config.settings, allowed_emails=("chester@example.com",)),
)
monkeypatch.setattr(
config,
"secrets",
config.Secrets("", "test-client-id", "test-client-secret", ""),
)
monkeypatch.setattr(router.queries, "list_events", lambda: [])

app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key="test-session-secret")
app.include_router(router.router)
return app


def test_is_email_allowed_matches_allowlist_case_insensitively(monkeypatch):
"""Allow Chester's configured dashboard email regardless of casing."""
monkeypatch.setattr(
config,
"settings",
replace(config.settings, allowed_emails=("Chester@Example.com",)),
)

assert auth.is_email_allowed("chester@example.com") is True
assert auth.is_email_allowed("steve@example.com") is False
assert auth.is_email_allowed("") is False


@pytest.mark.asyncio
@pytest.mark.parametrize(
("email", "expected_status"),
[("chester@example.com", 302), ("steve@example.com", 403)],
)
async def test_google_oauth_accepts_only_allowlisted_user(
dashboard_app, monkeypatch, email, expected_status
):
"""Complete OAuth login with Chester allowed and Steve denied."""
with run_server_in_thread(
user_claims=[User(sub=email, claims={"email": email})]
) as server:
provider_url = f"http://localhost:{server.server_port}"
monkeypatch.setattr(
auth,
"_GOOGLE_SERVER_METADATA_URL",
f"{provider_url}/.well-known/openid-configuration",
)
auth.reset_oauth()

try:
transport = httpx.ASGITransport(app=dashboard_app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test"
) as client:
login = await client.get("/dashboard/login", follow_redirects=False)

# The OIDC mock runs on a loopback HTTP server, outside the ASGI app.
async with httpx.AsyncClient() as provider_client:
authorized = await provider_client.post(
login.headers["location"], data={"sub": email}
)

callback = urlsplit(authorized.headers["location"])
response = await client.get(
f"{callback.path}?{callback.query}", follow_redirects=False
)

assert response.status_code == expected_status
if expected_status == 302:
assert response.headers["location"] == "/dashboard"
assert (
await client.get("/dashboard/api/events")
).status_code == 200
finally:
auth.reset_oauth()
Loading