From f600ceac144c48718dd810b20dba64296b687124 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 03:38:50 +0000 Subject: [PATCH] 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 --- files/admin.py | 40 ++++++++++++- files/migrations/0004_add_memory_card.py | 40 +++++++++++++ files/models.py | 74 ++++++++++++++++++++++++ files/serializers.py | 44 +++++++++++++- files/urls.py | 2 + files/views.py | 40 ++++++++++++- 6 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 files/migrations/0004_add_memory_card.py diff --git a/files/admin.py b/files/admin.py index 4be708c..336f6d8 100644 --- a/files/admin.py +++ b/files/admin.py @@ -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 = [ diff --git a/files/migrations/0004_add_memory_card.py b/files/migrations/0004_add_memory_card.py new file mode 100644 index 0000000..25d6059 --- /dev/null +++ b/files/migrations/0004_add_memory_card.py @@ -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'), + ], + }, + ), + ] diff --git a/files/models.py b/files/models.py index ec8e24c..d207a87 100644 --- a/files/models.py +++ b/files/models.py @@ -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. diff --git a/files/serializers.py b/files/serializers.py index 5df6440..3a09ac2 100644 --- a/files/serializers.py +++ b/files/serializers.py @@ -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 diff --git a/files/urls.py b/files/urls.py index 390a874..a9a48a3 100644 --- a/files/urls.py +++ b/files/urls.py @@ -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 = [ diff --git a/files/views.py b/files/views.py index 04996f3..b5aaab7 100644 --- a/files/views.py +++ b/files/views.py @@ -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."""