# stapel-profiles 0.12.4

User profiles and social graph: display name, avatar, locale/currency/measurement preferences, notification opt-outs, and follow/unfollow/block relationships between users, with a relationship-status lookup.

Contract: axes 1 · surface 11 · extension points 3 · operations 16 · error codes 53.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- PROFILES_AVATAR_CHECK [enum, default "comm"] — Avatar existence verification method
  Controls how the profile service confirms an uploaded avatar reference actually exists on the CDN before saving it: through the internal comm bus (default) or skipped entirely (only the reference format is still validated).

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- is_cdn_avatar_reference — stapel_profiles.models.is_cdn_avatar_reference
  Answer whether an avatar value IS a stapel-cdn reference (`avatar/<64-hex>`) — the single discriminator behind the avatar/avatar_source pair invariant. Reach for it when you have a ref and need to know where it came from; matching the prefix or the slash by hand is how a `product/<hash>` ref or a path-like upload key gets mistaken for a CDN avatar.
- resolve_avatar_source — stapel_profiles.models.resolve_avatar_source
  The avatar_source that MUST accompany a given avatar value — returns the declared source unchanged unless the value is unmistakably a CDN ref tagged otherwise. Call it in any writer that sets an avatar outside this module's serializers; storing the ref and letting the model default choose the tag is exactly how a live stand ended up serving 500s from a cosmetic avatar.
- validate_avatar_reference — stapel_profiles.models.validate_avatar_reference
  instead of: stapel_core.django.cdn.fields.validate_cdn_reference
  Format-check an avatar value against its declared avatar_source before storing it — the source taxonomy (file/url/gravatar/cdn) lives here, and only `cdn` has a fixed `avatar/<64-hex>` wire shape. Reach for it from any serializer seam that accepts an avatar; validating with the core CDN checker directly instead rejects every non-CDN source a project is allowed to pick.
- validate_display_name — stapel_profiles.validators.validate_display_name
  This module's display-name canon — minimum length, control/invisible characters and emoji, raising StapelValidationError with the module's own error keys so the frontend gets a translatable key rather than a sentence. Any host onboarding form, admin action or importer that writes a name must run it through here instead of inventing a second, differently-strict regex; the user.registered pre-fill already does.
### predicate
- declared_languages — stapel_profiles.models.declared_languages
  instead of: django.conf.settings.LANGUAGES
  Answer which languages this deployment actually DECLARED (it set LANGUAGES itself), as {code: name} — Django's 100-entry default answers {} because nobody claimed it. Reach for it wherever a language code must be validated or offered; reading settings.LANGUAGES directly cannot tell a declaration from Django's default and turns an unconfigured project into one that claims a hundred languages.
### factory
- assemble_profile_fields — stapel_profiles.field_defs.assemble_profile_fields
  The manifest → {field_name: Django field} primitive under build_profile_model, failing loudly at assembly time on an unknown identity preset or standard-field name instead of with a confusing system check later. Call it directly only when splicing profile fields into a model you already own; if you want a Profile, call build_profile_model.
- avatar_image — stapel_profiles.serializers.avatar_image
  instead of: stapel_core.media.image
  The renderable StapelImage for a profile's avatar — variant ladder plus blur-up — mapping this module's avatar_source taxonomy onto the matching media provider. A custom profile presenter or serializer seam must call this rather than hand the raw ref to the media builder with a guessed provider: a pil-default deployment describing CDN refs is how meettoday shipped an empty variant ladder.
- build_profile_model — stapel_profiles.field_defs.build_profile_model
  Assemble a project's extended, swappable Profile subclass from a field manifest (identity preset + standard fields + custom ProfileFieldDefs) — call it once in your OWN app's models.py and point STAPEL_SWAP["PROFILES_PROFILE_MODEL"] at the result, so the extra columns and their migration live in the project rather than in this package.
- ensure_declared_languages — stapel_profiles.models.ensure_declared_languages
  instead of: stapel_profiles.management.commands.sync_languages
  Make the Language reference table agree with the declaration: materialise a row for every declared code that has none. Reach for it before listing or validating languages in a deployment that never ran `sync_languages` — without it the picker is empty and every app_language write 400s, so no user can ever state a language and every notification downstream has to guess at its recipient's.
- get_profile_model — stapel_profiles.models.get_profile_model
  instead of: stapel_profiles.models.Profile
  THE way to reach the profile DAO: resolves STAPEL_SWAP["PROFILES_PROFILE_MODEL"], so a host that assembled its own extended Profile (build_profile_model) is honoured. Views, serializers, admin, GDPR providers and product code all go through it — a direct `from stapel_profiles.models import Profile` pins the zero-field default and silently ignores the swap, which is exactly what the SWAP001 lint flags.
- publish_profile_changed — stapel_profiles.events.publish_profile_changed
  instead of: stapel_core.comm.emit
  Emit profile.changed for a mutated profile row — the payload shape (schemas/emits/profile.changed.json) and the best-effort savepoint isolation that keeps a broker failure from 500-ing the request both live inside. Call it after ANY profile write that does not go through this module's serializers (a data migration, an admin action, a product's own service function), or every downstream consumer of the event quietly desyncs.

## Extension points — what a product replaces, fork-free
- profile.changed [comm_event]
  The primary hook for syncing derived data: emitted on every profile mutation (keyed by user_id) — subscribe with @on_action from any module or the host app, no import of this package.
- profile_extras_relation [app_layer_model]
  Extend the profile without a fork: an app-layer model with a OneToOne to profiles.Profile (or keyed by the same user_id UUID), exposed through the serializer seams — models here are deliberately not swappable.
- serializer_seams [class_override]
  Every profile/relationship view declares request/response serializer seams (SerializerSeamsMixin) — subclass the view, override the attribute, remount the URL; keep calling super() in serializer overrides so profile.changed keeps publishing.

## Fits with — fleet dependencies
- stapel-auth (optional) — consumes the user.registered / user.deleted comm events (OAuth avatar import, GDPR erasure on account deletion) that stapel-auth emits; decoupled via the comm bus, not a hard import dependency
- stapel-cdn (optional) — avatar storage/existence checks (PROFILES_AVATAR_CHECK=comm calls cdn.media_exists) and OAuth avatar import (cdn.import_from_url) go through stapel_core.comm to a CDN provider
- stapel-core (required) — comm bus (events/functions), JWT authentication, GDPR provider registry, AppSettings config layer
- stapel-gdpr (optional) — surfaces this module's registered GDPR provider (profile section export/erase) through DSR endpoints; the registration itself only needs stapel-core.gdpr

## HTTP operations (16) — call by operationId, never by a typed path
Paths are relative to `/profiles/api/v1/`.
### Languages
- GET /languages/{code}/ — get_language
- GET /languages/ — list_languages
### Notifications
- POST /notifications/unsubscribe — unsubscribe_notifications
### Profile
- POST /batch — batch_profiles
- GET /field-manifest — get_field_manifest
- GET /me — get_my_profile
- GET /{user_id} — get_profile
- PATCH /me — update_my_profile
### Relationships
- POST /{user_id}/block — block_user
- POST /{user_id}/follow — follow_user
- GET /me/blocked — get_my_blocked
- GET /me/followers — get_my_followers
- GET /me/following — get_my_following
- GET /{user_id}/relationship — get_relationship
- POST /{user_id}/unblock — unblock_user
- POST /{user_id}/unfollow — unfollow_user

## Error codes (53) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.avatar_not_found [400] fix_input
- error.400.avatar_source_mismatch [400] fix_input
- error.400.bad_request [400] fix_input
- error.400.cannot_block_self [400] fix_input
- error.400.cannot_follow_self [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.display_name_emoji [400] fix_input
- error.400.display_name_forbidden_chars [400] fix_input
- error.400.display_name_invisible_chars [400] fix_input
- error.400.display_name_too_short [400] fix_input
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.invalid_avatar_format [400] fix_input
- error.400.invalid_currency [400] fix_input
- error.400.too_many_ids [400] fix_input {requested,limit}
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.profile_not_found [404] fix_input
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
