Initial commit: Aurora Moments self-hosted photo/video sharing platform
Django/DRF + PostgreSQL/Redis/Celery backend with accounts, library, moments, and notifications apps; React/TypeScript/Vite frontend with Instagram-style sidebar nav, feed, profiles, nested comment replies, notifications, and photo/video upload with ffmpeg-generated posters. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Comment, Moment, MomentItem, Reaction
|
||||
|
||||
|
||||
class MomentItemInline(admin.TabularInline):
|
||||
model = MomentItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Moment)
|
||||
class MomentAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "owner", "audience", "created_at")
|
||||
list_filter = ("audience",)
|
||||
inlines = [MomentItemInline]
|
||||
|
||||
|
||||
admin.site.register(Comment)
|
||||
admin.site.register(Reaction)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MomentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.moments"
|
||||
label = "moments"
|
||||
@@ -0,0 +1,74 @@
|
||||
# Generated by Django 5.0.9 on 2026-08-09 17:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('library', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Moment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('caption', models.TextField(blank=True, max_length=2200)),
|
||||
('location', models.CharField(blank=True, max_length=255)),
|
||||
('audience', models.CharField(choices=[('public', 'Public'), ('followers', 'Followers'), ('friends', 'Friends'), ('family', 'Family'), ('custom', 'Custom'), ('only_me', 'Only Me')], default='public', max_length=10)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moments', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Comment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('body', models.TextField(max_length=1000)),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='moments.comment')),
|
||||
('moment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='moments.moment')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MomentItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('library_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moment_items', to='library.libraryitem')),
|
||||
('moment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='moments.moment')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
'unique_together': {('moment', 'library_item')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Reaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('reaction_type', models.CharField(choices=[('like', 'Like')], default='like', max_length=10)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('moment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='moments.moment')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('moment', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import Audience, TimeStampedModel
|
||||
from apps.library.models import LibraryItem
|
||||
|
||||
|
||||
class MomentQuerySet(models.QuerySet):
|
||||
def visible_to(self, user):
|
||||
"""FAMILY and CUSTOM audiences intentionally fall back to
|
||||
owner-only visibility until circles/custom-audience membership
|
||||
(V1.5) exists — better to under-share than leak content."""
|
||||
from apps.accounts.models import Follow
|
||||
|
||||
following_ids = Follow.objects.filter(
|
||||
follower=user, status=Follow.Status.ACCEPTED
|
||||
).values_list("following_id", flat=True)
|
||||
follower_ids = set(
|
||||
Follow.objects.filter(
|
||||
following=user, status=Follow.Status.ACCEPTED
|
||||
).values_list("follower_id", flat=True)
|
||||
)
|
||||
friend_ids = follower_ids & set(following_ids)
|
||||
|
||||
return self.filter(
|
||||
models.Q(owner=user)
|
||||
| models.Q(audience=Audience.PUBLIC)
|
||||
| models.Q(audience=Audience.FOLLOWERS, owner_id__in=following_ids)
|
||||
| models.Q(audience=Audience.FRIENDS, owner_id__in=friend_ids)
|
||||
).distinct()
|
||||
|
||||
|
||||
class Moment(TimeStampedModel):
|
||||
"""The social-media object: one or more LibraryItems shared with an
|
||||
explicit audience. Sharing a Moment never moves or copies the original
|
||||
file — it just makes the existing library item visible to others."""
|
||||
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="moments", on_delete=models.CASCADE)
|
||||
caption = models.TextField(max_length=2200, blank=True)
|
||||
location = models.CharField(max_length=255, blank=True)
|
||||
audience = models.CharField(max_length=10, choices=Audience.choices, default=Audience.PUBLIC)
|
||||
|
||||
objects = MomentQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Moment({self.id}) by {self.owner}"
|
||||
|
||||
|
||||
class MomentItem(models.Model):
|
||||
moment = models.ForeignKey(Moment, related_name="items", on_delete=models.CASCADE)
|
||||
library_item = models.ForeignKey(LibraryItem, related_name="moment_items", on_delete=models.CASCADE)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
unique_together = ("moment", "library_item")
|
||||
|
||||
|
||||
class Comment(TimeStampedModel):
|
||||
moment = models.ForeignKey(Moment, related_name="comments", on_delete=models.CASCADE)
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="comments", on_delete=models.CASCADE)
|
||||
parent = models.ForeignKey("self", related_name="replies", on_delete=models.CASCADE, null=True, blank=True)
|
||||
body = models.TextField(max_length=1000)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.author}: {self.body[:40]}"
|
||||
|
||||
|
||||
class Reaction(models.Model):
|
||||
class ReactionType(models.TextChoices):
|
||||
LIKE = "like", "Like"
|
||||
|
||||
moment = models.ForeignKey(Moment, related_name="reactions", on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="reactions", on_delete=models.CASCADE)
|
||||
reaction_type = models.CharField(max_length=10, choices=ReactionType.choices, default=ReactionType.LIKE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("moment", "user")
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
|
||||
class IsOwnerOrReadOnly(BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return obj.owner_id == request.user.id
|
||||
@@ -0,0 +1,103 @@
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.serializers import UserSerializer
|
||||
from apps.library.models import LibraryItem
|
||||
from apps.library.serializers import LibraryItemSerializer
|
||||
|
||||
from .models import Comment, Moment, MomentItem, Reaction
|
||||
|
||||
|
||||
class MomentItemSerializer(serializers.ModelSerializer):
|
||||
library_item = LibraryItemSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = MomentItem
|
||||
fields = ["id", "library_item", "order"]
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
author = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ["id", "moment", "author", "parent", "body", "created_at"]
|
||||
read_only_fields = ["id", "moment", "author", "created_at"]
|
||||
|
||||
def validate_parent(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
if value.parent_id is not None:
|
||||
raise serializers.ValidationError("Cannot reply to a reply; reply to the original comment instead.")
|
||||
moment = self.context.get("moment")
|
||||
if moment is not None and value.moment_id != moment.id:
|
||||
raise serializers.ValidationError("Reply must target a comment on the same Moment.")
|
||||
return value
|
||||
|
||||
|
||||
class ReactionSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Reaction
|
||||
fields = ["id", "moment", "user", "reaction_type", "created_at"]
|
||||
read_only_fields = ["id", "moment", "user", "created_at"]
|
||||
|
||||
|
||||
class MomentSerializer(serializers.ModelSerializer):
|
||||
owner = UserSerializer(read_only=True)
|
||||
items = MomentItemSerializer(many=True, read_only=True)
|
||||
library_item_ids = serializers.PrimaryKeyRelatedField(
|
||||
queryset=LibraryItem.objects.all(), many=True, write_only=True, source="library_items", required=False
|
||||
)
|
||||
comment_count = serializers.IntegerField(source="comments.count", read_only=True)
|
||||
reaction_count = serializers.IntegerField(source="reactions.count", read_only=True)
|
||||
viewer_reaction_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Moment
|
||||
fields = [
|
||||
"id", "owner", "caption", "location", "audience", "items",
|
||||
"library_item_ids", "comment_count", "reaction_count", "viewer_reaction_id",
|
||||
"created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id", "owner", "items", "comment_count", "reaction_count", "viewer_reaction_id",
|
||||
"created_at", "updated_at",
|
||||
]
|
||||
|
||||
def get_viewer_reaction_id(self, obj):
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
# obj.reactions is prefetched by the view, so this doesn't re-query per moment.
|
||||
reaction = next((r for r in obj.reactions.all() if r.user_id == request.user.id), None)
|
||||
return reaction.id if reaction else None
|
||||
|
||||
def validate_library_item_ids(self, library_items):
|
||||
request = self.context["request"]
|
||||
seen_ids = set()
|
||||
for item in library_items:
|
||||
if item.owner_id != request.user.id:
|
||||
raise serializers.ValidationError("Can only share items from your own library.")
|
||||
if item.id in seen_ids:
|
||||
raise serializers.ValidationError("The same photo was selected more than once.")
|
||||
seen_ids.add(item.id)
|
||||
return library_items
|
||||
|
||||
def validate(self, attrs):
|
||||
library_items = attrs.get("library_items", [])
|
||||
caption = attrs.get("caption", "")
|
||||
if not library_items and not caption.strip():
|
||||
raise serializers.ValidationError("A Moment needs a caption or at least one photo.")
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
library_items = validated_data.pop("library_items", [])
|
||||
validated_data["owner"] = self.context["request"].user
|
||||
with transaction.atomic():
|
||||
moment = Moment.objects.create(**validated_data)
|
||||
MomentItem.objects.bulk_create(
|
||||
[MomentItem(moment=moment, library_item=item, order=i) for i, item in enumerate(library_items)]
|
||||
)
|
||||
return moment
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import CommentViewSet, MomentViewSet, ReactionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", MomentViewSet, basename="moment")
|
||||
|
||||
comments_list = CommentViewSet.as_view({"get": "list", "post": "create"})
|
||||
comments_detail = CommentViewSet.as_view({"get": "retrieve", "delete": "destroy"})
|
||||
reactions_list = ReactionViewSet.as_view({"get": "list", "post": "create"})
|
||||
reactions_detail = ReactionViewSet.as_view({"get": "retrieve", "delete": "destroy"})
|
||||
|
||||
urlpatterns = [
|
||||
path("<int:moment_pk>/comments/", comments_list, name="moment-comments-list"),
|
||||
path("<int:moment_pk>/comments/<int:pk>/", comments_detail, name="moment-comments-detail"),
|
||||
path("<int:moment_pk>/reactions/", reactions_list, name="moment-reactions-list"),
|
||||
path("<int:moment_pk>/reactions/<int:pk>/", reactions_detail, name="moment-reactions-detail"),
|
||||
] + router.urls
|
||||
@@ -0,0 +1,69 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from .models import Comment, Moment, Reaction
|
||||
from .permissions import IsOwnerOrReadOnly
|
||||
from .serializers import CommentSerializer, MomentSerializer, ReactionSerializer
|
||||
|
||||
|
||||
class MomentViewSet(viewsets.ModelViewSet):
|
||||
"""list() is the home feed: everything visible to the requesting user,
|
||||
newest first. Filter to one user's posts (for a profile page) with
|
||||
?owner__username=<username> — still scoped by visible_to() below, so a
|
||||
private/followers-only post won't leak through the filter."""
|
||||
|
||||
serializer_class = MomentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly]
|
||||
filterset_fields = ["owner__username"]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Moment.objects.visible_to(self.request.user)
|
||||
.select_related("owner")
|
||||
.prefetch_related("items__library_item__renditions", "reactions")
|
||||
)
|
||||
|
||||
|
||||
class CommentViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = CommentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||
|
||||
def _get_moment(self):
|
||||
return Moment.objects.visible_to(self.request.user).get(pk=self.kwargs["moment_pk"])
|
||||
|
||||
def get_queryset(self):
|
||||
return Comment.objects.filter(moment=self._get_moment()).select_related("author")
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context["moment"] = self._get_moment()
|
||||
return context
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(moment=self._get_moment(), author=self.request.user)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.author_id != self.request.user.id and instance.moment.owner_id != self.request.user.id:
|
||||
raise PermissionDenied("Cannot delete another user's comment.")
|
||||
instance.delete()
|
||||
|
||||
|
||||
class ReactionViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ReactionSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||
|
||||
def _get_moment(self):
|
||||
return Moment.objects.visible_to(self.request.user).get(pk=self.kwargs["moment_pk"])
|
||||
|
||||
def get_queryset(self):
|
||||
return Reaction.objects.filter(moment=self._get_moment()).select_related("user")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(moment=self._get_moment(), user=self.request.user)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.user_id != self.request.user.id:
|
||||
raise PermissionDenied("Cannot remove another user's reaction.")
|
||||
instance.delete()
|
||||
Reference in New Issue
Block a user