Add Custom Fields
This guide explains how to add custom fields to ZineCore2 models while maintaining compatibility with the standard specification.
Prerequisites
- ✅ Understanding of Django models
- ✅ Understanding of Django migrations
- ✅ ZineCore2 server installed
- ✅ Database backed up
When to Add Custom Fields
Good reasons:
- Track institution-specific metadata
- Add workflow fields (review_status, cataloger_notes)
- Add local identifiers or classifications
- Extend profiles for specialized collections
Consider first:
- Can you use existing fields? (e.g., public_notes, identifier)
- Can you use vocabularies instead?
- Is this truly local, or should it be in the spec?
Adding a Simple Field
Step 1: Modify Model
Add field to the appropriate model:
# catalog/models.py
class Zine(TimestampedModel):
# ... existing fields ...
# CUSTOM FIELD: Local cataloging status
catalog_status = models.CharField(
max_length=50,
blank=True,
default='uncataloged',
choices=[
('uncataloged', 'Uncataloged'),
('in-progress', 'In Progress'),
('complete', 'Complete'),
('needs-review', 'Needs Review'),
],
help_text="Internal cataloging workflow status"
)
Step 2: Create Migration
cd backend
DJANGO_SETTINGS_MODULE=zinecore.settings.development \
../.venv/bin/python manage.py makemigrations catalog
Output:
Migrations for 'catalog':
catalog/migrations/0004_zine_catalog_status.py
- Add field catalog_status to zine
Step 3: Apply Migration
DJANGO_SETTINGS_MODULE=zinecore.settings.development \
../.venv/bin/python manage.py migrate catalog
Step 4: Update Serializers
Write serializer (for input):
# catalog/serializers.py
class ZineWriteSerializer(serializers.ModelSerializer):
class Meta:
model = Zine
fields = [
# ... existing fields ...
'catalog_status', # Add custom field
]
Read serializer (for output):
class ZineReadSerializer(serializers.ModelSerializer):
class Meta:
model = Zine
fields = [
# ... existing fields ...
'catalog_status', # Add custom field
]
Step 5: Test
# Create zine with custom field
curl -X POST http://localhost:8000/api/zines/ \
-H "Authorization: Token YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"zine_id": "zine_test",
"title": "Test Zine",
"creator": ["agent_test"],
"subject": ["test"],
"genre": ["test-zine"],
"date": ["2024"],
"language": ["en"],
"rights": ["unknown"],
"catalog_status": "in-progress"
}'
# Verify field appears in response
curl http://localhost:8000/api/zines/zine_test/ | jq '.catalog_status'
# Output: "in-progress"
Custom Field Types
Text Field
For longer text:
cataloger_notes = models.TextField(
blank=True,
help_text="Internal notes for catalogers (not public)"
)
Boolean Field
For yes/no flags:
digitization_priority = models.BooleanField(
default=False,
help_text="Mark for priority digitization"
)
Date Field
For dates:
catalog_date = models.DateField(
null=True,
blank=True,
help_text="Date cataloging was completed"
)
Integer Field
For numeric values:
physical_copy_count = models.IntegerField(
default=1,
help_text="Number of physical copies held"
)
Choice Field
For predefined options:
preservation_priority = models.CharField(
max_length=20,
choices=[
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
('urgent', 'Urgent'),
],
default='medium',
blank=True
)
Foreign Key
Link to another model:
cataloged_by = models.ForeignKey(
'auth.User',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='cataloged_zines',
help_text="Staff member who cataloged this zine"
)
Array Field
For lists (PostgreSQL only):
from django.contrib.postgres.fields import ArrayField
local_tags = ArrayField(
models.CharField(max_length=100),
blank=True,
default=list,
help_text="Local classification tags"
)
Custom Field Best Practices
Naming Convention
Prefix custom fields to distinguish from spec fields:
# Good
local_catalog_status = models.CharField(...)
internal_review_date = models.DateField(...)
inst_preservation_notes = models.TextField(...)
# Avoid (could conflict with future spec fields)
status = models.CharField(...)
review_date = models.DateField(...)
notes = models.TextField(...)
Documentation
Add clear help_text:
catalog_status = models.CharField(
max_length=50,
help_text="Internal cataloging workflow status (local extension)"
)
Defaults
Provide sensible defaults:
catalog_status = models.CharField(
max_length=50,
blank=True,
default='uncataloged' # Default prevents null errors
)
Optional vs Required
Make custom fields optional unless absolutely necessary:
# Good (optional)
cataloger_notes = models.TextField(blank=True)
# Risky (required)
cataloger_notes = models.TextField() # Breaks existing records!
Adding Fields to All Profiles
If you need the same field on multiple models:
Option 1: Add to Base Model
# core/models.py
class TimestampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# NEW: Add custom field to base
internal_notes = models.TextField(
blank=True,
help_text="Internal staff notes"
)
class Meta:
abstract = True
Effect: All models that inherit TimestampedModel get this field.
Migration: Run makemigrations for each app:
python manage.py makemigrations catalog agents holdings repositories
python manage.py migrate
Option 2: Add to Each Model
Add field to each model individually:
# catalog/models.py
class Zine(TimestampedModel):
# ... fields ...
internal_notes = models.TextField(blank=True)
# agents/models.py
class Agent(TimestampedModel):
# ... fields ...
internal_notes = models.TextField(blank=True)
Complex Custom Fields
JSON Field
For structured custom data:
from django.db.models import JSONField
custom_metadata = JSONField(
blank=True,
null=True,
default=dict,
help_text="Additional custom metadata (JSON)"
)
Usage:
zine.custom_metadata = {
"acquisition_price": 5.00,
"vendor": "Powell's Books",
"special_collections": ["Riot Grrrl Collection"]
}
zine.save()
Computed Fields
Fields calculated from other fields:
from django.db import models
class Zine(TimestampedModel):
# ... fields ...
@property
def is_fully_cataloged(self):
"""Check if all required cataloging is complete"""
return (
self.catalog_status == 'complete' and
self.creator and
self.subject and
bool(self.abstract)
)
Usage:
zine = Zine.objects.get(zine_id='zine_001')
if zine.is_fully_cataloged:
print("Ready for public display")
Note: Properties aren't stored in database, calculated on-the-fly.
Filtering by Custom Fields
Add to ViewSet
# catalog/views.py
class ZineViewSet(viewsets.ModelViewSet):
queryset = Zine.objects.all()
lookup_field = 'zine_id'
filterset_fields = [
'subject',
'genre',
'catalog_status', # Add custom field
]
Usage:
# Filter by custom field
curl "http://localhost:8000/api/zines/?catalog_status=in-progress"
Custom Filter Logic
For complex filtering:
# catalog/filters.py
from django_filters import rest_framework as filters
from .models import Zine
class ZineFilter(filters.FilterSet):
# Custom filter: uncataloged zines
needs_cataloging = filters.BooleanFilter(
method='filter_needs_cataloging',
label='Needs cataloging'
)
def filter_needs_cataloging(self, queryset, name, value):
if value:
return queryset.filter(catalog_status__in=['uncataloged', 'in-progress'])
return queryset
class Meta:
model = Zine
fields = ['catalog_status', 'needs_cataloging']
# catalog/views.py
class ZineViewSet(viewsets.ModelViewSet):
filterset_class = ZineFilter
Usage:
curl "http://localhost:8000/api/zines/?needs_cataloging=true"
Django Admin Integration
Display Custom Fields
# catalog/admin.py
from django.contrib import admin
from .models import Zine
@admin.register(Zine)
class ZineAdmin(admin.ModelAdmin):
list_display = [
'zine_id',
'title',
'catalog_status', # Show in list
]
list_filter = [
'catalog_status', # Filter sidebar
'digitization_priority',
]
fieldsets = [
('ZineCore2 Fields', {
'fields': ['zine_id', 'title', 'creator', ...]
}),
('Custom Fields', {
'fields': ['catalog_status', 'cataloger_notes'],
'classes': ['collapse'], # Collapsible section
}),
]
Maintaining Spec Compatibility
Keep Spec Fields Separate
DO:
# catalog/models.py
class Zine(TimestampedModel):
# === ZineCore2 Spec Fields ===
zine_id = models.CharField(...)
title = models.CharField(...)
# ... all spec fields ...
# === Local Custom Fields ===
catalog_status = models.CharField(...)
cataloger_notes = models.TextField(...)
DON'T:
# Mix spec and custom fields randomly
zine_id = models.CharField(...)
catalog_status = models.CharField(...) # Custom (confusing!)
title = models.CharField(...)
Document Custom Fields
Create CUSTOM_FIELDS.md:
# Custom Fields
## Zine Model
- `catalog_status` — Internal workflow status (added 2024-02-25)
- `cataloger_notes` — Internal cataloger notes (added 2024-02-25)
## Agent Model
- `staff_access_level` — Internal permission level (added 2024-03-01)
Future Spec Updates
If a spec update adds a field that conflicts with your custom field:
Step 1: Rename custom field:
python manage.py makemigrations --empty catalog --name rename_custom_field
# Migration
operations = [
migrations.RenameField(
model_name='zine',
old_name='status',
new_name='local_status',
),
]
Step 2: Add spec field normally.
Troubleshooting
"Migration conflicts detected"
Cause: Multiple migrations modifying the same field.
Solution:
# Merge migrations
python manage.py makemigrations --merge
"Column already exists"
Cause: Field added manually to database.
Solution:
# Fake the migration (database already correct)
python manage.py migrate catalog 0004 --fake
"Cannot add required field without default"
Cause: Adding required field to table with existing data.
Solution: Add field as optional first, or provide default:
# Option 1: Optional
new_field = models.CharField(max_length=100, blank=True)
# Option 2: Default
new_field = models.CharField(max_length=100, default='default_value')
Next Steps
- Database Migrations — Managing schema changes
- Architecture: Models — Understanding model structure
- Architecture: Serializers — Serializer patterns