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
14 changes: 14 additions & 0 deletions .claude/commands/migrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Django Migrate

Run Django database migrations via Docker Compose.

## Steps

1. **Run migrations** — execute `docker compose exec web python manage.py migrate` from the project root.

2. **Report** — show the output so the user can see which migrations were applied (or confirm "No migrations to apply." if already up to date).

## Notes

- If Docker is not running or the `web` container is not up, tell the user and suggest running `docker compose up -d` first.
- If a migration fails, show the full error output and stop — do not attempt to fake success.
44 changes: 0 additions & 44 deletions .claude/commands/mr.md

This file was deleted.

2 changes: 0 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,3 @@ BLIZZ_CLIENT=your-blizzard-client-id
BLIZZ_SECRET=your-blizzard-client-secret
BLIZZ_REDIRECT_URI=http://localhost:3000/redirect/

# Admin-only data scan trigger
DATA_PASSWORD=your-data-password-here
26 changes: 26 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/sh
set -e

# Python: run ruff via uvx (no-op if no .py files)
if command -v uvx >/dev/null 2>&1; then
uvx ruff check --fix . 2>/dev/null || true
uvx ruff format . 2>/dev/null || true
git add -u
fi

# JS/TS: root-level package.json (e.g. React apps), else frontend/ subdir
if command -v npm >/dev/null 2>&1; then
if [ -f "package.json" ]; then
[ ! -d node_modules ] && npm install --silent
npm run lint -- --fix 2>/dev/null || true
npm run format 2>/dev/null || true
git add -u
elif [ -d "frontend" ] && [ -f "frontend/package.json" ]; then
cd frontend
[ ! -d node_modules ] && npm install --silent
npm run lint -- --fix 2>/dev/null || true
npm run format 2>/dev/null || true
cd ..
git add -u
fi
fi
7 changes: 3 additions & 4 deletions .github/workflows/dev-to-main-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,10 @@ jobs:
MERGED_BRANCH: ${{ github.event.pull_request.head.ref }}
REPO: ${{ github.repository }}
run: |
OWNER="${REPO%%/*}"
EXISTING=$(gh pr list \
--repo "$REPO" \
--base main \
--head "${OWNER}:dev" \
--head dev \
--json number,body)

PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number // empty')
Expand All @@ -35,9 +34,9 @@ jobs:
--title "dev into main" \
--body "- $MERGED_BRANCH"
else
BODY=$(echo "$EXISTING" | jq -r '.[0].body')
BODY=$(echo "$EXISTING" | jq -r '.[0].body // ""')

if echo "$BODY" | grep -qF "- $MERGED_BRANCH"; then
if echo "$BODY" | grep -qFe "- $MERGED_BRANCH"; then
echo "Branch already listed, skipping."
else
gh pr edit "$PR_NUMBER" \
Expand Down
42 changes: 41 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Lint
name: Lint and Test

on:
pull_request:
Expand All @@ -21,3 +21,43 @@ jobs:
uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
with:
args: "format --check"

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

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

- name: Install system dependencies
run: sudo apt-get install -y default-libmysqlclient-dev build-essential pkg-config

- name: Install Python dependencies
run: pip install -r requirements.txt

- name: Run tests with coverage
env:
SECRET_KEY: ci-secret-key-not-used-in-production
DEBUG_OPTION: "True"
DB_NAME: ci_db
DB_USER: ci_user
DB_PASSWORD: ci_pass
DB_HOST: localhost
DB_PORT: "3306"
FRONTEND_URL: http://localhost:3000
HASH_KEY: ci-hash-key-not-used-in-production
BLIZZ_CLIENT: ci-client
BLIZZ_SECRET: ci-secret
BLIZZ_REDIRECT_URI: http://localhost:3000/redirect/
run: python -m pytest tests/ -v

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ignored files

.env
dump.txt
/media/uploads
mapping.txt
FazzToolsScraper.lua
Expand All @@ -17,4 +18,6 @@ test.py
# Python
__pycache__/
*.pyc
*.pyo
*.pyo
coverage.xml
.coverage
30 changes: 19 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ HASH_KEY= # Used to HMAC-hash the Blizzard user ID into our use
BLIZZ_CLIENT= # Blizzard OAuth app client ID
BLIZZ_SECRET= # Blizzard OAuth app secret
BLIZZ_REDIRECT_URI= # Must exactly match the redirect URI registered in Blizzard dev portal
DATA_PASSWORD= # Password to trigger a full data scan
```

After changing `.env`, use `docker compose up -d web` (not `restart`) to pick up the new values.
Expand All @@ -63,14 +62,22 @@ After changing `.env`, use `docker compose up -d web` (not `restart`) to pick up
## Project layout

```
backend/ Django project config (settings, urls, celery, wsgi)
apicore/ The single Django app
models.py All DB models
views.py All ViewSets + Lua file parser
tasks.py Celery tasks (fullAltScan, fullDataScan)
serializers.py DRF serializers
libs/ Helper mappings (keybind_mapping, icon_mapping)
migrations/ DB migrations
backend/ Django project config (settings, urls, celery, wsgi)
test_settings.py Overrides DB→SQLite and cache→locmem for pytest
apicore/ The single Django app
models.py All DB models
views.py All ViewSets + Lua file parser
tasks.py Celery tasks (fullAltScan, fullDataScan)
serializers.py DRF serializers
permissions.py IsSessionUser permission class
libs/
keybind_builder.py Pure keybind-building logic (build_all/single_keybinds, tier_sort_key)
keybind_mapping.py Slot→action-button mappings per addon
lua_parser.py Hand-rolled Lua-table-to-JSON converter
icon_mapping.py Mount/pet icon mappings
migrations/ DB migrations
tests/ pytest suite (47 tests); run via pytest tests/
conftest.py pytest env-var setup (pytest_configure hook)
```

## API URL structure
Expand Down Expand Up @@ -98,7 +105,7 @@ apicore/ The single Django app
### Custom endpoints
- `POST /api/custom/bnetlogin/` — Battle.net OAuth2 callback; creates/updates user and syncs alts
- `POST /api/custom/scanalt/` — Triggers `fullAltScan` Celery task for a user
- `POST /api/custom/datascan/` — Triggers `fullDataScan` Celery task (password-protected)
- `POST /api/custom/datascan/` — Triggers `fullDataScan` Celery task (Django admin user required)

## Key data flows

Expand Down Expand Up @@ -135,4 +142,5 @@ Fetches Blizzard static data API indexes and walks all professions (tiers → ca
- Several views use a flexible `fields[]` query param pattern to let the frontend request only the columns it needs
- `ProfileAltEquipment` stores equipment as `"equipmentId:variantCode"` strings rather than FK relations
- Expiry dates (`altExpiryDate`, `altProfessionExpiryDate`, etc.) are set to `now + 30 days` on each scan but are not actively enforced server-side
- `DataScan` is password-protected via `DATA_PASSWORD` env var — not authed via the normal auth system
- `DataScan` requires a Django admin user (`IsAdminUser`) — not the session-based auth used by profile endpoints
- Session auth: `BnetLogin` sets `request.session["user_id"]` on login; profile views enforce it via `IsSessionUser` (checks session against `?user=` param). `CORS_ALLOW_CREDENTIALS = True` is required for cookies to flow cross-origin.
153 changes: 153 additions & 0 deletions apicore/libs/keybind_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Pure keybind-building logic, extracted from views.py.

These functions transform a parsed Lua addon dict into the response shapes
consumed by the frontend. No Django models are touched here except for
_build_all_keybinds, which looks up ProfileAlt for class display names.
"""

import logging

from apicore.libs.keybind_mapping import getKeybindMap

logger = logging.getLogger(__name__)

_EXPANSION_ORDER: dict[str, int] = {
"classic": 0,
"outland": 1,
"northrend": 2,
"cataclysm": 3,
"pandaria": 4,
"draenor": 5,
"legion": 6,
"kul tiran": 7,
"zandalari": 7,
"shadowlands": 8,
"dragon isles": 9,
"khaz algar": 10,
"midnight": 11,
}

_SPAM_FILTER = {
"Auto Attack",
"Mobile Banking",
"Revive Battle Pets",
"Vindicaar Matrix Crystal",
"Shoot",
}

_SECTION_ORDER = {"Base": 0, "Talent": 1, "Misc": 2}


def tier_sort_key(tier_name: str) -> int:
name_lower = tier_name.lower()
for keyword, order in _EXPANSION_ORDER.items():
if keyword in name_lower:
return order
return 999


def build_all_keybinds(data: dict, user_id: str) -> list:
from apicore.models import ProfileAlt

result = []
for alt_key, alt_config in data.get("alts", {}).items():
specs = []
try:
if alt_config.get("kb") is not None:
specs = list(alt_config["kb"].keys())
else:
specs = ["---", "---", "---", "---"]
except (KeyError, TypeError):
specs = ["---", "---", "---", "---"]

specs.sort()
while len(specs) < 4:
specs.append("---")

name, realm = (alt_key.split("-", 1) + [""])[:2]
try:
alt_obj = ProfileAlt.objects.get(alt_name=name, alt_realm=realm)
row = [name, realm, alt_obj.get_alt_class_display()] + specs
result.append(row)
except ProfileAlt.DoesNotExist:
logger.debug("Alt not in DB: %s", alt_key)

result.sort(key=lambda x: (x[1], x[0]))
return result


def build_single_keybinds(data: dict, alt: str, realm: str, spec: str) -> list:
alt_key = f"{alt}-{realm}"
alt_config = data["alts"][alt_key]
keybind_map = getKeybindMap(alt_config["kbConfig"]["addon"])

user_keybind: dict[str, str] = {}
for slot, nice_spell in alt_config["kb"][spec].items():
prefix = nice_spell.split(":")[0]

if prefix == "spell":
try:
user_keybind[nice_spell] = alt_config["kbConfig"]["map"][keybind_map[int(slot)]]
except (KeyError, ValueError):
pass

elif prefix == "macro":
macro_name = nice_spell.split(":")[1]
found = False
for tab in alt_config.get("spell", {}).get(spec, {}):
for spell_id, spell_info in alt_config["spell"][spec][tab].items():
if spell_info[0] in alt_config["macro"][macro_name][2]:
found = True
spell_key = f"spell:{spell_id}"
try:
bound = alt_config["kbConfig"]["map"][keybind_map[int(slot)]]
except (KeyError, ValueError):
continue
if spell_key not in user_keybind:
user_keybind[spell_key] = bound
elif user_keybind[spell_key] != bound:
user_keybind[spell_key] += f" | {bound}"
if not found:
try:
user_keybind[nice_spell] = alt_config["kbConfig"]["map"][keybind_map[int(slot)]]
except (KeyError, ValueError):
pass

elif prefix == "item":
item_name = nice_spell.split(":")[1]
if item_name in alt_config.get("item", {}):
try:
user_keybind[nice_spell] = alt_config["kbConfig"]["map"][keybind_map[int(slot)]]
except (KeyError, ValueError):
pass

full_result = []
for tab in alt_config.get("spell", {}).get(spec, {}):
spells = []
for spell_id, spell_info in alt_config["spell"][spec][tab].items():
if spell_info[0] in _SPAM_FILTER:
continue
entry = [spell_info[0]]
if len(spell_info) > 1:
entry.append(spell_info[1])
entry.append(user_keybind.get(f"spell:{spell_id}", "UNBOUND"))
spells.append(entry)
spells.sort(key=lambda x: x[0])
full_result.append([tab.title(), spells])

misc = []
for item_name, item_info in alt_config.get("item", {}).items():
if f"item:{item_name}" in user_keybind:
misc.append([item_info[0], user_keybind[f"item:{item_name}"]])
for macro_name, macro_info in alt_config.get("macro", {}).items():
if f"macro:{macro_name}" in user_keybind:
misc.append([f"[Macro] {macro_info[0]}", user_keybind[f"macro:{macro_name}"]])
misc.sort(key=lambda x: x[0])
full_result.append(["Misc", misc])

full_result.sort(key=lambda x: _SECTION_ORDER.get(x[0], 99))

if len(full_result) >= 2:
full_result[0][1] = [x for x in full_result[0][1] if x not in full_result[1][1]]

return full_result
Loading
Loading