Skip to content

Digital Products Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a DIGITAL_PRODUCT space type with ProductItem model for selling tablature packs, preset packs, and individual downloadable items with video content.

Architecture: Follow the existing Module/Lesson pattern: Space(space_type="digital_product") is the sellable container, ProductItem is the child model with tab files, video media, and music-specific metadata. Pricing via existing Paywall. Access via existing AccessGroup.

Tech Stack: Django 6.0, DRF, PostgreSQL, django-vite, SimpleHistory


File Structure

ActionFileResponsibility
Createapps/spaces/models/product_item.pyProductItem model
Modifyapps/spaces/models/space.py:41-46Add DIGITAL_PRODUCT to SpaceType
Modifyapps/spaces/models/__init__.pyExport ProductItem
Createapps/spaces/serializers/product_items.pyProductItemSerializer
Modifyapps/spaces/serializers/__init__.pyExport serializer
Createapps/spaces/views/product_items.pyProductItem views
Modifyapps/spaces/views/__init__.pyExport views
Modifyapps/spaces/urls.pyRegister routes
Modifyapps/spaces/admin.pyRegister admin
Createapps/spaces/migrations/XXXX_add_product_item.pyMigration

Task 1: ProductItem Model

Covers: Core data model for digital products

Files:

  • Create: apps/spaces/models/product_item.py

  • Modify: apps/spaces/models/space.py:41-46

  • Modify: apps/spaces/models/__init__.py

  • [ ] Step 1: Add DIGITAL_PRODUCT to SpaceType

python
# apps/spaces/models/space.py, line 41-46
class SpaceType(models.TextChoices):
    BASIC = "basic", "Basic (feed)"
    CHAT = "chat", "Chat"
    EVENT = "event", "Event"
    COURSE = "course", "Course"
    DIGITAL_PRODUCT = "digital_product", "Digital Product"
    MEMBERS = "members", "Members"
    IMAGE = "image", "Image"
  • [ ] Step 2: Create ProductItem model
python
# apps/spaces/models/product_item.py
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models

from apps.communities.models import Community
from apps.spaces.models.space import Space
from apps.utils.models import AuditableModel


class ProductItem(AuditableModel):
    """A downloadable item within a digital-product Space — tablature, preset,
    sample pack, etc. Each item can bundle multiple videos (execution, lesson,
    slow/fast variants) via the same GenericRelation pipeline Lesson uses."""

    class FileType(models.TextChoices):
        TABLATURE = "tablature", "Tablature"
        PRESET = "preset", "Preset"
        MIDI = "midi", "MIDI"
        SAMPLE_PACK = "sample_pack", "Sample Pack"
        OTHER = "other", "Other"

    space = models.ForeignKey(Space, on_delete=models.CASCADE, related_name="product_items")
    community = models.ForeignKey(Community, on_delete=models.CASCADE, related_name="product_items")
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)

    # The actual downloadable file (.gp7, .gp8, .pdf, .fxp, .zip, etc.)
    file = models.FileField(upload_to="product-items/files/")

    # Multiple videos (execution, slow, fast, lesson) — same pipeline as Lesson
    media_items = GenericRelation("media.Media")

    # File classification
    file_type = models.CharField(max_length=20, choices=FileType.choices, default=FileType.OTHER)

    # Tablature metadata (blank when not applicable)
    tuning = models.CharField(max_length=50, blank=True, help_text="e.g. Standard, Drop D, DADGAD")
    difficulty = models.CharField(
        max_length=20,
        blank=True,
        choices=[
            ("beginner", "Beginner"),
            ("intermediate", "Intermediate"),
            ("advanced", "Advanced"),
        ],
    )
    instrument = models.CharField(
        max_length=20,
        blank=True,
        choices=[
            ("guitar", "Guitar"),
            ("bass", "Bass"),
            ("keys", "Keys"),
            ("drums", "Drums"),
            ("other", "Other"),
        ],
    )
    key = models.CharField(max_length=20, blank=True, help_text="e.g. Am, E, C major")

    # Preset metadata (blank when not applicable)
    daw = models.CharField(max_length=50, blank=True, help_text="e.g. Ableton, Logic, FL Studio")
    plugin = models.CharField(max_length=100, blank=True, help_text="e.g. Kontakt, Serum, Guitar Rig")
    genre = models.CharField(max_length=50, blank=True, help_text="e.g. blues, jazz, rock")

    # Preview & ordering
    is_free_preview = models.BooleanField(default=False)
    order = models.PositiveIntegerField(default=0)

    class Meta:
        ordering = ["order", "id"]
        indexes = [
            models.Index(fields=["space", "order"]),
            models.Index(fields=["community"]),
            models.Index(fields=["file_type"]),
        ]

    def __str__(self):
        return f"{self.space}: {self.title}"

    def clean(self):
        if self.space_id and self.space.space_type != Space.SpaceType.DIGITAL_PRODUCT:
            raise ValidationError("ProductItems can only be created in digital_product spaces.")
        if self.space_id and self.community_id and self.space.community_id != self.community_id:
            raise ValidationError("ProductItem community must match its space's community.")
  • [ ] Step 3: Export from models init
python
# apps/spaces/models/__init__.py — add import and __all__ entry
from apps.spaces.models.product_item import ProductItem
# ... add "ProductItem" to __all__
  • [ ] Step 4: Generate migration
bash
make manage ARGS='makemigrations spaces --name add_product_item'
  • [ ] Step 5: Apply migration
bash
make migrate

Task 2: Serializer

Covers: API serialization for ProductItem

Files:

  • Create: apps/spaces/serializers/product_items.py

  • Modify: apps/spaces/serializers/__init__.py

  • [ ] Step 1: Create ProductItemSerializer

python
# apps/spaces/serializers/product_items.py
from rest_framework import serializers

from apps.spaces.models import ProductItem


class ProductItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = ProductItem
        fields = (
            "id",
            "title",
            "description",
            "file",
            "file_type",
            "tuning",
            "difficulty",
            "instrument",
            "key",
            "daw",
            "plugin",
            "genre",
            "is_free_preview",
            "order",
            "created_at",
        )
        read_only_fields = ("id", "created_at")

    def create(self, validated_data):
        request = self.context["request"]
        space = self.context["space"]
        validated_data["community"] = request.community
        validated_data["space"] = space
        return super().create(validated_data)
  • [ ] Step 2: Export from serializers init
python
# apps/spaces/serializers/__init__.py — add import and __all__ entry
from apps.spaces.serializers.product_items import ProductItemSerializer

Task 3: Views

Covers: CRUD API endpoints for ProductItem

Files:

  • Create: apps/spaces/views/product_items.py

  • Modify: apps/spaces/views/__init__.py

  • [ ] Step 1: Create ProductItem views

python
# apps/spaces/views/product_items.py
from rest_framework import generics, permissions

from apps.communities.permissions import RequiresActiveMembership, RequiresCommunity
from apps.spaces.models import ProductItem, Space
from apps.spaces.permissions import HasSpaceAccess, IsAdminOrModeratorForWrite
from apps.spaces.serializers import ProductItemSerializer
from apps.spaces.views.base import SpaceScopedItemMixin


class ProductItemListCreateView(SpaceScopedItemMixin, generics.ListCreateAPIView):
    _space_type = Space.SpaceType.DIGITAL_PRODUCT
    serializer_class = ProductItemSerializer
    permission_classes = [
        RequiresCommunity,
        permissions.IsAuthenticated,
        RequiresActiveMembership,
        IsAdminOrModeratorForWrite,
    ]

    def get_queryset(self):
        return self.get_space().product_items.all()


class ProductItemDetailView(generics.RetrieveUpdateDestroyAPIView):
    serializer_class = ProductItemSerializer
    permission_classes = [
        RequiresCommunity,
        permissions.IsAuthenticated,
        RequiresActiveMembership,
        HasSpaceAccess,
        IsAdminOrModeratorForWrite,
    ]
    lookup_url_kwarg = "item_id"

    def get_queryset(self):
        return ProductItem.objects.filter(
            space__in=Space.objects.visible_to(self.request.membership),
            space__space_type=Space.SpaceType.DIGITAL_PRODUCT,
        )
  • [ ] Step 2: Export from views init
python
# apps/spaces/views/__init__.py — add import and __all__ entry
from apps.spaces.views.product_items import ProductItemDetailView, ProductItemListCreateView

Task 4: URLs

Covers: Route registration

Files:

  • Modify: apps/spaces/urls.py

  • [ ] Step 1: Add routes

python
# apps/spaces/urls.py — add after the lesson-progress line (line 55)
path("spaces/<int:space_id>/product-items/", views.ProductItemListCreateView.as_view(), name="product-item-list-create"),
path("product-items/<int:item_id>/", views.ProductItemDetailView.as_view(), name="product-item-detail"),

Task 5: Admin

Covers: Django admin registration

Files:

  • Modify: apps/spaces/admin.py

  • [ ] Step 1: Register ProductItemAdmin

python
# apps/spaces/admin.py — add import at top
from apps.spaces.models import ProductItem

# Add admin class after LessonProgressAdmin
@admin.register(ProductItem)
class ProductItemAdmin(admin.ModelAdmin):
    list_display = ("space", "title", "file_type", "is_free_preview", "order")
    list_editable = ("order", "is_free_preview")
    list_filter = ("space", "file_type", "difficulty", "instrument")
    search_fields = ("title", "description")
    autocomplete_fields = ("space",)

Task 6: Verification

Covers: Ensure everything works

  • [ ] Step 1: Run migrations check
bash
make manage ARGS='showmigrations spaces'
  • [ ] Step 2: Run existing tests (no regressions)
bash
make test ARGS='apps.spaces'
  • [ ] Step 3: Run type check
bash
make type-check
  • [ ] Step 4: Run lint
bash
make ruff

Strum — Documentação.