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 8449929..844b7f8 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: @@ -298,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 @@ -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: diff --git a/readme.md b/readme.md index 6563b96..36b59c2 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) +**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. Syncs character data (professions, equipment, mounts, pets) from the Blizzard Battle.net API and parses WoW Lua addon exports to serve keybind data.