How-To Guides

Add Custom Fields

How to extend ZineCore2 models with 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

Custom fields added! Continue to Deploy to Production for deployment guidance.
Copyright ©2026 ZineCore2 Contributors,