aa-affiliation-refresh 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. aa_affiliation_refresh-0.1.0a1/LICENSE +21 -0
  2. aa_affiliation_refresh-0.1.0a1/PKG-INFO +213 -0
  3. aa_affiliation_refresh-0.1.0a1/README.md +185 -0
  4. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/__init__.py +8 -0
  5. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/_log.py +26 -0
  6. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/app_settings.py +70 -0
  7. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/apps.py +25 -0
  8. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/checks.py +86 -0
  9. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/claims.py +49 -0
  10. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/keys.py +65 -0
  11. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/management/__init__.py +0 -0
  12. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/management/commands/__init__.py +0 -0
  13. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/management/commands/affiliation_refresh.py +456 -0
  14. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/markers.py +99 -0
  15. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/probe.py +133 -0
  16. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/refresh.py +512 -0
  17. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/selectors.py +80 -0
  18. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/signals.py +156 -0
  19. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/tasks.py +427 -0
  20. aa_affiliation_refresh-0.1.0a1/aa_affiliation_refresh/utils.py +73 -0
  21. aa_affiliation_refresh-0.1.0a1/pyproject.toml +170 -0
  22. aa_affiliation_refresh-0.1.0a1/pyproject.toml.orig +176 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Boris Talovikov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,213 @@
1
+ Metadata-Version: 2.4
2
+ Name: aa-affiliation-refresh
3
+ Version: 0.1.0a1
4
+ Summary: Refresh Alliance Auth character affiliations minutes after they change
5
+ Keywords: allianceauth,eve-online,esi,affiliation
6
+ Author: Boris Talovikov
7
+ Author-email: Boris Talovikov <boris.t.66@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 5.2
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: System :: Systems Administration
20
+ Requires-Dist: allianceauth>=5.3,<6
21
+ Requires-Dist: httpx>=0.28
22
+ Maintainer: Boris Talovikov, Boris Blade Artrald
23
+ Maintainer-email: Boris Talovikov <boris.t.66@gmail.com>, Boris Blade Artrald <boris-blade-artrald@eveo7.ru>
24
+ Requires-Python: >=3.11, <3.14
25
+ Project-URL: Source, https://gitlab.com/eveo7/aa-affiliation-refresh
26
+ Project-URL: Tracker, https://gitlab.com/eveo7/aa-affiliation-refresh/-/issues
27
+ Description-Content-Type: text/markdown
28
+
29
+ # aa-affiliation-refresh
30
+
31
+ Compares the corporation, alliance and faction of Alliance Auth characters with ESI - every 10 minutes for characters linked within the last two hours, every 65 minutes for every active user's main - and queues AA's own `update_character` for the ones that differ, so a newcomer gets the right state within about 70 minutes instead of the six-hour `run_model_update` cycle. These are the two lanes, `recent` and `mains`: periodic selection sweeps, each with its own schedule, priority and `last_run` / `last_chunk` markers. A user's main is also checked at login and when the main changes (debounced). When a character checked by the recent lane, by the login trigger or by the main-change trigger turns out to have changed alliance or faction while staying in its corporation, the other mains of that corporation are checked once (the cascade); the mains lane does not cascade, it covers every main anyway.
32
+
33
+ It writes nothing itself: `update_character` in AA does the write, the state assignment and the service hooks. It does not change states, groups or permissions, sends no notifications, keeps no history and exposes no metrics; `EveCorporationInfo` / `EveAllianceInfo` stay with `run_model_update`.
34
+
35
+ Requires `allianceauth>=5.3,<6` on Python 3.11-3.13. No models, no migrations: all runtime state lives in the Django cache under the prefix `aa_affiliation_refresh:`.
36
+
37
+ ## Install
38
+
39
+ ```sh
40
+ pip install aa-affiliation-refresh
41
+ ```
42
+
43
+ In `local.py`:
44
+
45
+ ```python
46
+ INSTALLED_APPS += ["aa_affiliation_refresh"]
47
+ ```
48
+
49
+ ```python
50
+ from celery.schedules import crontab
51
+ from datetime import timedelta
52
+
53
+ CELERYBEAT_SCHEDULE["affiliation_refresh_recent_characters"] = {
54
+ "task": "aa_affiliation_refresh.refresh_recent_characters",
55
+ "schedule": crontab(minute="*/10"),
56
+ "options": {"priority": 4},
57
+ }
58
+ CELERYBEAT_SCHEDULE["affiliation_refresh_main_characters"] = {
59
+ "task": "aa_affiliation_refresh.refresh_main_characters",
60
+ "schedule": timedelta(minutes=65),
61
+ }
62
+ ```
63
+
64
+ `options` puts the selection task of the recent lane at the priority of its chunks; without it the task runs at Celery's default 5 and waits behind AA's own tasks while its chunks would go at 4. Restart beat and the workers: beat reads the schedule at start. `python manage.py check` reports `aa_affiliation_refresh.W001` when the `refresh_recent_characters` entry is missing and `aa_affiliation_refresh.I001` when the `refresh_main_characters` entry is missing; the mains lane is optional and is disabled by deleting its entry. The key must be `CELERYBEAT_SCHEDULE`: AA hands that name to Celery, so an entry under `CELERY_BEAT_SCHEDULE` reaches neither beat nor the check.
65
+
66
+ Undo: delete the two entries and the `INSTALLED_APPS` line, then in the AA admin (Periodic tasks) delete or disable the two periodic tasks `affiliation_refresh_recent_characters` and `affiliation_refresh_main_characters`: beat copies `CELERYBEAT_SCHEDULE` into that table at start and only adds or updates rows, never deletes them, so a leftover row keeps sending a task no worker registers (`Received unregistered task of type ...` on every tick). Restart beat and the workers. Those two `PeriodicTask` rows are the only database trace; cache keys expire within an hour (the login debounce key within `LOGIN_DEBOUNCE_SECONDS`, a `rejected` mark within 86400 s) except the two `last_run` / `last_chunk` markers per lane, which have no TTL: `cache.delete_pattern("aa_affiliation_refresh:*")` in `python manage.py shell` (`from django.core.cache import cache`; `delete_pattern` is django-redis, the backend of the AA template) removes them with every other key of the module and nothing else, where `cache.clear()` would empty the cache of the whole installation.
67
+
68
+ ## Settings
69
+
70
+ Read once at import; all optional.
71
+
72
+ | Setting | Default | Range | Meaning |
73
+ |---|---|---|---|
74
+ | `AA_AFFILIATION_REFRESH_RECENT_WINDOW_MINUTES` | `120` | `>= 1` | how long after a character is linked the recent lane keeps checking it; must exceed CCP's one-hour cache plus the lane interval (10 minutes), or the character drops out of the selection before ESI stops serving the stale answer |
75
+ | `AA_AFFILIATION_REFRESH_CHUNK_SIZE` | `500` | `1..1000` | ids per ESI request; ESI accepts up to 1000 on `POST /characters/affiliation`, AA uses 500 |
76
+ | `AA_AFFILIATION_REFRESH_LOGIN_DEBOUNCE_SECONDS` | `600` | `>= 0` | at most one main-character check per login or main change within this many seconds; `0` disables both triggers |
77
+
78
+ A value of the wrong type (a boolean counts as the wrong type for a number) or outside the range is not an error: the worker and the web process log one `warning` from `extensions.aa_affiliation_refresh.utils` at import (`setting invalid name=<name> value=<value> reason=<reason> default=<default>`, `reason` one of `wrong_type`, `below_min`, `above_max`; for `wrong_type` the `value` is the type) and run with the default. `python manage.py check` does not report this; the log at startup is the only trace.
79
+
80
+ Not settings (module constants): `update_character` for one character is queued at most once per 3600 s (CCP's cache on the single-id body); a corporation cascade runs at most once per 3600 s; 4xx answers spent on bisection are counted for the whole installation over 60 s (`BISECT_ERROR_BUDGET`, 30; "Limitations"); an id ESI refused alone is not asked again for 86400 s (`REJECTED_TTL_SECONDS`); task priorities are 4 for the recent lane, login and main change (between AA's service singles at 3 and the default 5) and 6 for the mains lane and cascades (ahead of `run_model_update` at 7). Bisection is what a chunk does after a 4xx other than 429: ESI refuses the whole body of `POST /characters/affiliation` when it contains an id it does not accept, without naming the id, so the module splits the body in two, asks for each half and repeats until a one-id body isolates the culprit, at most `2 * ceil(log2(n))` extra requests instead of `n` single-id ones.
81
+
82
+ The module's own single-id requests (the login and main-change triggers, `--character` with one id, a chunk or a bisection step of one id, the probe's `[a]` body) send the body `[X]` that `update_character` sends, so CCP answers the next `[X]` from its cache until the `Expires` of that answer (one hour). The module records the moment per character (`single_fetch:<id>`, TTL to `Expires`) and holds back an `update_character` that a multi-id request would queue before it: a change found by the single-id request itself is queued at once, since `update_character` gets the same answer. The command prints the hold as `deferred until=`, the same as the cooldown on `last_updated_affiliations`.
83
+
84
+ An id ESI refuses with a 4xx when asked alone (the bisection isolated it) is marked `rejected:<id>` for 86400 s (`REJECTED_TTL_SECONDS`), with one `warning` line per id, `affiliation id marked rejected character_id=<id> status=<status> ttl=86400`, only when another request of the same bisection succeeded: with no success at all the 4xx is systemic (a route change, a 403 for the installation) and nothing is marked. Until the mark expires the lanes and the command leave the id out of their requests and count it under `rejected=`; `--character ID` names the id and sends the request regardless. To lift a mark by hand: `cache.delete("aa_affiliation_refresh:rejected:<id>")` in `python manage.py shell` (`from django.core.cache import cache`), or wait for the TTL.
85
+
86
+ ## Why 65 minutes, not hourly
87
+
88
+ CCP caches `POST /characters/affiliation` for one hour per request body. The mains lane sends the same body on every run, so two runs closer than an hour hand the second one the cached answer: a run for nothing, and a change lands two hours late. The 65 minutes are between two queueings; between two starts it is `65 + d2 - d1`, where `d1` and `d2` are the queue delays of the two runs. When the first run waited five minutes longer than the second (possible right after `run_model_update`, which starts with a jitter of up to 10 minutes) the second still hits the cache; the cost is one idle run. The recent lane is not affected: every new link changes its body, and the 120-minute window is sized for the one-hour cache. This rests on the cache being keyed by body; "Cache probe" is how to check it.
89
+
90
+ ## Command
91
+
92
+ ```text
93
+ python manage.py affiliation_refresh --user LOGIN [--dry-run]
94
+ python manage.py affiliation_refresh --character ID ... [--dry-run]
95
+ python manage.py affiliation_refresh --recent [--dry-run]
96
+ python manage.py affiliation_refresh --mains [--dry-run]
97
+ python manage.py affiliation_refresh --status
98
+ python manage.py affiliation_refresh --probe-cache [--character ID ID]
99
+ ```
100
+
101
+ - `--user LOGIN`: every character linked to the user; no such user or no linked characters is an error.
102
+ - `--character ID ...`: the listed ids; ids absent from the database are printed as `unknown in db: 123, 456` and dropped, no known id is an error.
103
+ - `--recent`: what the recent lane would select now, printed as `selected 3 (window=120m): 123 (Name) created=2026-09-19T12:40:00Z, ...`; `selected 0 (window=120m)` is a normal outcome.
104
+ - `--mains`: every active user's main; `selected 0` is a normal outcome.
105
+ - `--status`: schedule entry and last-run markers per lane.
106
+ - `--probe-cache`: live probe of CCP's cache rules, about 70 s ("Cache probe").
107
+
108
+ Exactly one mode per call. `--dry-run` applies to the first four modes; `--status` and `--probe-cache` ignore it, and the probe always sends its five requests. The first four fetch ESI synchronously in chunks of `CHUNK_SIZE` (`AA_AFFILIATION_REFRESH_CHUNK_SIZE`), print `esi date=... expires=... last_modified=...` per chunk with at least one 200 answer (until `expires` the same body returns the same answer; a chunk whose ids are all marked `rejected` sends no request and a chunk whose ids ESI refused one by one during bisection got only 4xx: neither prints an `esi` line), `rejected=N: 123, 456` under it when ESI refused ids with a 4xx or they carry a `rejected` mark ("Settings"; those ids are neither diffed nor queued), one line per changed character with the resulting state under it, then the totals. Without `--dry-run` the changed, non-deferred, not-yet-queued characters are queued with priority 4 and `queued=M already_queued=K` is printed, `K` counting the characters whose `queued:<id>` key from an earlier queueing is still set; the command never writes the database and never cascades.
109
+
110
+ Exit code 0 in every normal outcome including an empty selection; 2 with argparse's usage on stderr for an unknown option or a non-integer id; 1 with the reason on stderr for a wrong mode combination (none or more than one), an unknown user, an empty `--user` / `--character` selection, `--probe-cache` without `--character` when the database holds fewer than two mains (`probe needs two character ids: pass --character A B`), an ESI failure (`esi request failed: status=502 cause=Bad Gateway` for a 4xx or 5xx, `esi request failed: RequestError: ConnectError(...)` for a transport failure), in which case nothing was queued for the failed chunk and the chunks before it are already queued, and the installation being under ESI's error limit (`esi error limited reset=<seconds>`, `-` when ESI sent no reset; django-esi keeps the block until then).
111
+
112
+ `--user pilot --dry-run`:
113
+
114
+ ```text
115
+ esi date=Fri, 19 Sep 2026 12:00:00 GMT expires=Fri, 19 Sep 2026 13:00:00 GMT last_modified=Fri, 19 Sep 2026 11:59:30 GMT
116
+ character 90000001 (Pilot One): corporation 98000001 -> 98000002, alliance - -> 99000003
117
+ state: Guest (unchanged)
118
+ checked=1 changed=1 deferred=0
119
+ ```
120
+
121
+ `-` stands for no alliance or faction. A character whose last single-id answer is younger than an hour (AA's `last_updated_affiliations` or the module's own `single_fetch` mark, "Settings") ends with `deferred until=2026-09-19T13:05:00Z` and is not queued. The state line is `state: Guest -> Member`, `state: Guest (unchanged)` for a main and `state: n/a (alt)` for a character that is no user's main (an alt or an unlinked character). Without `--dry-run` the last line is followed by `queued=1 already_queued=0`. A one-id `--character ID` sends the body `[ID]` even with `--dry-run` and sets the `single_fetch` mark.
122
+
123
+ `--status`:
124
+
125
+ ```text
126
+ recent: schedule=<crontab: */10 * * * * (m/h/dM/MY/d)>
127
+ recent: last_run=2026-09-19T12:05:00Z age=7m selected=12 chunks=1
128
+ recent: last_chunk=2026-09-19T12:05:00Z age=7m checked=12 changed=1 deferred=0 queued=1
129
+ mains: schedule=datetime.timedelta(seconds=3900)
130
+ mains: last_run=never
131
+ mains: last_chunk=never
132
+ ```
133
+
134
+ `schedule=missing` is the entry the check would report. `last_run` is written by the lane's selection task after it queued the chunks, `last_chunk` by each chunk when it finishes, so it shows the last chunk only: a failure in an earlier chunk of the same run is invisible here and visible only in the log. `never` means no marker since the cache was last emptied (`cache.delete_pattern`, `cache.clear()`, a Redis restart without persistence) or a marker the command could not read (a `warning` line `marker unreadable` printed with the output: the marker fields changed with a module upgrade; the next run rewrites it). Both markers come from tasks in the `celery` queue, so a worker that does not consume it ages both. Reading: `last_run=never` or older than two intervals - the selection task does not run: the schedule, the beat log, or the worker does not consume the `celery` queue; `last_run` fresh but `last_chunk` old or `never` while `selected > 0` - the chunks fail before writing the marker (a task traceback in the worker output) or wait behind higher-priority tasks (mains chunks run at 6, behind the default 5); `last_chunk` fresh with `checked=0` while `selected > 0` - the ESI request failed (`affiliation request failed` in `log/extensions.log`) or every id of the chunk carries a `rejected` mark (a `warning` `affiliation id marked rejected` per id on the tick that marked it); `selected=0` with complaints - "Limitations", the selection rules. A negative `age=` means the worker's clock is ahead of the clock of the host running the command.
135
+
136
+ Log files in the AA template: the module logs to `log/extensions.log` (logger `extensions.aa_affiliation_refresh.*`), AA to `log/allianceauth.log`, both at `INFO` and above (the module's `debug` lines reach only the console handler); a task traceback goes to the worker's own output (the supervisor log or `docker logs`), not to either file.
137
+
138
+ The admin action "Update from ESI (Celery)" on `EveCharacter` is the UI alternative for one character.
139
+
140
+ ## Runbook: a user is stuck in Guest
141
+
142
+ Run as the user that owns the AA installation, with ESI reachable.
143
+
144
+ 1. In the AA admin: the user has `is_active` and a main character in the profile. Without a main AA gives Guest whatever the affiliations are; stop here.
145
+ 2. `python manage.py affiliation_refresh --status`: `last_run` of the `recent` lane older than two intervals of the printed `schedule=`, or `never` - the selection task does not run: check the schedule, the beat log and whether the worker consumes the `celery` queue; `last_run` fresh but `last_chunk` old while `selected > 0` - the chunks fail, search the worker output for a traceback of `aa_affiliation_refresh.refresh_affiliations`. In both cases stop here.
146
+ 3. `python manage.py affiliation_refresh --recent --dry-run`: the newcomer is not in `selected` - outside the window or without an `OwnershipRecord` ("Limitations"); go to step 4.
147
+ 4. `python manage.py affiliation_refresh --user LOGIN --dry-run`.
148
+ 5. No difference printed - check the character's `last_updated_affiliations` and the states in the admin (`member_corporations` / `member_alliances`, priorities); when ESI still returns the old corporation, repeat after the `expires` printed by the command.
149
+ 6. A difference with `deferred until=` - wait until `until` (CCP's cache on this character); the lane queues the update itself.
150
+ 7. `state: Guest (unchanged)` - the corporation is in no state; fix the states in the admin, the module is not involved.
151
+ 8. `state: Guest -> Member` - repeat without `--dry-run`; the last line is `queued=1 already_queued=0`. The `update_character` task runs at priority 4, in a minute on an idle worker; wait for `Updating <user> state to Member` in `log/allianceauth.log` (written by AA when it saves the character). The module writes nothing at this step: its `affiliation changed` line comes from the lanes, not from the command.
152
+ 9. `queued=1` but no `Updating <user> state to` within five minutes - `update_character` failed or the worker does not consume the `celery` queue: search the worker output for a traceback of `allianceauth.eveonline.tasks.update_character`; the usual cause is `HTTPNotModified` ("Known AA and django-esi defects"): run the admin action "Update from ESI (Celery)" on any other character, then repeat step 8. After a failed `update_character` the key `queued:<id>` stays for up to 3600 s (`UPDATE_COOLDOWN_SECONDS`, not a setting) and a repeat of step 8 prints `queued=0 already_queued=1`: queue again after the key expires, or first run `cache.delete("aa_affiliation_refresh:queued:<id>")` in `python manage.py shell` (`from django.core.cache import cache`). No traceback and no `Updating` line - the worker does not consume the queue; the beat and worker logs are the next place.
153
+
154
+ ## Limitations
155
+
156
+ - Re-linking a character that the same user already linked before creates no `OwnershipRecord` (AA skips the duplicate by `owner_hash` and character): the recent lane never sees it; it is checked by the mains lane if it is a main, otherwise by `run_model_update`.
157
+ - `OwnershipRecord.created` is `auto_now`, not `auto_now_add`: saving a record in the AA admin moves its date and pulls the character into the recent lane for one more check. AA itself never re-saves records.
158
+ - Characters in Doomheim (`corporation_id` 1000001) are excluded from both lanes, as in AA, and by the login and main-change triggers. A character ESI leaves out of its answer is skipped without a log line; `run_model_update` picks it up.
159
+ - A user with `is_active=False` (email not confirmed) is refreshed by the recent lane and gets a state by corporation; `State.objects.get_for_user` in AA ignores `is_active`, `run_model_update` behaves the same.
160
+ - The ESI daily downtime (11:00 UTC) costs one `warning` line `affiliation request failed characters=<n> error=<class> status=<status> cause=<text>` per chunk of a run that falls into it (the mains lane runs `ceil(mains / CHUNK_SIZE)` chunks); `status=` is the HTTP status of a 4xx or 5xx and `-` otherwise, `cause=` ESI's error text for those and the transport exception for the rest; the next tick retries, nothing to do.
161
+ - ESI error budget (100 errors per minute per installation, shared with every AA task and plugin): the module produces at most one 4xx per chunk per tick, and with bisection about 18 extra requests, 10 of them 4xx, per rejected id. The 4xx answers spent on bisection are counted in the cache for the whole installation over 60 s (`BISECT_ERROR_BUDGET`, 30: three rejected ids per minute); the chunk whose 4xx exceeds the budget fails as a whole with `affiliation request failed`. The rejected ids isolated in that call, the one whose 4xx exceeded the budget included, are marked (`rejected:<id>`, "Settings") and the next tick isolates the next three or four (four when the budget runs out on a single-id body), so a chunk with any number of rejected ids completes in at most `ceil(n / 3)` ticks; a systemic 4xx (a route or version change, a 403 for the installation) marks nothing and the chunk is retried whole, at most 31 4xx per minute plus one per further chunk, never `2n - 1` per chunk. The module's window starts at the first 4xx and is not aligned with ESI's minute (`X-ESI-Error-Limit-Reset`), so two module windows can fall into one ESI minute and the bound against ESI is doubled, about `2 x (31 + chunks - 1)`; at the lanes' cadence it stays below 100. Past 100 errors ESI answers 420 and django-esi blocks every ESI request of the installation until the window resets; a 420 is caught by django-esi before the request and retried by `rate_limit_retry_task`.
162
+ - The main-change receiver on `UserProfile` fires on every full `save()` without `update_fields`, which in AA 5.3.1 is a change of language, theme or night mode, the registration, the re-attach of a returning character in the SSO backend, the sidebar toggle (`minimize_sidebar`) and a save in the admin: each costs one ESI request for the main, at most once per `LOGIN_DEBOUNCE_SECONDS` per character.
163
+ - The module needs cache errors to raise. With `DJANGO_REDIS_IGNORE_EXCEPTIONS = True` (not set by the AA template) django-redis returns `None` instead: a claim of a `queued`, `cascade`, `login` or `bisect_errors` key then raises `CacheUnavailableError` with the text `cache returned None (DJANGO_REDIS_IGNORE_EXCEPTIONS?) key=<key>` (the task fails with a traceback, the login trigger logs one `error` line `main character refresh failed ... error=CacheUnavailableError cause=cache returned None (...)` without a traceback and the login goes through), while the lane markers are written without a check and `--status` prints `never` for lanes that run.
164
+ - `update_character` that fails inside AA after the ESI request went through (`get_or_create_esi` of the corporation or alliance) leaves the ETag stored: the next queueing an hour later gets a 304, `HTTPNotModified`, and fails again until AA's `update_character` for another character overwrites the shared ETag key; the module's own requests store no ETag, so its lanes never clear it, and without an operator the clearing waits for the next `run_model_update` (six hours at most). The module does not fix this; runbook step 9 names the action.
165
+ - `python manage.py check` reads `settings.CELERYBEAT_SCHEDULE`, not the `django_celery_beat` table beat fills from it (Undo in "Install"): an entry disabled by hand in the admin is not seen. `--status` shows it as a stale `last_run`.
166
+ - Emptying the cache (`cache.clear()`, Redis without persistence) resets the markers to `never` and drops the `queued`, `cascade`, `single_fetch` and debounce keys: one duplicate `update_character` or cascade may follow; a duplicate `update_character` fails with `HTTPNotModified` (a traceback in the worker log) and changes nothing. When the broker refuses a queueing, the `login`, `queued` or `cascade` key claimed for it is released and the next tick retries.
167
+
168
+ ## Cache probe
169
+
170
+ ```sh
171
+ python manage.py affiliation_refresh --probe-cache
172
+ ```
173
+
174
+ Live ESI, about 70 s: the bodies `[a, b]`, `[b, a]`, `[a]`, `[a, b]` with a 60 s pause after the first, `a` and `b` being the two lowest main character ids (`--character A B` overrides), then the body `[2147483000]`, an id inside int32 that is not allocated. Compares `Expires`, `Last-Modified` and `ETag`, never `Date`: a CDN may stamp the current time on a cached answer. Output: one line per body, `ab [90000001, 90000002] expires=... last_modified=... etag=...`, then `cache=<verdict> bad_id=<outcome>`.
175
+
176
+ | `cache=` | Meaning | Schedule consequence |
177
+ |---|---|---|
178
+ | `by-body` | the same body gets the same headers, a different one does not | 65 minutes stands |
179
+ | `by-set` | `[a, b]` and `[b, a]` share headers, `[a]` does not (ESI normalises order) | 65 minutes stands |
180
+ | `endpoint-wide` | every body shares one set of headers | replace `timedelta(minutes=65)` with `crontab(minute="5")`: both lanes see one hourly snapshot, the interval buys nothing |
181
+ | `no-cache` | the same body twice got different headers | 65 minutes stands; the deferral and the `queued` key stay, they cost nothing |
182
+
183
+ `bad_id=rejected`: a 4xx for the whole body, the bisection path is what runs in production. `bad_id=skipped`: ESI answers without the id, the 4xx path is untested on live ESI and bisection stays as insurance. `bad_id=returned`: the id `2147483000` is allocated and the outcome says nothing about the 4xx path; raise `PROBE_MISSING_CHARACTER_ID` in `probe.py`, there is no command-line switch for it. `bad_id=error:<class>` (`error:HTTPServerError`, `error:ReadTimeout`, `error:HTTPClientError` for a 429): the fifth request failed with a 5xx, a 429 (ESI's bucket limit, not a verdict on the id) or a transport error after the four bodies were answered; `cache=` stands, the 4xx path is untested, rerun for it.
184
+
185
+ The probe has not been run against a production installation yet; until it is, the 65-minute interval is an assumption from the spec, and the command's verdict is the way to confirm it - record it here as `Result (date): cache=<verdict> bad_id=<verdict>`. Re-run before changing the mains interval and after any CCP announcement about caching on this endpoint; the table above says what to change.
186
+
187
+ ## Known AA and django-esi defects
188
+
189
+ - `EveCharacter.update_character()` does not catch `HTTPNotModified`: a repeat of the same single-id body without another affiliation request in between gets a 304 and a traceback. Triggers: the admin action "Update from ESI (Browser)" twice in a row, two `update_character(X)` tasks in a row, a retry after a failure in `get_or_create_esi`. The module avoids it with the one-hour deferral and the `queued` key; the fix is an upstream MR.
190
+ - django-esi 9.10.0 keys its response cache and its ETag without the request body (`_extract_body_param()` runs before `_cache_key()` in `esi/openapi_clients.py`): all `PostCharactersAffiliation` calls share one key. AA works only because every AA call passes `store_cache=False`; a third-party app calling `result()` with the default `store_cache=True` hands `update_character` another body's answer, and it takes `affiliation[0]` without checking `character_id`. The module passes `use_cache=False` and matches by `character_id`; reports go to django-esi (key with body) and AA (`character_id` check).
191
+
192
+ ## CI
193
+
194
+ `.gitlab-ci.yml` runs on merge requests, on pushes to `main` and `develop`, and on tags; a push to a branch with an open merge request starts no second pipeline, a branch without one runs nothing. Every job is one nox session in the `ghcr.io/astral-sh/uv:python3.13-trixie` image and is reproduced locally with `uv run nox -s <session>` (the `tests` job needs `uv python install 3.11` and so on first).
195
+
196
+ | Job | Session | When | Red means |
197
+ |---|---|---|---|
198
+ | `lint` | `lint` | every pipeline | a ruff or rumdl finding |
199
+ | `typecheck` | `typecheck` | every pipeline | a mypy or basedpyright error |
200
+ | `migrations` | `migrations_check` | every pipeline | a model without its migration |
201
+ | `audit` | `audit` | every pipeline | a known vulnerability in `uv.lock`; a runner failure is retried twice, a finding is not |
202
+ | `packaging` | `verify_wheel` | every pipeline | wheel contents differ from `git ls-files` |
203
+ | `tests` (3.11, 3.12, 3.13) | `tests_matrix(python_version='X')` | every pipeline | a test failure on that interpreter, or `uv lock --check`: `uv.lock` behind `pyproject.toml` |
204
+ | `coverage` | `coverage` | every pipeline | below `fail_under` (100), or `uv lock --check`: `uv.lock` behind `pyproject.toml`; `htmlcov/` and `coverage.xml` stay as artifacts for two weeks, also on failure |
205
+ | `lowest_direct` | `tests` after `uv sync --resolution lowest-direct` | every pipeline, allowed to fail | the lower bounds of the declared ranges do not pass the suite |
206
+ | `markdown_links` | `markdown_lint` | scheduled pipeline on `main` or `develop`, allowed to fail | a dead link; README cross links to gitlab.com answer 403 while the repository is private, so the job is red until it is public |
207
+ | `build`, `publish`, `release` | `verify_wheel`, `uv build`, `uv publish`, GitLab release | protected tag `vX.Y.Z` | below |
208
+
209
+ Release: push a protected tag `vX.Y.Z`. `build` strips the `v` and requires `X.Y.Z` to equal `project.version` in `pyproject.toml` and to have a `## [X.Y.Z]` section in `CHANGELOG.md` (that section is the release notes); either mismatch is red before anything is published. `publish` uploads `dist/*.whl` and `dist/*.tar.gz` to PyPI by Trusted Publishing: no token is stored in CI variables, the publisher is configured on PyPI (namespace `eveo7`, project `aa-affiliation-refresh`, pipeline file `.gitlab-ci.yml`, environment `pypi`, compared case-sensitively), and an unprotected tag is refused by the job rule. While the project does not exist on PyPI, the publisher is added as a pending publisher on the account page "Publishing" (`https://pypi.org/manage/account/publishing/`); the first upload creates the project and turns it into an ordinary publisher. `release` creates the GitLab release from the notes with a link to the PyPI page. A red `publish` leaves nothing on PyPI; a version that reached PyPI cannot be uploaded again (PyPI refuses a file name it has seen), so a broken release is followed by the next version, not by a re-tag.
210
+
211
+ Locally, `gitlab-ci-local <job>` (docker required) runs a job in the same image; `gitlab-ci-local --list` shows the jobs of a push pipeline, tag jobs appear with `--variable CI_COMMIT_TAG=v0.1.0 --variable CI_COMMIT_REF_PROTECTED=true`, the scheduled job with `--variable CI_PIPELINE_SOURCE=schedule`. Runner tags are ignored locally; `publish` and `release` cannot run locally (no OIDC token, no GitLab API).
212
+
213
+ Russian: [README.ru.md](https://gitlab.com/eveo7/aa-affiliation-refresh/-/blob/main/README.ru.md).
@@ -0,0 +1,185 @@
1
+ # aa-affiliation-refresh
2
+
3
+ Compares the corporation, alliance and faction of Alliance Auth characters with ESI - every 10 minutes for characters linked within the last two hours, every 65 minutes for every active user's main - and queues AA's own `update_character` for the ones that differ, so a newcomer gets the right state within about 70 minutes instead of the six-hour `run_model_update` cycle. These are the two lanes, `recent` and `mains`: periodic selection sweeps, each with its own schedule, priority and `last_run` / `last_chunk` markers. A user's main is also checked at login and when the main changes (debounced). When a character checked by the recent lane, by the login trigger or by the main-change trigger turns out to have changed alliance or faction while staying in its corporation, the other mains of that corporation are checked once (the cascade); the mains lane does not cascade, it covers every main anyway.
4
+
5
+ It writes nothing itself: `update_character` in AA does the write, the state assignment and the service hooks. It does not change states, groups or permissions, sends no notifications, keeps no history and exposes no metrics; `EveCorporationInfo` / `EveAllianceInfo` stay with `run_model_update`.
6
+
7
+ Requires `allianceauth>=5.3,<6` on Python 3.11-3.13. No models, no migrations: all runtime state lives in the Django cache under the prefix `aa_affiliation_refresh:`.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pip install aa-affiliation-refresh
13
+ ```
14
+
15
+ In `local.py`:
16
+
17
+ ```python
18
+ INSTALLED_APPS += ["aa_affiliation_refresh"]
19
+ ```
20
+
21
+ ```python
22
+ from celery.schedules import crontab
23
+ from datetime import timedelta
24
+
25
+ CELERYBEAT_SCHEDULE["affiliation_refresh_recent_characters"] = {
26
+ "task": "aa_affiliation_refresh.refresh_recent_characters",
27
+ "schedule": crontab(minute="*/10"),
28
+ "options": {"priority": 4},
29
+ }
30
+ CELERYBEAT_SCHEDULE["affiliation_refresh_main_characters"] = {
31
+ "task": "aa_affiliation_refresh.refresh_main_characters",
32
+ "schedule": timedelta(minutes=65),
33
+ }
34
+ ```
35
+
36
+ `options` puts the selection task of the recent lane at the priority of its chunks; without it the task runs at Celery's default 5 and waits behind AA's own tasks while its chunks would go at 4. Restart beat and the workers: beat reads the schedule at start. `python manage.py check` reports `aa_affiliation_refresh.W001` when the `refresh_recent_characters` entry is missing and `aa_affiliation_refresh.I001` when the `refresh_main_characters` entry is missing; the mains lane is optional and is disabled by deleting its entry. The key must be `CELERYBEAT_SCHEDULE`: AA hands that name to Celery, so an entry under `CELERY_BEAT_SCHEDULE` reaches neither beat nor the check.
37
+
38
+ Undo: delete the two entries and the `INSTALLED_APPS` line, then in the AA admin (Periodic tasks) delete or disable the two periodic tasks `affiliation_refresh_recent_characters` and `affiliation_refresh_main_characters`: beat copies `CELERYBEAT_SCHEDULE` into that table at start and only adds or updates rows, never deletes them, so a leftover row keeps sending a task no worker registers (`Received unregistered task of type ...` on every tick). Restart beat and the workers. Those two `PeriodicTask` rows are the only database trace; cache keys expire within an hour (the login debounce key within `LOGIN_DEBOUNCE_SECONDS`, a `rejected` mark within 86400 s) except the two `last_run` / `last_chunk` markers per lane, which have no TTL: `cache.delete_pattern("aa_affiliation_refresh:*")` in `python manage.py shell` (`from django.core.cache import cache`; `delete_pattern` is django-redis, the backend of the AA template) removes them with every other key of the module and nothing else, where `cache.clear()` would empty the cache of the whole installation.
39
+
40
+ ## Settings
41
+
42
+ Read once at import; all optional.
43
+
44
+ | Setting | Default | Range | Meaning |
45
+ |---|---|---|---|
46
+ | `AA_AFFILIATION_REFRESH_RECENT_WINDOW_MINUTES` | `120` | `>= 1` | how long after a character is linked the recent lane keeps checking it; must exceed CCP's one-hour cache plus the lane interval (10 minutes), or the character drops out of the selection before ESI stops serving the stale answer |
47
+ | `AA_AFFILIATION_REFRESH_CHUNK_SIZE` | `500` | `1..1000` | ids per ESI request; ESI accepts up to 1000 on `POST /characters/affiliation`, AA uses 500 |
48
+ | `AA_AFFILIATION_REFRESH_LOGIN_DEBOUNCE_SECONDS` | `600` | `>= 0` | at most one main-character check per login or main change within this many seconds; `0` disables both triggers |
49
+
50
+ A value of the wrong type (a boolean counts as the wrong type for a number) or outside the range is not an error: the worker and the web process log one `warning` from `extensions.aa_affiliation_refresh.utils` at import (`setting invalid name=<name> value=<value> reason=<reason> default=<default>`, `reason` one of `wrong_type`, `below_min`, `above_max`; for `wrong_type` the `value` is the type) and run with the default. `python manage.py check` does not report this; the log at startup is the only trace.
51
+
52
+ Not settings (module constants): `update_character` for one character is queued at most once per 3600 s (CCP's cache on the single-id body); a corporation cascade runs at most once per 3600 s; 4xx answers spent on bisection are counted for the whole installation over 60 s (`BISECT_ERROR_BUDGET`, 30; "Limitations"); an id ESI refused alone is not asked again for 86400 s (`REJECTED_TTL_SECONDS`); task priorities are 4 for the recent lane, login and main change (between AA's service singles at 3 and the default 5) and 6 for the mains lane and cascades (ahead of `run_model_update` at 7). Bisection is what a chunk does after a 4xx other than 429: ESI refuses the whole body of `POST /characters/affiliation` when it contains an id it does not accept, without naming the id, so the module splits the body in two, asks for each half and repeats until a one-id body isolates the culprit, at most `2 * ceil(log2(n))` extra requests instead of `n` single-id ones.
53
+
54
+ The module's own single-id requests (the login and main-change triggers, `--character` with one id, a chunk or a bisection step of one id, the probe's `[a]` body) send the body `[X]` that `update_character` sends, so CCP answers the next `[X]` from its cache until the `Expires` of that answer (one hour). The module records the moment per character (`single_fetch:<id>`, TTL to `Expires`) and holds back an `update_character` that a multi-id request would queue before it: a change found by the single-id request itself is queued at once, since `update_character` gets the same answer. The command prints the hold as `deferred until=`, the same as the cooldown on `last_updated_affiliations`.
55
+
56
+ An id ESI refuses with a 4xx when asked alone (the bisection isolated it) is marked `rejected:<id>` for 86400 s (`REJECTED_TTL_SECONDS`), with one `warning` line per id, `affiliation id marked rejected character_id=<id> status=<status> ttl=86400`, only when another request of the same bisection succeeded: with no success at all the 4xx is systemic (a route change, a 403 for the installation) and nothing is marked. Until the mark expires the lanes and the command leave the id out of their requests and count it under `rejected=`; `--character ID` names the id and sends the request regardless. To lift a mark by hand: `cache.delete("aa_affiliation_refresh:rejected:<id>")` in `python manage.py shell` (`from django.core.cache import cache`), or wait for the TTL.
57
+
58
+ ## Why 65 minutes, not hourly
59
+
60
+ CCP caches `POST /characters/affiliation` for one hour per request body. The mains lane sends the same body on every run, so two runs closer than an hour hand the second one the cached answer: a run for nothing, and a change lands two hours late. The 65 minutes are between two queueings; between two starts it is `65 + d2 - d1`, where `d1` and `d2` are the queue delays of the two runs. When the first run waited five minutes longer than the second (possible right after `run_model_update`, which starts with a jitter of up to 10 minutes) the second still hits the cache; the cost is one idle run. The recent lane is not affected: every new link changes its body, and the 120-minute window is sized for the one-hour cache. This rests on the cache being keyed by body; "Cache probe" is how to check it.
61
+
62
+ ## Command
63
+
64
+ ```text
65
+ python manage.py affiliation_refresh --user LOGIN [--dry-run]
66
+ python manage.py affiliation_refresh --character ID ... [--dry-run]
67
+ python manage.py affiliation_refresh --recent [--dry-run]
68
+ python manage.py affiliation_refresh --mains [--dry-run]
69
+ python manage.py affiliation_refresh --status
70
+ python manage.py affiliation_refresh --probe-cache [--character ID ID]
71
+ ```
72
+
73
+ - `--user LOGIN`: every character linked to the user; no such user or no linked characters is an error.
74
+ - `--character ID ...`: the listed ids; ids absent from the database are printed as `unknown in db: 123, 456` and dropped, no known id is an error.
75
+ - `--recent`: what the recent lane would select now, printed as `selected 3 (window=120m): 123 (Name) created=2026-09-19T12:40:00Z, ...`; `selected 0 (window=120m)` is a normal outcome.
76
+ - `--mains`: every active user's main; `selected 0` is a normal outcome.
77
+ - `--status`: schedule entry and last-run markers per lane.
78
+ - `--probe-cache`: live probe of CCP's cache rules, about 70 s ("Cache probe").
79
+
80
+ Exactly one mode per call. `--dry-run` applies to the first four modes; `--status` and `--probe-cache` ignore it, and the probe always sends its five requests. The first four fetch ESI synchronously in chunks of `CHUNK_SIZE` (`AA_AFFILIATION_REFRESH_CHUNK_SIZE`), print `esi date=... expires=... last_modified=...` per chunk with at least one 200 answer (until `expires` the same body returns the same answer; a chunk whose ids are all marked `rejected` sends no request and a chunk whose ids ESI refused one by one during bisection got only 4xx: neither prints an `esi` line), `rejected=N: 123, 456` under it when ESI refused ids with a 4xx or they carry a `rejected` mark ("Settings"; those ids are neither diffed nor queued), one line per changed character with the resulting state under it, then the totals. Without `--dry-run` the changed, non-deferred, not-yet-queued characters are queued with priority 4 and `queued=M already_queued=K` is printed, `K` counting the characters whose `queued:<id>` key from an earlier queueing is still set; the command never writes the database and never cascades.
81
+
82
+ Exit code 0 in every normal outcome including an empty selection; 2 with argparse's usage on stderr for an unknown option or a non-integer id; 1 with the reason on stderr for a wrong mode combination (none or more than one), an unknown user, an empty `--user` / `--character` selection, `--probe-cache` without `--character` when the database holds fewer than two mains (`probe needs two character ids: pass --character A B`), an ESI failure (`esi request failed: status=502 cause=Bad Gateway` for a 4xx or 5xx, `esi request failed: RequestError: ConnectError(...)` for a transport failure), in which case nothing was queued for the failed chunk and the chunks before it are already queued, and the installation being under ESI's error limit (`esi error limited reset=<seconds>`, `-` when ESI sent no reset; django-esi keeps the block until then).
83
+
84
+ `--user pilot --dry-run`:
85
+
86
+ ```text
87
+ esi date=Fri, 19 Sep 2026 12:00:00 GMT expires=Fri, 19 Sep 2026 13:00:00 GMT last_modified=Fri, 19 Sep 2026 11:59:30 GMT
88
+ character 90000001 (Pilot One): corporation 98000001 -> 98000002, alliance - -> 99000003
89
+ state: Guest (unchanged)
90
+ checked=1 changed=1 deferred=0
91
+ ```
92
+
93
+ `-` stands for no alliance or faction. A character whose last single-id answer is younger than an hour (AA's `last_updated_affiliations` or the module's own `single_fetch` mark, "Settings") ends with `deferred until=2026-09-19T13:05:00Z` and is not queued. The state line is `state: Guest -> Member`, `state: Guest (unchanged)` for a main and `state: n/a (alt)` for a character that is no user's main (an alt or an unlinked character). Without `--dry-run` the last line is followed by `queued=1 already_queued=0`. A one-id `--character ID` sends the body `[ID]` even with `--dry-run` and sets the `single_fetch` mark.
94
+
95
+ `--status`:
96
+
97
+ ```text
98
+ recent: schedule=<crontab: */10 * * * * (m/h/dM/MY/d)>
99
+ recent: last_run=2026-09-19T12:05:00Z age=7m selected=12 chunks=1
100
+ recent: last_chunk=2026-09-19T12:05:00Z age=7m checked=12 changed=1 deferred=0 queued=1
101
+ mains: schedule=datetime.timedelta(seconds=3900)
102
+ mains: last_run=never
103
+ mains: last_chunk=never
104
+ ```
105
+
106
+ `schedule=missing` is the entry the check would report. `last_run` is written by the lane's selection task after it queued the chunks, `last_chunk` by each chunk when it finishes, so it shows the last chunk only: a failure in an earlier chunk of the same run is invisible here and visible only in the log. `never` means no marker since the cache was last emptied (`cache.delete_pattern`, `cache.clear()`, a Redis restart without persistence) or a marker the command could not read (a `warning` line `marker unreadable` printed with the output: the marker fields changed with a module upgrade; the next run rewrites it). Both markers come from tasks in the `celery` queue, so a worker that does not consume it ages both. Reading: `last_run=never` or older than two intervals - the selection task does not run: the schedule, the beat log, or the worker does not consume the `celery` queue; `last_run` fresh but `last_chunk` old or `never` while `selected > 0` - the chunks fail before writing the marker (a task traceback in the worker output) or wait behind higher-priority tasks (mains chunks run at 6, behind the default 5); `last_chunk` fresh with `checked=0` while `selected > 0` - the ESI request failed (`affiliation request failed` in `log/extensions.log`) or every id of the chunk carries a `rejected` mark (a `warning` `affiliation id marked rejected` per id on the tick that marked it); `selected=0` with complaints - "Limitations", the selection rules. A negative `age=` means the worker's clock is ahead of the clock of the host running the command.
107
+
108
+ Log files in the AA template: the module logs to `log/extensions.log` (logger `extensions.aa_affiliation_refresh.*`), AA to `log/allianceauth.log`, both at `INFO` and above (the module's `debug` lines reach only the console handler); a task traceback goes to the worker's own output (the supervisor log or `docker logs`), not to either file.
109
+
110
+ The admin action "Update from ESI (Celery)" on `EveCharacter` is the UI alternative for one character.
111
+
112
+ ## Runbook: a user is stuck in Guest
113
+
114
+ Run as the user that owns the AA installation, with ESI reachable.
115
+
116
+ 1. In the AA admin: the user has `is_active` and a main character in the profile. Without a main AA gives Guest whatever the affiliations are; stop here.
117
+ 2. `python manage.py affiliation_refresh --status`: `last_run` of the `recent` lane older than two intervals of the printed `schedule=`, or `never` - the selection task does not run: check the schedule, the beat log and whether the worker consumes the `celery` queue; `last_run` fresh but `last_chunk` old while `selected > 0` - the chunks fail, search the worker output for a traceback of `aa_affiliation_refresh.refresh_affiliations`. In both cases stop here.
118
+ 3. `python manage.py affiliation_refresh --recent --dry-run`: the newcomer is not in `selected` - outside the window or without an `OwnershipRecord` ("Limitations"); go to step 4.
119
+ 4. `python manage.py affiliation_refresh --user LOGIN --dry-run`.
120
+ 5. No difference printed - check the character's `last_updated_affiliations` and the states in the admin (`member_corporations` / `member_alliances`, priorities); when ESI still returns the old corporation, repeat after the `expires` printed by the command.
121
+ 6. A difference with `deferred until=` - wait until `until` (CCP's cache on this character); the lane queues the update itself.
122
+ 7. `state: Guest (unchanged)` - the corporation is in no state; fix the states in the admin, the module is not involved.
123
+ 8. `state: Guest -> Member` - repeat without `--dry-run`; the last line is `queued=1 already_queued=0`. The `update_character` task runs at priority 4, in a minute on an idle worker; wait for `Updating <user> state to Member` in `log/allianceauth.log` (written by AA when it saves the character). The module writes nothing at this step: its `affiliation changed` line comes from the lanes, not from the command.
124
+ 9. `queued=1` but no `Updating <user> state to` within five minutes - `update_character` failed or the worker does not consume the `celery` queue: search the worker output for a traceback of `allianceauth.eveonline.tasks.update_character`; the usual cause is `HTTPNotModified` ("Known AA and django-esi defects"): run the admin action "Update from ESI (Celery)" on any other character, then repeat step 8. After a failed `update_character` the key `queued:<id>` stays for up to 3600 s (`UPDATE_COOLDOWN_SECONDS`, not a setting) and a repeat of step 8 prints `queued=0 already_queued=1`: queue again after the key expires, or first run `cache.delete("aa_affiliation_refresh:queued:<id>")` in `python manage.py shell` (`from django.core.cache import cache`). No traceback and no `Updating` line - the worker does not consume the queue; the beat and worker logs are the next place.
125
+
126
+ ## Limitations
127
+
128
+ - Re-linking a character that the same user already linked before creates no `OwnershipRecord` (AA skips the duplicate by `owner_hash` and character): the recent lane never sees it; it is checked by the mains lane if it is a main, otherwise by `run_model_update`.
129
+ - `OwnershipRecord.created` is `auto_now`, not `auto_now_add`: saving a record in the AA admin moves its date and pulls the character into the recent lane for one more check. AA itself never re-saves records.
130
+ - Characters in Doomheim (`corporation_id` 1000001) are excluded from both lanes, as in AA, and by the login and main-change triggers. A character ESI leaves out of its answer is skipped without a log line; `run_model_update` picks it up.
131
+ - A user with `is_active=False` (email not confirmed) is refreshed by the recent lane and gets a state by corporation; `State.objects.get_for_user` in AA ignores `is_active`, `run_model_update` behaves the same.
132
+ - The ESI daily downtime (11:00 UTC) costs one `warning` line `affiliation request failed characters=<n> error=<class> status=<status> cause=<text>` per chunk of a run that falls into it (the mains lane runs `ceil(mains / CHUNK_SIZE)` chunks); `status=` is the HTTP status of a 4xx or 5xx and `-` otherwise, `cause=` ESI's error text for those and the transport exception for the rest; the next tick retries, nothing to do.
133
+ - ESI error budget (100 errors per minute per installation, shared with every AA task and plugin): the module produces at most one 4xx per chunk per tick, and with bisection about 18 extra requests, 10 of them 4xx, per rejected id. The 4xx answers spent on bisection are counted in the cache for the whole installation over 60 s (`BISECT_ERROR_BUDGET`, 30: three rejected ids per minute); the chunk whose 4xx exceeds the budget fails as a whole with `affiliation request failed`. The rejected ids isolated in that call, the one whose 4xx exceeded the budget included, are marked (`rejected:<id>`, "Settings") and the next tick isolates the next three or four (four when the budget runs out on a single-id body), so a chunk with any number of rejected ids completes in at most `ceil(n / 3)` ticks; a systemic 4xx (a route or version change, a 403 for the installation) marks nothing and the chunk is retried whole, at most 31 4xx per minute plus one per further chunk, never `2n - 1` per chunk. The module's window starts at the first 4xx and is not aligned with ESI's minute (`X-ESI-Error-Limit-Reset`), so two module windows can fall into one ESI minute and the bound against ESI is doubled, about `2 x (31 + chunks - 1)`; at the lanes' cadence it stays below 100. Past 100 errors ESI answers 420 and django-esi blocks every ESI request of the installation until the window resets; a 420 is caught by django-esi before the request and retried by `rate_limit_retry_task`.
134
+ - The main-change receiver on `UserProfile` fires on every full `save()` without `update_fields`, which in AA 5.3.1 is a change of language, theme or night mode, the registration, the re-attach of a returning character in the SSO backend, the sidebar toggle (`minimize_sidebar`) and a save in the admin: each costs one ESI request for the main, at most once per `LOGIN_DEBOUNCE_SECONDS` per character.
135
+ - The module needs cache errors to raise. With `DJANGO_REDIS_IGNORE_EXCEPTIONS = True` (not set by the AA template) django-redis returns `None` instead: a claim of a `queued`, `cascade`, `login` or `bisect_errors` key then raises `CacheUnavailableError` with the text `cache returned None (DJANGO_REDIS_IGNORE_EXCEPTIONS?) key=<key>` (the task fails with a traceback, the login trigger logs one `error` line `main character refresh failed ... error=CacheUnavailableError cause=cache returned None (...)` without a traceback and the login goes through), while the lane markers are written without a check and `--status` prints `never` for lanes that run.
136
+ - `update_character` that fails inside AA after the ESI request went through (`get_or_create_esi` of the corporation or alliance) leaves the ETag stored: the next queueing an hour later gets a 304, `HTTPNotModified`, and fails again until AA's `update_character` for another character overwrites the shared ETag key; the module's own requests store no ETag, so its lanes never clear it, and without an operator the clearing waits for the next `run_model_update` (six hours at most). The module does not fix this; runbook step 9 names the action.
137
+ - `python manage.py check` reads `settings.CELERYBEAT_SCHEDULE`, not the `django_celery_beat` table beat fills from it (Undo in "Install"): an entry disabled by hand in the admin is not seen. `--status` shows it as a stale `last_run`.
138
+ - Emptying the cache (`cache.clear()`, Redis without persistence) resets the markers to `never` and drops the `queued`, `cascade`, `single_fetch` and debounce keys: one duplicate `update_character` or cascade may follow; a duplicate `update_character` fails with `HTTPNotModified` (a traceback in the worker log) and changes nothing. When the broker refuses a queueing, the `login`, `queued` or `cascade` key claimed for it is released and the next tick retries.
139
+
140
+ ## Cache probe
141
+
142
+ ```sh
143
+ python manage.py affiliation_refresh --probe-cache
144
+ ```
145
+
146
+ Live ESI, about 70 s: the bodies `[a, b]`, `[b, a]`, `[a]`, `[a, b]` with a 60 s pause after the first, `a` and `b` being the two lowest main character ids (`--character A B` overrides), then the body `[2147483000]`, an id inside int32 that is not allocated. Compares `Expires`, `Last-Modified` and `ETag`, never `Date`: a CDN may stamp the current time on a cached answer. Output: one line per body, `ab [90000001, 90000002] expires=... last_modified=... etag=...`, then `cache=<verdict> bad_id=<outcome>`.
147
+
148
+ | `cache=` | Meaning | Schedule consequence |
149
+ |---|---|---|
150
+ | `by-body` | the same body gets the same headers, a different one does not | 65 minutes stands |
151
+ | `by-set` | `[a, b]` and `[b, a]` share headers, `[a]` does not (ESI normalises order) | 65 minutes stands |
152
+ | `endpoint-wide` | every body shares one set of headers | replace `timedelta(minutes=65)` with `crontab(minute="5")`: both lanes see one hourly snapshot, the interval buys nothing |
153
+ | `no-cache` | the same body twice got different headers | 65 minutes stands; the deferral and the `queued` key stay, they cost nothing |
154
+
155
+ `bad_id=rejected`: a 4xx for the whole body, the bisection path is what runs in production. `bad_id=skipped`: ESI answers without the id, the 4xx path is untested on live ESI and bisection stays as insurance. `bad_id=returned`: the id `2147483000` is allocated and the outcome says nothing about the 4xx path; raise `PROBE_MISSING_CHARACTER_ID` in `probe.py`, there is no command-line switch for it. `bad_id=error:<class>` (`error:HTTPServerError`, `error:ReadTimeout`, `error:HTTPClientError` for a 429): the fifth request failed with a 5xx, a 429 (ESI's bucket limit, not a verdict on the id) or a transport error after the four bodies were answered; `cache=` stands, the 4xx path is untested, rerun for it.
156
+
157
+ The probe has not been run against a production installation yet; until it is, the 65-minute interval is an assumption from the spec, and the command's verdict is the way to confirm it - record it here as `Result (date): cache=<verdict> bad_id=<verdict>`. Re-run before changing the mains interval and after any CCP announcement about caching on this endpoint; the table above says what to change.
158
+
159
+ ## Known AA and django-esi defects
160
+
161
+ - `EveCharacter.update_character()` does not catch `HTTPNotModified`: a repeat of the same single-id body without another affiliation request in between gets a 304 and a traceback. Triggers: the admin action "Update from ESI (Browser)" twice in a row, two `update_character(X)` tasks in a row, a retry after a failure in `get_or_create_esi`. The module avoids it with the one-hour deferral and the `queued` key; the fix is an upstream MR.
162
+ - django-esi 9.10.0 keys its response cache and its ETag without the request body (`_extract_body_param()` runs before `_cache_key()` in `esi/openapi_clients.py`): all `PostCharactersAffiliation` calls share one key. AA works only because every AA call passes `store_cache=False`; a third-party app calling `result()` with the default `store_cache=True` hands `update_character` another body's answer, and it takes `affiliation[0]` without checking `character_id`. The module passes `use_cache=False` and matches by `character_id`; reports go to django-esi (key with body) and AA (`character_id` check).
163
+
164
+ ## CI
165
+
166
+ `.gitlab-ci.yml` runs on merge requests, on pushes to `main` and `develop`, and on tags; a push to a branch with an open merge request starts no second pipeline, a branch without one runs nothing. Every job is one nox session in the `ghcr.io/astral-sh/uv:python3.13-trixie` image and is reproduced locally with `uv run nox -s <session>` (the `tests` job needs `uv python install 3.11` and so on first).
167
+
168
+ | Job | Session | When | Red means |
169
+ |---|---|---|---|
170
+ | `lint` | `lint` | every pipeline | a ruff or rumdl finding |
171
+ | `typecheck` | `typecheck` | every pipeline | a mypy or basedpyright error |
172
+ | `migrations` | `migrations_check` | every pipeline | a model without its migration |
173
+ | `audit` | `audit` | every pipeline | a known vulnerability in `uv.lock`; a runner failure is retried twice, a finding is not |
174
+ | `packaging` | `verify_wheel` | every pipeline | wheel contents differ from `git ls-files` |
175
+ | `tests` (3.11, 3.12, 3.13) | `tests_matrix(python_version='X')` | every pipeline | a test failure on that interpreter, or `uv lock --check`: `uv.lock` behind `pyproject.toml` |
176
+ | `coverage` | `coverage` | every pipeline | below `fail_under` (100), or `uv lock --check`: `uv.lock` behind `pyproject.toml`; `htmlcov/` and `coverage.xml` stay as artifacts for two weeks, also on failure |
177
+ | `lowest_direct` | `tests` after `uv sync --resolution lowest-direct` | every pipeline, allowed to fail | the lower bounds of the declared ranges do not pass the suite |
178
+ | `markdown_links` | `markdown_lint` | scheduled pipeline on `main` or `develop`, allowed to fail | a dead link; README cross links to gitlab.com answer 403 while the repository is private, so the job is red until it is public |
179
+ | `build`, `publish`, `release` | `verify_wheel`, `uv build`, `uv publish`, GitLab release | protected tag `vX.Y.Z` | below |
180
+
181
+ Release: push a protected tag `vX.Y.Z`. `build` strips the `v` and requires `X.Y.Z` to equal `project.version` in `pyproject.toml` and to have a `## [X.Y.Z]` section in `CHANGELOG.md` (that section is the release notes); either mismatch is red before anything is published. `publish` uploads `dist/*.whl` and `dist/*.tar.gz` to PyPI by Trusted Publishing: no token is stored in CI variables, the publisher is configured on PyPI (namespace `eveo7`, project `aa-affiliation-refresh`, pipeline file `.gitlab-ci.yml`, environment `pypi`, compared case-sensitively), and an unprotected tag is refused by the job rule. While the project does not exist on PyPI, the publisher is added as a pending publisher on the account page "Publishing" (`https://pypi.org/manage/account/publishing/`); the first upload creates the project and turns it into an ordinary publisher. `release` creates the GitLab release from the notes with a link to the PyPI page. A red `publish` leaves nothing on PyPI; a version that reached PyPI cannot be uploaded again (PyPI refuses a file name it has seen), so a broken release is followed by the next version, not by a re-tag.
182
+
183
+ Locally, `gitlab-ci-local <job>` (docker required) runs a job in the same image; `gitlab-ci-local --list` shows the jobs of a push pipeline, tag jobs appear with `--variable CI_COMMIT_TAG=v0.1.0 --variable CI_COMMIT_REF_PROTECTED=true`, the scheduled job with `--variable CI_PIPELINE_SOURCE=schedule`. Runner tags are ignored locally; `publish` and `release` cannot run locally (no OIDC token, no GitLab API).
184
+
185
+ Russian: [README.ru.md](https://gitlab.com/eveo7/aa-affiliation-refresh/-/blob/main/README.ru.md).
@@ -0,0 +1,8 @@
1
+ """Refresh Alliance Auth character affiliations minutes after they change."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("aa-affiliation-refresh")
7
+ except PackageNotFoundError: # pragma: no cover - source checkout only
8
+ __version__ = "0.0.0"
@@ -0,0 +1,26 @@
1
+ """Logger factory enforcing the ``extensions.`` prefix; log value helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+
8
+ def get_logger(name: str) -> logging.Logger:
9
+ """
10
+ Return a logger under the ``extensions.`` namespace.
11
+
12
+ Alliance Auth routes ``extensions.*`` to the extensions log file;
13
+ a bare ``logging.getLogger(__name__)`` would land in the default
14
+ stream where operators do not look for module output.
15
+ """
16
+ return logging.getLogger(f"extensions.{name}")
17
+
18
+
19
+ def one_line(text: object) -> str:
20
+ """
21
+ Collapse ``text`` to one line for a log field; ``-`` when empty.
22
+
23
+ A multi-line value (a pydantic error, a chained message) would split
24
+ one log record into several lines.
25
+ """
26
+ return " ".join(str(text).split()) or "-"
@@ -0,0 +1,70 @@
1
+ """
2
+ Operator settings (``AA_AFFILIATION_REFRESH_*``) and module constants.
3
+
4
+ Settings are read once at import through ``clean_setting``: a wrong type
5
+ or an out-of-range value logs a warning and keeps the default, never
6
+ raises. Raising here would break the worker's import of ``tasks.py`` on
7
+ a typo and print a traceback where a system-check message belongs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Final
13
+
14
+ from aa_affiliation_refresh.utils import clean_setting
15
+
16
+ # Window for the "recently linked" lane. Must exceed CCP's one-hour
17
+ # cache on POST /characters/affiliation plus the lane interval, or a
18
+ # newcomer drops out of the selection before the cache expires.
19
+ RECENT_WINDOW_MINUTES: Final[int] = clean_setting(
20
+ "AA_AFFILIATION_REFRESH_RECENT_WINDOW_MINUTES", 120, min_value=1
21
+ )
22
+ # Ids per ESI request; the endpoint accepts up to 1000, AA uses 500.
23
+ CHUNK_SIZE: Final[int] = clean_setting(
24
+ "AA_AFFILIATION_REFRESH_CHUNK_SIZE", 500, min_value=1, max_value=1000
25
+ )
26
+ # At most one main-character check per login within this many seconds;
27
+ # 0 disables the login and main-change triggers.
28
+ LOGIN_DEBOUNCE_SECONDS: Final[int] = clean_setting(
29
+ "AA_AFFILIATION_REFRESH_LOGIN_DEBOUNCE_SECONDS", 600, min_value=0
30
+ )
31
+
32
+ # CCP caches POST /characters/affiliation for one hour per request
33
+ # body. update_character(X) sends its own body [X]; queueing it while
34
+ # EveCharacter.last_updated_affiliations (the Date of the last [X]
35
+ # response) is younger than this would rewrite stale data.
36
+ UPDATE_COOLDOWN_SECONDS: Final[int] = 3600
37
+ # A corporation cascades at most once per CCP cache window.
38
+ CASCADE_TTL_SECONDS: Final[int] = 3600
39
+ # 4xx answers bisection may spend per window, shared by every chunk of
40
+ # the installation and counted in the cache (not per call: the mains
41
+ # lane runs 20 chunks of 500 side by side and ESI's limit of 100 errors
42
+ # per minute is per installation). One culprit in a chunk of 500 costs
43
+ # ~2 * ceil(log2(500)) = 18 extra requests, ceil(log2(500)) + 1 = 10 of
44
+ # them 4xx; 30 covers three culprits per minute and leaves 70 to AA's
45
+ # own tasks within the module's window. That window starts at the first
46
+ # 4xx and is not aligned with ESI's minute (X-ESI-Error-Limit-Reset), so
47
+ # two module windows can fall into one ESI minute: the bound against
48
+ # ESI is doubled, about 2 x (31 + chunks - 1); at the lanes' cadence it
49
+ # stays below 100. A chunk the budget does not cover fails as a whole
50
+ # with "affiliation request failed"; the ids isolated before that are
51
+ # marked rejected (below), so the next tick has fewer culprits to find.
52
+ BISECT_ERROR_BUDGET: Final[int] = 30
53
+ BISECT_ERROR_WINDOW_SECONDS: Final[int] = 60
54
+ # An id ESI refuses does not mend itself; AA's run_model_update retries
55
+ # it every six hours anyway, so one retry a day costs nothing extra.
56
+ REJECTED_TTL_SECONDS: Final[int] = 86_400
57
+ # AA: service singles 3, default 5, service bulk 6, run_model_update 7.
58
+ REACTIVE_PRIORITY: Final[int] = 4
59
+ BULK_PRIORITY: Final[int] = 6
60
+
61
+ REFRESH_TASK_NAME: Final[str] = "aa_affiliation_refresh.refresh_affiliations"
62
+ RECENT_TASK_NAME: Final[str] = (
63
+ "aa_affiliation_refresh.refresh_recent_characters"
64
+ )
65
+ MAINS_TASK_NAME: Final[str] = "aa_affiliation_refresh.refresh_main_characters"
66
+ LANE_TASK_NAMES: Final[dict[str, str]] = {
67
+ "recent": RECENT_TASK_NAME,
68
+ "mains": MAINS_TASK_NAME,
69
+ }
70
+ LANES: Final[tuple[str, ...]] = tuple(LANE_TASK_NAMES)
@@ -0,0 +1,25 @@
1
+ """Django application configuration."""
2
+
3
+ from django.apps import AppConfig
4
+
5
+ from aa_affiliation_refresh import __version__
6
+
7
+
8
+ class AffiliationRefreshConfig(AppConfig):
9
+ """Application config for the affiliation refresh module."""
10
+
11
+ name = "aa_affiliation_refresh"
12
+ label = "aa_affiliation_refresh"
13
+ verbose_name = f"Affiliation Refresh v{__version__}"
14
+ default_auto_field = "django.db.models.AutoField"
15
+
16
+ def ready(self) -> None:
17
+ """
18
+ Register signal receivers and system checks.
19
+
20
+ Both modules register at import time (``@receiver`` and
21
+ ``@checks.register``); nothing else imports them, so without
22
+ this import the login trigger and the schedule check are
23
+ silently absent.
24
+ """
25
+ from aa_affiliation_refresh import checks, signals # noqa: F401