From eb996e9314ed6f0d82a07aa2f0992e04a7f2e450 Mon Sep 17 00:00:00 2001 From: Joe Farrelly Date: Fri, 19 Jun 2026 20:00:49 +0100 Subject: [PATCH 1/4] Add cross-repo suite badges to README --- readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readme.md b/readme.md index 6563b96..cdb7d6a 100644 --- a/readme.md +++ b/readme.md @@ -5,6 +5,8 @@ [![codecov](https://codecov.io/gh/joefarrelly/FazzToolsAPI/graph/badge.svg)](https://codecov.io/gh/joefarrelly/FazzToolsAPI) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +**Frontend** [![Deploy](https://github.com/joefarrelly/FazzToolsFrontend/actions/workflows/deploy.yml/badge.svg)](https://github.com/joefarrelly/FazzToolsFrontend/actions/workflows/deploy.yml) [![Lint and Test](https://github.com/joefarrelly/FazzToolsFrontend/actions/workflows/lint.yml/badge.svg)](https://github.com/joefarrelly/FazzToolsFrontend/actions/workflows/lint.yml) | **Scraper** [![Lint](https://github.com/joefarrelly/FazzToolsScraper/actions/workflows/lint.yml/badge.svg)](https://github.com/joefarrelly/FazzToolsScraper/actions/workflows/lint.yml) [![Release](https://github.com/joefarrelly/FazzToolsScraper/actions/workflows/release.yml/badge.svg)](https://github.com/joefarrelly/FazzToolsScraper/actions/workflows/release.yml) + Django REST Framework backend for **FazzTools** — a World of Warcraft companion app. Syncs character data (professions, equipment, mounts, pets) from the Blizzard Battle.net API and parses WoW Lua addon exports to serve keybind data. From 68b4faa333e26e31dbe7ddee89bec10fb59f5606 Mon Sep 17 00:00:00 2001 From: Joe Farrelly Date: Fri, 19 Jun 2026 20:13:38 +0100 Subject: [PATCH 2/4] Replace cross-repo badges with suite links --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index cdb7d6a..36b59c2 100644 --- a/readme.md +++ b/readme.md @@ -5,7 +5,7 @@ [![codecov](https://codecov.io/gh/joefarrelly/FazzToolsAPI/graph/badge.svg)](https://codecov.io/gh/joefarrelly/FazzToolsAPI) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -**Frontend** [![Deploy](https://github.com/joefarrelly/FazzToolsFrontend/actions/workflows/deploy.yml/badge.svg)](https://github.com/joefarrelly/FazzToolsFrontend/actions/workflows/deploy.yml) [![Lint and Test](https://github.com/joefarrelly/FazzToolsFrontend/actions/workflows/lint.yml/badge.svg)](https://github.com/joefarrelly/FazzToolsFrontend/actions/workflows/lint.yml) | **Scraper** [![Lint](https://github.com/joefarrelly/FazzToolsScraper/actions/workflows/lint.yml/badge.svg)](https://github.com/joefarrelly/FazzToolsScraper/actions/workflows/lint.yml) [![Release](https://github.com/joefarrelly/FazzToolsScraper/actions/workflows/release.yml/badge.svg)](https://github.com/joefarrelly/FazzToolsScraper/actions/workflows/release.yml) +**Suite:** [Backend](https://github.com/joefarrelly/FazzToolsAPI) · [Frontend](https://github.com/joefarrelly/FazzToolsFrontend) · [Addon](https://github.com/joefarrelly/FazzToolsScraper) Django REST Framework backend for **FazzTools** — a World of Warcraft companion app. From b626acd5ec66f5dc7852b96309d0b7ff1ddd90f8 Mon Sep 17 00:00:00 2001 From: Joe Farrelly Date: Sun, 21 Jun 2026 22:27:09 +0100 Subject: [PATCH 3/4] Add Celery reliability: rate limiting, error handling, stale purge --- apicore/tasks.py | 107 ++++++++++++++++++++++++++++++++------------ backend/settings.py | 7 +++ docker-compose.yml | 13 ++++++ 3 files changed, 98 insertions(+), 29 deletions(-) diff --git a/apicore/tasks.py b/apicore/tasks.py index 8449929..bacc692 100644 --- a/apicore/tasks.py +++ b/apicore/tasks.py @@ -90,7 +90,17 @@ } _session = requests.Session() -_session.mount("https://", HTTPAdapter(max_retries=Retry(total=3, backoff_factor=1))) +_session.mount( + "https://", + HTTPAdapter( + max_retries=Retry( + total=5, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["HEAD", "GET", "POST"], + ) + ), +) # --------------------------------------------------------------------------- @@ -129,35 +139,47 @@ def fullAltScan(user_id: str, client: str, secret: str) -> None: group(scan_single_alt.s(alt_id, user_id, token) for alt_id in alt_ids).apply_async() -@shared_task -def scan_single_alt(alt_id: int, user_id: str, token: str) -> None: - alt = ProfileAlt.objects.get(alt_id=alt_id) - user = ProfileUser.objects.get(user_id=user_id) - auth_headers = {"Authorization": f"Bearer {token}"} - - char_base = f"{_EU_API_BASE}/profile/wow/character/{alt.alt_realm_slug}/{alt.alt_name.lower()}" - endpoints = { - "professions": f"{char_base}/professions", - "equipment": f"{char_base}/equipment", - "mounts": f"{char_base}/collections/mounts", - "pets": f"{char_base}/collections/pets", - } - - for key, url in endpoints.items(): - resp = _api_get(url, _PROFILE_PARAMS, auth_headers) - if resp.status_code != 200: - logger.warning("Blizzard API %s %s", resp.status_code, url) - continue - if key == "professions": - _sync_professions(alt, resp.json(), auth_headers) - elif key == "equipment": - _sync_equipment(alt, resp.json()) - elif key == "mounts": - _sync_mounts(user, resp.json()) - elif key == "pets": - _sync_pets(user, resp.json()) +@shared_task(bind=True) +def scan_single_alt(self, alt_id: int, user_id: str, token: str) -> None: + try: + alt = ProfileAlt.objects.get(alt_id=alt_id) + user = ProfileUser.objects.get(user_id=user_id) + auth_headers = {"Authorization": f"Bearer {token}"} - logger.info("Completed alt scan: %s-%s", alt.alt_name, alt.alt_realm_slug) + char_base = ( + f"{_EU_API_BASE}/profile/wow/character/{alt.alt_realm_slug}/{alt.alt_name.lower()}" + ) + endpoints = { + "professions": f"{char_base}/professions", + "equipment": f"{char_base}/equipment", + "mounts": f"{char_base}/collections/mounts", + "pets": f"{char_base}/collections/pets", + } + + for key, url in endpoints.items(): + resp = _api_get(url, _PROFILE_PARAMS, auth_headers) + if resp.status_code != 200: + logger.warning("Blizzard API %s %s", resp.status_code, url) + continue + if key == "professions": + _sync_professions(alt, resp.json(), auth_headers) + elif key == "equipment": + _sync_equipment(alt, resp.json()) + elif key == "mounts": + _sync_mounts(user, resp.json()) + elif key == "pets": + _sync_pets(user, resp.json()) + + logger.info("Completed alt scan: %s-%s", alt.alt_name, alt.alt_realm_slug) + except Exception as exc: + logger.error( + "scan_single_alt failed: alt_id=%s user_id=%s task_id=%s error=%s", + alt_id, + user_id, + self.request.id, + exc, + ) + raise def _sync_professions(alt: ProfileAlt, data: dict, auth_headers: dict) -> None: @@ -595,3 +617,30 @@ def _sync_pet_data(index_data: dict, auth_headers: dict) -> None: ) except (KeyError, TypeError) as exc: logger.warning("Failed to parse pet: %s", exc) + + +# --------------------------------------------------------------------------- +# Maintenance — purge stale profile data +# --------------------------------------------------------------------------- + + +@shared_task +def purge_stale_profiles() -> None: + now = timezone.now() + prof_data_deleted, _ = ProfileAltProfessionData.objects.filter( + alt_profession_data_expiry_date__lt=now + ).delete() + prof_deleted, _ = ProfileAltProfession.objects.filter( + alt_profession_expiry_date__lt=now + ).delete() + equip_deleted, _ = ProfileAltEquipment.objects.filter( + alt_equipment_expiry_date__lt=now + ).delete() + alt_deleted, _ = ProfileAlt.objects.filter(alt_expiry_date__lt=now).delete() + logger.info( + "purge_stale_profiles: deleted %d profession_data, %d professions, %d equipment, %d alts", + prof_data_deleted, + prof_deleted, + equip_deleted, + alt_deleted, + ) diff --git a/backend/settings.py b/backend/settings.py index 02813c7..1f7d0d0 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -106,6 +106,13 @@ "apicore.tasks.scan_single_alt": {"queue": "alt_scan"}, } +CELERY_BEAT_SCHEDULE = { + "purge-stale-profiles-daily": { + "task": "apicore.tasks.purge_stale_profiles", + "schedule": 86400, + }, +} + REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": 100, diff --git a/docker-compose.yml b/docker-compose.yml index 61fb278..9bf2e57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,19 @@ services: redis: condition: service_started + beat: + build: . + command: celery -A backend beat -l info + volumes: + - .:/app + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + volumes: db_data: media_data: From 9384c6270c223a5b3d96facc39d96527c410b966 Mon Sep 17 00:00:00 2001 From: Joe Farrelly Date: Sun, 21 Jun 2026 22:32:15 +0100 Subject: [PATCH 4/4] Rename DataEquipmentVariant.armour to armor --- .../migrations/0010_rename_armour_to_armor.py | 17 +++++++++++++++++ apicore/models.py | 2 +- apicore/serializers.py | 2 +- apicore/tasks.py | 4 ++-- 4 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 apicore/migrations/0010_rename_armour_to_armor.py diff --git a/apicore/migrations/0010_rename_armour_to_armor.py b/apicore/migrations/0010_rename_armour_to_armor.py new file mode 100644 index 0000000..8d9307b --- /dev/null +++ b/apicore/migrations/0010_rename_armour_to_armor.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2 on 2026-06-21 21:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("apicore", "0009_dataprofessionrecipe_recipe_icon"), + ] + + operations = [ + migrations.RenameField( + model_name="dataequipmentvariant", + old_name="armour", + new_name="armor", + ), + ] diff --git a/apicore/models.py b/apicore/models.py index ebc850a..8c0f830 100644 --- a/apicore/models.py +++ b/apicore/models.py @@ -90,7 +90,7 @@ class DataEquipmentVariant(models.Model): equipment = models.ForeignKey(DataEquipment, on_delete=models.CASCADE) variant = models.CharField(max_length=64) stamina = models.PositiveSmallIntegerField() - armour = models.PositiveSmallIntegerField() + armor = models.PositiveSmallIntegerField() strength = models.PositiveSmallIntegerField() agility = models.PositiveSmallIntegerField() intellect = models.PositiveSmallIntegerField() diff --git a/apicore/serializers.py b/apicore/serializers.py index 2a07738..c2e4250 100644 --- a/apicore/serializers.py +++ b/apicore/serializers.py @@ -69,7 +69,7 @@ class Meta: "equipment", "variant", "stamina", - "armour", + "armor", "strength", "agility", "intellect", diff --git a/apicore/tasks.py b/apicore/tasks.py index bacc692..844b7f8 100644 --- a/apicore/tasks.py +++ b/apicore/tasks.py @@ -320,14 +320,14 @@ def _sync_equipment(alt: ProfileAlt, data: dict) -> None: def _parse_item_stats(item: dict) -> dict: defaults = {v: 0 for v in _STAT_FIELDS.values()} - defaults["armour"] = 0 + defaults["armor"] = 0 for stat in item.get("stats", []): field = _STAT_FIELDS.get(stat.get("type", {}).get("type", "")) if field: defaults[field] = stat.get("value", 0) - defaults["armour"] = item.get("armor", {}).get("value", 0) + defaults["armor"] = item.get("armor", {}).get("value", 0) defaults["level"] = item.get("level", {}).get("value", 0) defaults["quality"] = item.get("quality", {}).get("name", "") return defaults