Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Profile Picture Support for Users and Groups #12

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ venv.bak/

pnpm-lock.yaml
docker-compose.override.yml
.claudesync
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ services:
sh -c "
cd /app &&
echo 'Installing Dependencies...' &&
npm install &&
npm install --legacy-peer-deps &&
echo 'Starting Development Server...' &&
npm run dev
"
Expand Down
20 changes: 20 additions & 0 deletions splinter/apps/group/migrations/0004_group_profile_picture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2024-12-31 12:21

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('group', '0003_alter_groupmembership_options'),
('media', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='group',
name='profile_picture',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='group_profile', to='media.media'),
),
]
11 changes: 10 additions & 1 deletion splinter/apps/group/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@

from splinter.apps.group.managers import GroupManager
from splinter.db.models import PublicModel, SoftDeleteModel, TimestampedModel
from splinter.core.mixins import ProfilePictureMixin


class Group(TimestampedModel, SoftDeleteModel, PublicModel):
class Group(TimestampedModel, SoftDeleteModel, PublicModel, ProfilePictureMixin):
name = models.CharField(max_length=255)
members = models.ManyToManyField('user.User', through='group.GroupMembership', related_name='+')

profile_picture = models.OneToOneField(
'media.Media',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='group_profile'
)

created_by = models.ForeignKey('user.User', on_delete=models.PROTECT, related_name='created_groups')

objects = GroupManager()
Expand Down
12 changes: 11 additions & 1 deletion splinter/apps/group/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@
from splinter.apps.group.models import Group, GroupMembership
from splinter.apps.user.fields import UserSerializerField
from splinter.apps.user.models import User
from splinter.apps.media.models import Media
from splinter.apps.user.serializers import SimpleUserSerializer
from splinter.core.prefetch import PrefetchQuerysetSerializerMixin


class GroupProfilePictureSerializer(serializers.ModelSerializer):
class Meta:
model = Media
fields = ('uid', 'url')
read_only_fields = ('uid', 'url')


class SimpleGroupSerializer(serializers.ModelSerializer):
urn = serializers.CharField(read_only=True)
uid = serializers.CharField(source='public_id', read_only=True)
Expand All @@ -38,6 +46,8 @@ def prefetch_queryset(self, queryset=None):


class GroupSerializer(PrefetchQuerysetSerializerMixin, SimpleGroupSerializer):
profile_picture = GroupProfilePictureSerializer(read_only=True)

outstanding_balances = GroupOutstandingBalanceSerializer(
many=True,
read_only=True,
Expand All @@ -48,7 +58,7 @@ class GroupSerializer(PrefetchQuerysetSerializerMixin, SimpleGroupSerializer):
)

class Meta(SimpleGroupSerializer.Meta):
fields = SimpleGroupSerializer.Meta.fields + ('outstanding_balances', 'aggregated_outstanding_balance')
fields = SimpleGroupSerializer.Meta.fields + ('outstanding_balances', 'aggregated_outstanding_balance', 'profile_picture')

def prefetch_queryset(self, queryset=None):
outstanding_balance_qs = self.prefetch_nested_queryset('outstanding_balances').filter(
Expand Down
1 change: 1 addition & 0 deletions splinter/apps/group/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
path('groups/<uuid:group_uid>', views.RetrieveUpdateDestroyGroupView.as_view()),
path('groups/<uuid:group_uid>/members', views.CreateUpdateGroupMembershipView.as_view()),
path('groups/<uuid:group_uid>/members/<str:member_uid>', views.DestroyGroupMembershipView.as_view()),
path('groups/<uuid:group_uid>/profile-picture', views.UpdateGroupProfilePictureView.as_view()),
]
27 changes: 27 additions & 0 deletions splinter/apps/group/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from rest_framework.exceptions import ValidationError
from rest_framework.generics import get_object_or_404
from rest_framework.parsers import MultiPartParser

from splinter.apps.expense.models import OutstandingBalance
from splinter.apps.group.models import Group, GroupMembership
Expand Down Expand Up @@ -81,3 +82,29 @@ def put(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()

class UpdateGroupProfilePictureView(UpdateAPIView):
parser_classes = (MultiPartParser,)

def get_object(self):
return get_object_or_404(
Group.objects.of(self.request.user),
public_id=self.kwargs['group_uid']
)

def put(self, request, *args, **kwargs):
if 'file' not in request.FILES:
raise ValidationError('No file provided')

file = request.FILES['file']
content_type = ContentType.objects.get_for_model(self.get_object())

media = Media.objects.create(
file=file,
content_type=content_type,
object_id=self.get_object().id,
uploaded_by=request.user
)

self.get_object().update_profile_picture(media)
return Response(status=status.HTTP_204_NO_CONTENT)
Empty file added splinter/apps/media/__init__.py
Empty file.
41 changes: 41 additions & 0 deletions splinter/apps/media/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 5.1.4 on 2024-12-31 11:30

import django.db.models.deletion
import splinter.apps.media.models
import splinter.db.models.fields
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='Media',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_id', splinter.db.models.fields.UniqueUUIDField(editable=False)),
('removed_at', models.DateTimeField(blank=True, db_index=True, editable=False, null=True)),
('file', models.FileField(upload_to=splinter.apps.media.models.get_upload_path, validators=[splinter.apps.media.models.validate_file_size, splinter.apps.media.models.validate_image_extension])),
('original_filename', models.CharField(max_length=255)),
('file_size', models.PositiveIntegerField(editable=False)),
('mime_type', models.CharField(editable=False, max_length=255)),
('object_id', models.BigIntegerField()),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'media',
'db_table': 'media',
'indexes': [models.Index(fields=['content_type', 'object_id'], name='media_content_a5c40c_idx')],
},
),
]
Empty file.
86 changes: 86 additions & 0 deletions splinter/apps/media/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import mimetypes
import uuid
from pathlib import Path

from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _

from splinter.db.models import PublicModel, SoftDeleteModel


def get_upload_path(instance, filename):
"""Generate a unique path for uploaded file."""
ext = Path(filename).suffix
return f'uploads/{instance.content_type.model}/{instance.public_id}{ext}'


def validate_file_size(value):
"""Validate file size is under max allowed size."""
if value.size > settings.MAX_UPLOAD_SIZE:
raise ValidationError(
_('File too large. Size should not exceed %(max_size)s MB.') % {'max_size': settings.MAX_UPLOAD_SIZE / (1024 * 1024)}
)


def validate_image_extension(value):
"""Validate file has an allowed image extension."""
ext = Path(value.name).suffix.lower()
if ext not in settings.ALLOWED_IMAGE_EXTENSIONS:
raise ValidationError(
_('Unsupported file extension. Allowed extensions are: %(valid_extensions)s') % {
'valid_extensions': ', '.join(settings.ALLOWED_IMAGE_EXTENSIONS)
}
)


class Media(PublicModel, SoftDeleteModel):
"""Model for handling uploaded media files with content type relations."""

file = models.FileField(
upload_to=get_upload_path,
validators=[validate_file_size, validate_image_extension]
)

# Original filename
original_filename = models.CharField(max_length=255)

# File metadata
file_size = models.PositiveIntegerField(editable=False)
mime_type = models.CharField(max_length=255, editable=False)

# Generic foreign key to associate media with any model
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.BigIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')

# When the media was uploaded
uploaded_at = models.DateTimeField(auto_now_add=True)
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)

class Meta:
db_table = 'media'
verbose_name_plural = 'media'
indexes = [
models.Index(fields=['content_type', 'object_id']),
]

def __str__(self):
return f"{self.original_filename} ({self.mime_type})"

def save(self, *args, **kwargs):
if not self.pk: # Only set these fields on creation
self.original_filename = self.file.name
self.file_size = self.file.size
self.mime_type = (
mimetypes.guess_type(self.file.name)[0]
or 'application/octet-stream'
)
super().save(*args, **kwargs)

@property
def url(self):
return self.file.url if self.file else None
42 changes: 42 additions & 0 deletions splinter/apps/media/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from rest_framework import serializers

from splinter.apps.media.models import Media
from splinter.core.prefetch import PrefetchQuerysetSerializerMixin


class MediaSerializer(PrefetchQuerysetSerializerMixin, serializers.ModelSerializer):
uid = serializers.UUIDField(source='public_id', read_only=True)
urn = serializers.CharField(read_only=True)
url = serializers.URLField(read_only=True)

class Meta:
model = Media
fields = ('uid', 'urn', 'url', 'original_filename', 'file_size', 'mime_type', 'uploaded_at')
read_only_fields = ('original_filename', 'file_size', 'mime_type', 'uploaded_at')


class MediaUploadSerializer(serializers.ModelSerializer):
file = serializers.FileField(write_only=True)

class Meta:
model = Media
fields = ('file',)

def create(self, validated_data):
request = self.context.get('request')
if not request or not request.user:
raise serializers.ValidationError("User must be authenticated")

# Get the content type and object ID from the URL
content_type = self.context.get('content_type')
object_id = self.context.get('object_id')

if not content_type or not object_id:
raise serializers.ValidationError("Content type and object ID are required")

return Media.objects.create(
file=validated_data['file'],
content_type=content_type,
object_id=object_id,
uploaded_by=request.user
)
8 changes: 8 additions & 0 deletions splinter/apps/media/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.urls import path

from splinter.apps.media import views

urlpatterns = [
path('<str:model_type>/<int:model_id>/upload', views.UploadMediaView.as_view()),
path('<uuid:media_uid>', views.DeleteMediaView.as_view()),
]
43 changes: 43 additions & 0 deletions splinter/apps/media/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from django.contrib.contenttypes.models import ContentType
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import MultiPartParser

from splinter.apps.media.models import Media
from splinter.apps.media.serializers import MediaSerializer, MediaUploadSerializer
from splinter.core.views import CreateAPIView, DestroyAPIView


class UploadMediaView(CreateAPIView):
serializer_class = MediaUploadSerializer
parser_classes = (MultiPartParser,)

def get_serializer_context(self):
context = super().get_serializer_context()

# Get model type and ID from URL parameters
model_type = self.kwargs.get('model_type')
model_id = self.kwargs.get('model_id')

try:
content_type = ContentType.objects.get(model=model_type)
except ContentType.DoesNotExist:
raise ValidationError(f"Invalid model type: {model_type}")

# Verify the object exists
try:
content_type.get_object_for_this_type(id=model_id)
except content_type.model_class().DoesNotExist:
raise ValidationError(f"No {model_type} found with ID {model_id}")

context['content_type'] = content_type
context['object_id'] = model_id
return context


class DeleteMediaView(DestroyAPIView):
queryset = Media.objects.all()
lookup_field = 'public_id'
lookup_url_kwarg = 'media_uid'

def get_queryset(self):
return super().get_queryset().filter(uploaded_by=self.request.user)
20 changes: 20 additions & 0 deletions splinter/apps/user/migrations/0003_user_profile_picture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2024-12-31 12:21

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('media', '0001_initial'),
('user', '0002_extension'),
]

operations = [
migrations.AddField(
model_name='user',
name='profile_picture',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_profile', to='media.media'),
),
]
Loading