feat: add MemoryCard model for knowledge cards in shared spaces

Lightweight card system for notes, ideas, tasks, references, and quotes
within SharedSpaces. Supports markdown body, tags, media attachments (M2M),
position ordering, and type filtering. REST API at /api/v1/cards/ with
full CRUD. Phase 2 stub for Docmost collaborative editing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 03:38:50 +00:00
parent b45a29c092
commit f600ceac14
6 changed files with 237 additions and 3 deletions

View File

@ -6,7 +6,7 @@ from django import forms
from django.contrib import admin
from django.utils.html import format_html
from .models import MediaFile, PublicShare, FileAccessLog, SharedSpace
from .models import MediaFile, PublicShare, FileAccessLog, SharedSpace, MemoryCard
class SharedSpaceAdminForm(forms.ModelForm):
@ -206,6 +206,44 @@ class PublicShareAdmin(admin.ModelAdmin):
public_url_display.short_description = 'Public URL'
@admin.register(MemoryCard)
class MemoryCardAdmin(admin.ModelAdmin):
list_display = [
'title',
'card_type',
'shared_space',
'position',
'attachment_count',
'created_at',
]
list_filter = ['card_type', 'shared_space', 'created_at']
search_fields = ['title', 'body', 'tags']
readonly_fields = ['id', 'created_at', 'updated_at']
fieldsets = (
(None, {
'fields': ('id', 'title', 'body', 'card_type', 'tags')
}),
('Space & Position', {
'fields': ('shared_space', 'position')
}),
('Attachments', {
'fields': ('media_files',),
}),
('Integration', {
'fields': ('docmost_page_id',),
'classes': ('collapse',)
}),
('Metadata', {
'fields': ('created_by', 'created_at', 'updated_at')
}),
)
filter_horizontal = ['media_files']
def attachment_count(self, obj):
return obj.media_files.count()
attachment_count.short_description = 'Attachments'
@admin.register(FileAccessLog)
class FileAccessLogAdmin(admin.ModelAdmin):
list_display = [

View File

@ -0,0 +1,40 @@
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('files', '0003_sharedspace_owner_did_sharedspace_visibility'),
]
operations = [
migrations.CreateModel(
name='MemoryCard',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=255)),
('body', models.TextField(blank=True, default='', help_text='Markdown content')),
('card_type', models.CharField(choices=[('note', 'Note'), ('idea', 'Idea'), ('task', 'Task'), ('reference', 'Reference'), ('quote', 'Quote')], default='note', max_length=20)),
('tags', models.JSONField(blank=True, default=list)),
('position', models.IntegerField(db_index=True, default=0)),
('docmost_page_id', models.CharField(blank=True, default='', help_text='Docmost page ID for collaborative editing (Phase 2)', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('shared_space', models.ForeignKey(help_text='Space this card belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='memory_cards', to='files.sharedspace')),
('media_files', models.ManyToManyField(blank=True, related_name='memory_cards', to='files.mediafile')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='memory_cards', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['position', '-created_at'],
'indexes': [
models.Index(fields=['card_type'], name='files_memor_card_ty_idx'),
models.Index(fields=['position'], name='files_memor_positio_idx'),
models.Index(fields=['created_at'], name='files_memor_created_idx'),
],
},
),
]

View File

@ -353,6 +353,80 @@ class PublicShare(models.Model):
return f"{base_url}/s/{self.token}"
class MemoryCard(models.Model):
"""
Lightweight knowledge card within a shared space.
Phase 1: native Django model for notes, ideas, tasks, references.
Phase 2: optional Docmost sidecar for collaborative wiki editing.
"""
CARD_TYPE_CHOICES = [
('note', 'Note'),
('idea', 'Idea'),
('task', 'Task'),
('reference', 'Reference'),
('quote', 'Quote'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
shared_space = models.ForeignKey(
'SharedSpace',
on_delete=models.CASCADE,
related_name='memory_cards',
help_text="Space this card belongs to"
)
title = models.CharField(max_length=255)
body = models.TextField(blank=True, default='', help_text="Markdown content")
card_type = models.CharField(
max_length=20,
choices=CARD_TYPE_CHOICES,
default='note',
)
tags = models.JSONField(default=list, blank=True)
# Optional media attachments
media_files = models.ManyToManyField(
'MediaFile',
blank=True,
related_name='memory_cards',
)
# Ordering within the space
position = models.IntegerField(default=0, db_index=True)
# Phase 2: Docmost integration
docmost_page_id = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Docmost page ID for collaborative editing (Phase 2)"
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='memory_cards'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['position', '-created_at']
indexes = [
models.Index(fields=['card_type']),
models.Index(fields=['position']),
models.Index(fields=['created_at']),
]
def __str__(self):
return f"[{self.card_type}] {self.title}"
class FileAccessLog(models.Model):
"""
Audit log for file access events.

View File

@ -5,7 +5,7 @@ Serializers for file upload and sharing.
from rest_framework import serializers
from django.utils import timezone
from .models import MediaFile, PublicShare, FileAccessLog
from .models import MediaFile, PublicShare, FileAccessLog, MemoryCard
class MediaFileSerializer(serializers.ModelSerializer):
@ -221,6 +221,48 @@ class PublicShareInfoSerializer(serializers.ModelSerializer):
}
class MemoryCardSerializer(serializers.ModelSerializer):
attachment_count = serializers.SerializerMethodField()
class Meta:
model = MemoryCard
fields = [
'id',
'shared_space',
'title',
'body',
'card_type',
'tags',
'media_files',
'position',
'docmost_page_id',
'created_by',
'created_at',
'updated_at',
'attachment_count',
]
read_only_fields = [
'id',
'created_by',
'created_at',
'updated_at',
'attachment_count',
]
def get_attachment_count(self, obj):
return obj.media_files.count()
def create(self, validated_data):
media_files = validated_data.pop('media_files', [])
request = self.context.get('request')
if request and request.user.is_authenticated:
validated_data['created_by'] = request.user
card = super().create(validated_data)
if media_files:
card.media_files.set(media_files)
return card
class FileAccessLogSerializer(serializers.ModelSerializer):
class Meta:
model = FileAccessLog

View File

@ -8,6 +8,7 @@ from rest_framework.routers import DefaultRouter
from .views import (
MediaFileViewSet,
PublicShareViewSet,
MemoryCardViewSet,
PublicDownloadView,
PublicShareInfoView,
PublicShareVerifyPasswordView,
@ -16,6 +17,7 @@ from .views import (
router = DefaultRouter()
router.register(r'media', MediaFileViewSet, basename='media')
router.register(r'shares', PublicShareViewSet, basename='shares')
router.register(r'cards', MemoryCardViewSet, basename='cards')
# API URLs (under /api/v1/)
api_urlpatterns = [

View File

@ -13,7 +13,7 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from .models import MediaFile, PublicShare, FileAccessLog, SharedSpace
from .models import MediaFile, PublicShare, FileAccessLog, SharedSpace, MemoryCard
from config.encryptid_auth import SpacePermission
from .serializers import (
MediaFileSerializer,
@ -22,6 +22,7 @@ from .serializers import (
PublicShareCreateSerializer,
PublicShareInfoSerializer,
FileAccessLogSerializer,
MemoryCardSerializer,
)
@ -211,6 +212,43 @@ class PublicShareViewSet(viewsets.ModelViewSet):
return request.META.get('REMOTE_ADDR', '')
class MemoryCardViewSet(viewsets.ModelViewSet):
"""ViewSet for managing memory cards within shared spaces."""
queryset = MemoryCard.objects.all()
serializer_class = MemoryCardSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, SpacePermission]
def get_space_config(self, request):
"""Provide space config for SpacePermission check."""
space_slug = request.query_params.get('space')
if not space_slug:
return None
try:
space = SharedSpace.objects.get(slug=space_slug, is_active=True)
return {'visibility': space.visibility, 'owner_did': space.owner_did}
except SharedSpace.DoesNotExist:
return None
def get_queryset(self):
queryset = MemoryCard.objects.all()
space = self.request.query_params.get('space')
if space:
queryset = queryset.filter(shared_space__slug=space)
card_type = self.request.query_params.get('type')
if card_type:
queryset = queryset.filter(card_type=card_type)
tags = self.request.query_params.getlist('tag')
if tags:
for tag in tags:
queryset = queryset.filter(tags__contains=tag)
return queryset
@method_decorator(csrf_exempt, name='dispatch')
class PublicDownloadView(View):
"""Public view for downloading shared files."""