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:
parent
b45a29c092
commit
f600ceac14
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue