Profile Claiming APIs#

For portal developers who need to extend, customise, or integrate with FairDM’s profile-claiming machinery.


Overview#

Feature 010 introduces a set of service functions, utilities, and an admin integration for linking unclaimed contributor profiles to registered user accounts. The implementation is split across three layers:

Layer

Module

Purpose

Token utilities

fairdm.contrib.contributors.utils.tokens

Generate and validate signed claim tokens

Audit utilities

fairdm.contrib.contributors.utils.audit

Write immutable audit-log records

Claiming services

fairdm.contrib.contributors.services.claiming

Execute each claiming pathway

Merge service

fairdm.contrib.contributors.services.merge

Merge two Person records

Fuzzy matching

fairdm.contrib.contributors.services.matching

Find potential duplicate Persons


Settings#

CLAIM_TOKEN_MAX_AGE#

Default: 604800 (7 days, in seconds)

Controls the validity window for claim token links generated by generate_claim_token(). Tokens older than this value are rejected by validate_claim_token().

# settings.py
CLAIM_TOKEN_MAX_AGE = 3 * 24 * 60 * 60  # 3 days

CLAIM_TOKEN_SALT#

Default: "fairdm.contributor.claim"

The HMAC salt passed to Django’s TimestampSigner. Changing this value invalidates all existing tokens — use with care in production.

# settings.py
CLAIM_TOKEN_SALT = "myportal.contributor.claim"

Token Utilities#

generate_claim_token(person: Person) -> str#

Returns an opaque, URL-safe signed string encoding the Person PK and a timestamp. No database record is created.

from fairdm.contrib.contributors.utils.tokens import generate_claim_token

token = generate_claim_token(person)
claim_url = request.build_absolute_uri(
    reverse("contributors:claim-profile", kwargs={"token": token})
)
# Send claim_url to the contributor by email.

Preconditions: person.is_claimed must be False.

Notes:

  • Generating a new token does not invalidate previously generated tokens.

  • All unexpired tokens become inert once the person is claimed (is_claimed=True).


validate_claim_token(token_string: str) -> Person#

Validates the token signature and checks whether the person is still unclaimed. Raises ClaimingError on any failure.

from fairdm.contrib.contributors.utils.tokens import validate_claim_token
from fairdm.contrib.contributors.exceptions import ClaimingError

try:
    person = validate_claim_token(token_string)
except ClaimingError as exc:
    # Token is invalid, expired, tampered, or person already claimed.
    logger.warning("Token validation failed: %s", exc)

Raises ClaimingError when:

  • The HMAC signature is invalid (tampered URL).

  • The token has exceeded CLAIM_TOKEN_MAX_AGE.

  • The resolved Person is already claimed (is_claimed=True).


Claiming Services#

claim_via_orcid(person, sociallogin) -> Person#

Called automatically by the SocialAccountAdapter.pre_social_login hook when an ORCID identifier on an unclaimed profile matches the signing-in user’s ORCID iD. Portals using the default adapter do not need to call this directly.

from fairdm.contrib.contributors.services.claiming import claim_via_orcid
from fairdm.contrib.contributors.exceptions import ClaimingError

try:
    claimed = claim_via_orcid(person, sociallogin)
except ClaimingError as exc:
    # Person is already claimed or banned.
    ...

On success: sets is_claimed=True, is_active=True, connects the social account, and writes a ClaimingAuditLog record.


claim_via_email(person) -> Person | None#

Called automatically by the handle_email_confirmed signal handler. Portals using the default signal handler do not need to call this directly.

from fairdm.contrib.contributors.services.claiming import claim_via_email

result = claim_via_email(person)
if result is None:
    # ACCOUNT_EMAIL_VERIFICATION != "mandatory" — email claiming is disabled.
    pass

Silent no-op (returns None) when ACCOUNT_EMAIL_VERIFICATION != "mandatory".

On success: sets is_claimed=True, is_active=True, and writes an audit log record.


claim_via_token(token_string, user) -> Person#

Called by ClaimProfileConfirmView on POST /claim/<token>/confirm/. Can also be called programmatically for custom claiming flows.

from fairdm.contrib.contributors.services.claiming import claim_via_token
from fairdm.contrib.contributors.exceptions import ClaimingError

try:
    claimed = claim_via_token(token_string, request.user)
except ClaimingError as exc:
    messages.error(request, str(exc))

Handles the simple-claim path only. When the calling user already has a different active Person profile, the view layer routes to merge_persons() instead.


Merge Service#

merge_persons(person_keep, person_discard) -> Person#

Transfers all data from person_discard to person_keep and permanently deletes person_discard. The entire operation is wrapped in transaction.atomic().

from fairdm.contrib.contributors.services.merge import merge_persons

try:
    kept = merge_persons(person_keep=active_user, person_discard=unclaimed)
except Exception:
    # Any error causes a full rollback — partial merges are impossible.
    raise

What is transferred:

  • Contributions (with unique_together deduplication)

  • ContributorIdentifier records

  • Affiliations (with unique_together deduplication)

  • allauth EmailAddress and SocialAccount records

  • Guardian UserObjectPermission assignments

  • Blank profile fields (first_name, last_name, website, bio, image)

What is NOT merged: is_active, is_claimed — managed by the service layer.

Raises ValueError (not ClaimingError) when person_keep == person_discard.


Fuzzy Matching Service#

find_duplicate_candidates(person, threshold=0.85) -> list[dict]#

Returns a list of {"person": Person, "score": float} dicts for persons whose name similarity (via rapidfuzz.fuzz.token_sort_ratio) meets or exceeds threshold.

from fairdm.contrib.contributors.services.matching import find_duplicate_candidates

candidates = find_duplicate_candidates(person, threshold=0.85)
for candidate in candidates:
    print(candidate["person"].name, candidate["score"])

Results are sorted by score descending. The person itself is always excluded. This function is called on-demand from the admin change view — it does not persist results.


Custom Exceptions#

ClaimingError#

Location: fairdm.contrib.contributors.exceptions

Raised by all claiming service functions for expected, user-facing failure conditions. Distinguishes them from unexpected programmer errors (which still raise ValueError).

from fairdm.contrib.contributors.exceptions import ClaimingError

try:
    claim_via_token(token_string, user)
except ClaimingError as exc:
    # Safe to show exc message to the user.
    messages.error(request, str(exc))

Audit Logging#

Every claiming attempt — success or failure — is written to ClaimingAuditLog.

log_claiming_event(method, source, target, initiated_by, ip_address, success, failure_reason, details)#

You can write custom audit entries from your own claiming code:

from fairdm.contrib.contributors.utils.audit import log_claiming_event
from fairdm.contrib.contributors.models import ClaimMethod

log_claiming_event(
    method=ClaimMethod.ADMIN_MANUAL,
    source=unclaimed_person,
    target=activated_person,
    initiated_by=request.user,
    ip_address=request.META.get("REMOTE_ADDR"),
    success=True,
    details={"note": "Manually activated by admin."},
)

ClaimingAuditLog records are immutable — save() raises ValueError on updates.


Extending the Claiming Flow#

Custom adapter#

To override or extend ORCID claiming behaviour, subclass SocialAccountAdapter:

# myportal/adapters.py
from fairdm.contrib.contributors.adapters import SocialAccountAdapter

class CustomSocialAccountAdapter(SocialAccountAdapter):
    def pre_social_login(self, request, sociallogin):
        # Custom pre-processing ...
        super().pre_social_login(request, sociallogin)
# settings.py
SOCIALACCOUNT_ADAPTER = "myportal.adapters.CustomSocialAccountAdapter"

Custom signal handler#

To add post-claim logic after email confirmation, connect to email_confirmed:

from allauth.account.signals import email_confirmed

def my_post_claim_handler(sender, request=None, email_address=None, **kwargs):
    # Additional logic after email claiming ...
    pass

email_confirmed.connect(my_post_claim_handler)