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
| Action | File | Responsibility |
|---|---|---|
| Create | apps/spaces/models/product_item.py | ProductItem model |
| Modify | apps/spaces/models/space.py:41-46 | Add DIGITAL_PRODUCT to SpaceType |
| Modify | apps/spaces/models/__init__.py | Export ProductItem |
| Create | apps/spaces/serializers/product_items.py | ProductItemSerializer |
| Modify | apps/spaces/serializers/__init__.py | Export serializer |
| Create | apps/spaces/views/product_items.py | ProductItem views |
| Modify | apps/spaces/views/__init__.py | Export views |
| Modify | apps/spaces/urls.py | Register routes |
| Modify | apps/spaces/admin.py | Register admin |
| Create | apps/spaces/migrations/XXXX_add_product_item.py | Migration |
Task 1: ProductItem Model
Covers: Core data model for digital products
Files:
Create:
apps/spaces/models/product_item.pyModify:
apps/spaces/models/space.py:41-46Modify:
apps/spaces/models/__init__.py[ ] Step 1: Add DIGITAL_PRODUCT to SpaceType
# 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
# 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
# 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
make manage ARGS='makemigrations spaces --name add_product_item'- [ ] Step 5: Apply migration
make migrateTask 2: Serializer
Covers: API serialization for ProductItem
Files:
Create:
apps/spaces/serializers/product_items.pyModify:
apps/spaces/serializers/__init__.py[ ] Step 1: Create ProductItemSerializer
# 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
# apps/spaces/serializers/__init__.py — add import and __all__ entry
from apps.spaces.serializers.product_items import ProductItemSerializerTask 3: Views
Covers: CRUD API endpoints for ProductItem
Files:
Create:
apps/spaces/views/product_items.pyModify:
apps/spaces/views/__init__.py[ ] Step 1: Create ProductItem views
# 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
# apps/spaces/views/__init__.py — add import and __all__ entry
from apps.spaces.views.product_items import ProductItemDetailView, ProductItemListCreateViewTask 4: URLs
Covers: Route registration
Files:
Modify:
apps/spaces/urls.py[ ] Step 1: Add routes
# 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
# 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
make manage ARGS='showmigrations spaces'- [ ] Step 2: Run existing tests (no regressions)
make test ARGS='apps.spaces'- [ ] Step 3: Run type check
make type-check- [ ] Step 4: Run lint
make ruff