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 |
|
Generate and validate signed claim tokens |
Audit utilities |
|
Write immutable audit-log records |
Claiming services |
|
Execute each claiming pathway |
Merge service |
|
Merge two Person records |
Fuzzy 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
Personis already claimed (is_claimed=True).
Claiming Services#
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_togetherdeduplication)ContributorIdentifierrecordsAffiliations (with
unique_togetherdeduplication)allauth
EmailAddressandSocialAccountrecordsGuardian
UserObjectPermissionassignmentsBlank 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)