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
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
24 changes: 16 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,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
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
1 change: 0 additions & 1 deletion apicore/tests.py

This file was deleted.

Loading
Loading