Managing Contributors#
This guide covers administrator workflows for managing people, organizations, and affiliations in your research portal.
Overview#
The Contributors system provides admin interfaces for:
Person Admin: Unified user account + contributor profile management
Organization Admin: Organization management with inline affiliations
Affiliation Admin: Verification and role management workflows
ORCID/ROR Sync: Background synchronization with external scholarly databases
Person Admin Interface#
Unified Auth + Contributor Fields#
The Person admin combines Django’s user management with contributor-specific fields in a single interface.
Key Admin Sections#
Personal Information Tab:
Name fields (first_name, last_name, auto-generates display name)
Email address (required for claimed accounts, NULL for unclaimed)
Profile/biography
Avatar image
Keywords (research interests)
Account Status Tab:
is_claimed: Boolean indicating if this person has claimed their accountis_active: Standard Django active statusis_staff: Django admin accessis_superuser: Full system access
Privacy Settings Tab:
privacy_settings: JSON field controlling field visibilityDefault: Email private for claimed users, all fields public for unclaimed
Identifiers Tab:
ORCID (via ContributorIdentifier inline)
Other external identifiers
Permissions Tab:
Django groups and permissions
Object-level permissions (via django-guardian)
State Machine Overview#
Person accounts follow this lifecycle:
Ghost (unclaimed, no email):
is_claimed=False,email=None,is_active=FalseCreated automatically during data import for attribution
Not searchable in portal member listings
Invited (unclaimed, has email):
is_claimed=False,emailset,is_active=FalseInvitation sent but not yet accepted
Invitation workflows in Feature 010 (not yet released)
Claimed (active account):
is_claimed=True,is_active=TrueFull user account with authentication
Appears in portal member listings
Banned (deactivated):
is_claimed=True,is_active=FalseAccount disabled but data preserved
Creating Person Records#
Creating Claimed User Accounts:
Navigate to Contributors > Persons > Add person
Fill required fields:
Email address
First name
Last name
Password (or use “Set password” link after creation)
Check
is_active(auto-checked for claimed accounts)Save
The system automatically:
Sets
is_claimed=TrueGenerates
namefrom first_name + last_nameSets default privacy (email private)
Creating Unclaimed Records (for data attribution):
Navigate to Contributors > Persons > Add person
Fill name fields only (leave email NULL)
Leave
is_activeuncheckedLeave
is_claimeduncheckedSave
Use for attributing data to people who haven’t registered yet.
Bulk ORCID Import#
To import multiple people from ORCID:
Prepare CSV with ORCID IDs:
orcid_id 0000-0002-1825-0097 0000-0003-1234-5678
Use Django shell:
import csv from fairdm.contrib.contributors.models import Person with open('orcids.csv') as f: reader = csv.DictReader(f) for row in reader: Person.from_orcid(row['orcid_id'])
Background tasks will sync full ORCID data asynchronously
Organization Admin Interface#
Organization Overview#
Organizations represent institutions, companies, research groups, and other organizational entities.
Admin Features#
Inline Memberships:
View and edit affiliations directly within organization admin
Add new members inline
Assign roles (PENDING, MEMBER, ADMIN, OWNER)
ROR Synchronization:
Admin action: “Sync from ROR”
Select organizations with ROR identifiers
Triggers background sync task
Updates organization metadata from ROR
Ownership Transfer:
Admin action: “Transfer Ownership”
Select single organization
Redirects to organization page with instructions
Ownership transfer via Affiliation type change
Creating Organizations#
Navigate to Contributors > Organizations > Add organization
Fill required fields:
Name
City (optional)
Country (optional)
Location (lat/lon, optional)
Save
To link with ROR:
Create organization
Add ContributorIdentifier with type=“ROR” and value=“0xxxxxx00”
Use “Sync from ROR” admin action to populate metadata
Managing Organization Memberships#
Adding Members:
Open organization in admin
Scroll to “Affiliations” inline section
Click “Add another Affiliation”
Select:
Person
Type (PENDING, MEMBER, ADMIN, OWNER)
Start date (PartialDate: “2020”, “2020-03”, or “2020-03-15”)
End date (optional, leave NULL for current affiliation)
Is primary (one per person)
Save
Affiliation Type Meanings:
PENDING: Membership pending verification (no permissions)
MEMBER: Regular member (read-only org access)
ADMIN: Administrator (can manage memberships)
OWNER: Owner (full control, automatically grants
manage_organizationpermission)
⚠️ Important: Only ONE owner per organization. Setting a new owner automatically demotes the previous owner to ADMIN.
Transferring Organization Ownership#
To transfer ownership:
Method 1: Via Admin Action
Select organization in admin changelist
Choose “Transfer Ownership” action
Follow instructions to set new owner
Method 2: Via Affiliation Type
Edit the organization
Find the affiliation for the new owner
Change type to “OWNER”
Save
Old owner is automatically demoted to ADMIN
The manage_organization permission is automatically synced via lifecycle hooks.
Affiliation Verification Workflow#
Workflow Overview#
User Requests Affiliation (Feature 010 - not released):
User submits affiliation request via portal UI
Creates Affiliation with type=PENDING
Admin Reviews:
Navigate to Contributors > Affiliations
Filter by
type=PENDINGReview request
Approve or Reject:
Approve: Change type to MEMBER (or ADMIN if appropriate)
Reject: Delete affiliation or leave as PENDING with note
Permission Sync:
Lifecycle hooks automatically update object-level permissions
OWNER affiliations grant
manage_organizationpermission
Affiliation Admin List Filters#
Use filters to find affiliations:
By organization: Filter by organization name
By person: Filter by person name
By type: PENDING, MEMBER, ADMIN, OWNER
By status: Current (end_date=NULL) vs Past (end_date set)
Primary only: Show only is_primary=True
Bulk Affiliation Management#
To verify multiple pending affiliations:
Filter list:
type=PENDINGSelect affiliations to approve
Choose admin action: “Approve affiliations”
Confirm bulk update
(Note: Custom admin action required - not in base FairDM)
ORCID/ROR Sync Troubleshooting#
Background Task System#
ORCID and ROR synchronization uses Celery background tasks:
Person.from_orcid(): Schedules
sync_contributor_identifiertaskOrganization.from_ror(): Schedules
sync_contributor_identifiertaskAdmin Actions: “Sync from ROR” runs background sync
Checking Sync Status#
Via Admin Interface:
Open Person or Organization in admin
Check “Synced data” readonly field:
If empty: No sync data available
If populated: Shows raw JSON from ORCID/ROR
Check “Last synced” timestamp
Via Django Shell:
from fairdm.contrib.contributors.models import Person
person = Person.objects.get(email="example@example.com")
print(person.synced_data) # Raw ORCID data
print(person.last_synced) # Timestamp
# Get ORCID identifier
orcid = person.identifiers.filter(type="ORCID").first()
if orcid:
print(f"ORCID ID: {orcid.value}")
Common Sync Issues#
Issue: ORCID sync fails
Symptoms:
synced_dataempty after synclast_syncednot updated
Troubleshooting:
Check Celery worker is running:
celery -A config worker -l info
Check ORCID API credentials in settings:
# config/settings.py ORCID_CLIENT_ID = env("ORCID_CLIENT_ID") ORCID_SECRET = env("ORCID_SECRET")
Verify ORCID identifier format:
Correct:
0000-0002-1825-0097Incorrect:
https://orcid.org/0000-0002-1825-0097
Check Celery task status:
# Install flower for monitoring celery -A config flower # Visit http://localhost:5555
Issue: ROR sync not updating organization
Troubleshooting:
Verify ROR ID format:
Correct:
04aj4c181(9 characters, starts with 0)Also accepts:
https://ror.org/04aj4c181
Check ROR identifier exists:
org = Organization.objects.get(pk=123) ror = org.identifiers.filter(type="ROR").first() print(ror.value) # Should print ROR ID
Manually trigger sync:
from fairdm.contrib.contributors.tasks import sync_contributor_identifier ror_id = org.identifiers.filter(type="ROR").first() sync_contributor_identifier.delay(ror_id.pk)
Issue: Celery broker not configured
Error: kombu.exceptions.OperationalError: [Errno 111] Connection refused
Fix:
Start Redis:
# Linux/Mac redis-server # Windows (via Docker) docker run -d -p 6379:6379 redis
Configure broker in settings:
# config/settings.py CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://localhost:6379/0")
Restart Celery worker
Manual Sync (No Celery)#
To sync without Celery (development only):
from fairdm.contrib.contributors.utils.transforms import ORCIDTransform, RORTransform
# ORCID sync
person = Person.objects.get(email="example@example.com")
orcid_id = person.identifiers.filter(type="ORCID").first()
if orcid_id:
# Fetch and apply ORCID data
orcid_data = ORCIDTransform.fetch(orcid_id.value)
internal_data = ORCIDTransform.to_internal(orcid_data)
for key, value in internal_data.items():
setattr(person, key, value)
person.synced_data = orcid_data
person.last_synced = timezone.now()
person.save()
# ROR sync (similar pattern)
org = Organization.objects.get(pk=123)
ror_id = org.identifiers.filter(type="ROR").first()
if ror_id:
ror_data = RORTransform.fetch(ror_id.value)
internal_data = RORTransform.to_internal(ror_data)
for key, value in internal_data.items():
setattr(org, key, value)
org.synced_data = ror_data
org.last_synced = timezone.now()
org.save()
Data Cleanup & Maintenance#
Finding Orphaned Ghosts#
Ghost persons (unclaimed, no email) created during data import but never used:
from fairdm.contrib.contributors.models import Person
# Find ghosts with no contributions
ghosts_without_data = Person.objects.ghost().filter(
contributions__isnull=True,
affiliations__isnull=True
)
# Optionally delete
# ghosts_without_data.delete()
Merging Duplicate Persons#
If duplicate person records exist:
Identify duplicates (same name, similar email/ORCID)
Choose canonical record
Reassign contributions:
from fairdm.contrib.contributors.models import Person, Contribution canonical = Person.objects.get(pk=primary_id) duplicate = Person.objects.get(pk=duplicate_id) # Move contributions Contribution.objects.filter(contributor=duplicate).update(contributor=canonical) # Move affiliations duplicate.affiliations.update(person=canonical) # Delete duplicate duplicate.delete()
Verify contributions transferred:
canonical.contributions.count() # Should include old duplicate's contributions
Bulk Privacy Settings Update#
To update privacy settings for multiple unclaimed persons:
from fairdm.contrib.contributors.models import Person
# Make all unclaimed persons' emails public (they're NULL anyway)
unclaimed_persons = Person.objects.unclaimed()
for person in unclaimed_persons:
person.privacy_settings = {"email": "public"}
person.save()
Performance Optimization#
Large Contributor Databases#
For portals with 10,000+ contributors:
QuerySet Optimization:
# Use select_related for foreign keys
affiliations = Affiliation.objects.select_related('person', 'organization')
# Use prefetch_related for reverse relations
people = Person.objects.prefetch_related('affiliations', 'contributions')
# Annotate counts to avoid N+1 queries
from django.db.models import Count
people_with_counts = Person.objects.real().annotate(
contribution_count=Count('contributions'),
affiliation_count=Count('affiliations')
)
Database Indexing:
Ensure indexes exist on:
Person.email(automatically indexed via unique=True)Person.is_claimedPerson.is_activeAffiliation.typeAffiliation.end_dateAffiliation.is_primary
Check via:
poetry run python manage.py sqlmigrate contributors 0001
# Look for CREATE INDEX statements
Caching Contributor Lookups:
For frequently accessed profiles:
from django.core.cache import cache
def get_person_profile(person_pk):
cache_key = f"person_profile_{person_pk}"
profile = cache.get(cache_key)
if profile is None:
person = Person.objects.select_related('primary_affiliation__organization').get(pk=person_pk)
profile = {
"name": person.name,
"email": person.email,
"affiliation": person.primary_affiliation().organization.name if person.primary_affiliation() else None,
}
cache.set(cache_key, profile, timeout=3600) # 1 hour
return profile
Security Considerations#
Protecting Personal Data#
Email Privacy:
By default, claimed user emails are private
Unclaimed persons have NULL emails (safe to display)
Use
person.get_visible_fields(viewer)in templates
GDPR Compliance:
Person records contain personal data
Implement data export/deletion on request
Use privacy_settings to let users control visibility
Sample GDPR Export:
def export_person_data(person):
"""Export all personal data for GDPR compliance."""
return {
"personal_info": {
"name": person.name,
"email": person.email,
"profile": person.profile,
},
"affiliations": [
{
"organization": aff.organization.name,
"role": aff.get_type_display(),
"start": str(aff.start_date),
"end": str(aff.end_date) if aff.end_date else None,
}
for aff in person.affiliations.all()
],
"contributions": [
{
"type": contrib.content_type.model,
"id": contrib.object_id,
"roles": [role.label for role in contrib.roles.all()],
}
for contrib in person.contributions.all()
],
}
Permission Boundaries#
Organization Ownership:
Only one OWNER per organization
OWNER has
manage_organizationpermissionPermission synced automatically via Affiliation lifecycle hooks
Admin Access:
Django staff/superuser can access all records
Non-staff users see only records they have permission for
Use django-guardian for object-level permissions