Archived
feat: monorepo structure with Django backend, React frontend, and Expo mobile app
- Backend: Django 5 + DRF with accounts, documents, collections, and reading apps - Custom User model with email-based auth, JWT via SimpleJWT - Full CRUD viewsets with ModelSerializer + DRF routers - pytest, Ruff, drf-spectacular (OpenAPI), whitenoise - Dockerfile for production deployment - Frontend: React 18 + TypeScript + Vite - Lazy-loaded routes with ProtectedRoute/PublicRoute guards - Auth context with useReducer, token refresh interceptor - Pages: Login, Register, Library, Document Detail, Reader, Collections, Settings - Dark theme, responsive grid layout, Vite proxy to Django backend - Mobile: Expo SDK 51 + React Native + Expo Router - File-based routing with login, register, and library screens - AsyncStorage for token persistence, token refresh interceptor - Shared API types via @cloud-reader/shared workspace package - Shared: TypeScript types (API responses, auth, documents, etc.) - CI/CD: 3 independent GitHub Actions pipelines (backend, frontend, mobile)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Collection
|
||||
|
||||
|
||||
@admin.register(Collection)
|
||||
class CollectionAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "owner", "document_count", "is_public", "created_at"]
|
||||
list_filter = ["is_public"]
|
||||
search_fields = ["name", "description"]
|
||||
@@ -0,0 +1,39 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Collection(models.Model):
|
||||
"""A user-created collection of documents."""
|
||||
name = models.CharField(max_length=300)
|
||||
description = models.TextField(blank=True, default="")
|
||||
cover = models.ImageField(upload_to="collection_covers/", blank=True, null=True)
|
||||
documents = models.ManyToManyField(
|
||||
"documents.Document",
|
||||
related_name="collections",
|
||||
blank=True,
|
||||
)
|
||||
is_public = models.BooleanField(default=False)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="collections",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "collections_collection"
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def document_count(self) -> int:
|
||||
return self.documents.count()
|
||||
|
||||
@property
|
||||
def cover_url(self) -> str | None:
|
||||
if self.cover:
|
||||
return self.cover.url
|
||||
return None
|
||||
@@ -0,0 +1,33 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Collection
|
||||
|
||||
|
||||
class CollectionSerializer(serializers.ModelSerializer[Collection]):
|
||||
cover_url = serializers.SerializerMethodField()
|
||||
document_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Collection
|
||||
fields = [
|
||||
"id", "name", "description", "cover_url", "document_count",
|
||||
"is_public", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "document_count", "created_at", "updated_at"]
|
||||
|
||||
def get_cover_url(self, obj: Collection) -> str | None:
|
||||
return obj.cover_url
|
||||
|
||||
|
||||
class CollectionDetailSerializer(CollectionSerializer):
|
||||
documents = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
|
||||
|
||||
class Meta(CollectionSerializer.Meta):
|
||||
fields = CollectionSerializer.Meta.fields + ["documents", "owner"]
|
||||
|
||||
|
||||
class CollectionDocumentActionSerializer(serializers.Serializer):
|
||||
document_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
allow_empty=False,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", views.CollectionViewSet, basename="collection")
|
||||
|
||||
app_name = "collections"
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.documents.models import Document
|
||||
|
||||
from .models import Collection
|
||||
from .serializers import (
|
||||
CollectionDetailSerializer,
|
||||
CollectionDocumentActionSerializer,
|
||||
CollectionSerializer,
|
||||
)
|
||||
|
||||
|
||||
class CollectionViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD for user collections."""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ("retrieve", "update", "partial_update"):
|
||||
return CollectionDetailSerializer
|
||||
return CollectionSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Collection.objects.filter(owner=self.request.user).prefetch_related("documents")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def add_documents(self, request, pk=None):
|
||||
"""Add documents to a collection."""
|
||||
collection = self.get_object()
|
||||
serializer = CollectionDocumentActionSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
docs = Document.objects.filter(
|
||||
id__in=serializer.validated_data["document_ids"],
|
||||
owner=request.user,
|
||||
)
|
||||
collection.documents.add(*docs)
|
||||
return Response({"detail": f"Added {docs.count()} document(s)."}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def remove_documents(self, request, pk=None):
|
||||
"""Remove documents from a collection."""
|
||||
collection = self.get_object()
|
||||
serializer = CollectionDocumentActionSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
collection.documents.remove(*serializer.validated_data["document_ids"])
|
||||
return Response({"detail": "Documents removed."}, status=status.HTTP_200_OK)
|
||||
Reference in New Issue
Block a user