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."""