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 song memos #165

Merged
merged 4 commits into from
Dec 24, 2024
Merged
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
61 changes: 58 additions & 3 deletions api/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from django import forms
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html

from sing_along.utils.tabs import TabScraper

from .models import Like, Membership, Song, Songbook, SongEntry
from .models import Like, Membership, Song, Songbook, SongEntry, SongMemo


class SongForm(forms.ModelForm):
Expand All @@ -29,10 +31,17 @@ class LikeInline(admin.TabularInline):
extra = 1


class SongMemoInline(admin.TabularInline):
model = SongMemo
extra = 1 # Number of empty forms to display by default
fields = ["user", "text"]


class SongAdmin(admin.ModelAdmin):
form = SongForm
list_display = ("artist", "title", "url", "rating", "votes", "transpose")
search_fields = ("artist", "title", "url", "transpose")
inlines = [SongMemoInline] # Add the SongMemoInline here

def save_model(self, request, obj, form, change):
tab_url = form.cleaned_data.get("tab_url")
Expand All @@ -55,11 +64,25 @@ class SongbookAdmin(admin.ModelAdmin):
inlines = (MembershipInline,)


class SongbookFilter(admin.SimpleListFilter):
title = "Songbook"
parameter_name = "songbook"

def lookups(self, request, model_admin):
songbooks = Songbook.objects.all()
return [(sb.pk, sb.title) for sb in songbooks]

def queryset(self, request, queryset):
if self.value():
return queryset.filter(songbook_id=self.value())
return queryset


class SongEntryAdmin(admin.ModelAdmin):
list_filter = [
"is_flagged",
"songbook__theme",
("songbook__theme", admin.EmptyFieldListFilter),
SongbookFilter,
]
list_select_related = ("song", "songbook")
list_display = (
Expand Down Expand Up @@ -90,7 +113,9 @@ def get_songbook_title(self, obj):
description="Song Title",
)
def get_song_title(self, obj):
return obj.song.title
# Generate a link to the Song admin page
song_admin_url = reverse("admin:api_song_change", args=[obj.song.id])
return format_html('<a href="{}">{}</a>', song_admin_url, obj.song.title)

@admin.display(
ordering="song__artist",
Expand All @@ -106,6 +131,36 @@ def num_likes(self, obj):
num_likes.short_description = "Number of Likes"


class PendingTextFilter(admin.SimpleListFilter):
title = "Pending Memos" # Display name for the filter
parameter_name = "text" # URL parameter for the filter

def lookups(self, request, model_admin):
"""
Returns a list of tuples defining the filter options.
"""
return (
(
"pending",
"Pending",
), # The key will be used in the URL, and the value is displayed
)

def queryset(self, request, queryset):
"""
Filters the queryset based on the selected option.
"""
if self.value() == "pending":
return queryset.filter(text="pending")
return queryset


class SongMemoAdmin(admin.ModelAdmin):
list_filter = (PendingTextFilter, "user")
list_display = ["user", "song", "text"]


admin.site.register(Songbook, SongbookAdmin)
admin.site.register(Song, SongAdmin)
admin.site.register(SongEntry, SongEntryAdmin)
admin.site.register(SongMemo, SongMemoAdmin)
57 changes: 57 additions & 0 deletions api/migrations/0032_add_songbook_memo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Generated by Django 3.2.20 on 2024-12-23 16:23

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


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("api", "0031_songbook_action_verb"),
]

operations = [
migrations.CreateModel(
name="SongMemo",
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)),
("text", models.TextField(blank=True, null=True)),
(
"song",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="song_memos",
related_query_name="memo",
to="api.song",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="song_memos",
related_query_name="memo",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="songmemo",
constraint=models.UniqueConstraint(
fields=("user", "song"), name="unique song memo"
),
),
]
31 changes: 31 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ class Meta:
transpose = models.IntegerField(null=True, blank=True, db_column="capo")
spotify_ID = models.CharField(max_length=120, null=True, blank=True)

def __str__(self):
return f"{self.title} - {self.artist}"


class SongEntry(SafeDeleteModel, CreatedUpdated):
class Meta:
Expand Down Expand Up @@ -242,3 +245,31 @@ class Meta:
related_name="song_entry_likes",
related_query_name="like",
)


class SongMemo(CreatedUpdated):
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "song"],
name="unique song memo",
)
]

text = models.TextField(null=True, blank=True)

song = models.ForeignKey(
Song,
on_delete=models.CASCADE,
related_name="song_memos",
related_query_name="memo",
)
user = models.ForeignKey(
get_user_model(),
on_delete=models.CASCADE,
related_name="song_memos",
related_query_name="memo",
)

def __str__(self):
return f"Memo by {self.user.username} for {self.song}"
33 changes: 32 additions & 1 deletion api/serializers/song.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,29 @@
from rest_framework import serializers
from spotipy.oauth2 import SpotifyClientCredentials

from api.models import Song
from api.models import Song, SongMemo


class SongMemoSerializer(serializers.ModelSerializer):
class Meta:
model = SongMemo
fields = [
"id",
"created_at",
"updated_at",
"text",
"song",
"user",
]
extra_kwargs = {
"user": {"read_only": True},
}


class SongSerializer(serializers.ModelSerializer):
spotify_ID = serializers.SerializerMethodField()
song_entry_count = serializers.SerializerMethodField()
song_memo = serializers.SerializerMethodField()

class Meta:
model = Song
Expand All @@ -26,8 +43,22 @@ class Meta:
"votes",
"transpose",
"song_entry_count",
"song_memo",
]

def get_song_memo(self, obj):
request = self.context.get("request")
include_song_memos = self.context.get("include_song_memos", False)

# Only include song memos if the flag is set and the request exists
if not include_song_memos or request is None:
return None

memos = obj.song_memos.all() # Access the prefetch directly
if memos:
memo = memos[0] # Assuming you only care about the first memo
return SongMemoSerializer(memo).data

def get_song_entry_count(self, obj):
try:
return obj.song_entry_count
Expand Down
6 changes: 6 additions & 0 deletions api/serializers/songbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def get_is_songbook_owner(self, obj):

class SongbookDetailSerializer(serializers.ModelSerializer):
song_entries = SongEntrySerializer(many=True)
current_song_position = serializers.SerializerMethodField()

class Meta:
model = Songbook
Expand All @@ -167,6 +168,11 @@ class Meta:
"is_noodle_mode",
"song_entries",
"current_song_timestamp",
"current_song_position",
"created_at",
]

extra_kwargs = {"session_key": {"read_only": True}}

def get_current_song_position(self, obj):
return obj.get_current_song_position()
2 changes: 1 addition & 1 deletion api/tests/views/test_songbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ def test_songbook_details_as_owner(self):
self.client.force_authenticate(user=self.user)

# Act
with self.assertNumQueries(7):
with self.assertNumQueries(8):
response = self.client.get(
reverse(
"songbook-details",
Expand Down
23 changes: 23 additions & 0 deletions api/views/song.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,26 @@ def search(self, request):
status=status.HTTP_200_OK,
data=SongSerializer(song_matches, many=True).data,
)

@action(
methods=["patch"],
detail=True,
url_path="toggle-pending-memo",
url_name="toggle-pending-memo",
)
def toggle_songbook_memo(self, request, pk):
instance = self.get_object()

memo = instance.song_memos.filter(user=request.user).first()
if memo is not None and memo.text != "pending":
return Response(status=status.HTTP_409_CONFLICT)

if instance.song_memos.filter(user=request.user).exists():
instance.song_memos.filter(user=request.user).delete()
else:
instance.song_memos.create(user=request.user, text="pending")

return Response(
status=status.HTTP_200_OK,
data=SongSerializer(instance, context={"request": request}).data,
)
31 changes: 23 additions & 8 deletions api/views/songbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,18 @@ def get_queryset(self):
queryset = self.queryset.filter(members__id=self.request.user.id)

if self.action == "songbook_details":
return queryset.prefetch_related(
Prefetch(
"song_entries",
queryset=SongEntry.objects.order_by("created_at").prefetch_related(
"song"
),
return (
queryset.prefetch_related(
Prefetch(
"song_entries",
queryset=SongEntry.objects.order_by(
"created_at"
).prefetch_related("song"),
)
)
).all()
.prefetch_related("song_entries__song__song_memos")
.all()
)

queryset = queryset.prefetch_related("song_entries").prefetch_related(
"membership_set__user__social_auth"
Expand All @@ -86,6 +90,15 @@ def get_serializer_class(self):
return SongbookSerializer
return SongbookListSerializer

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

# Check if the request path contains "details"
request = self.request
context["include_song_memos"] = "details" in request.get_full_path()

return context

def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
self._check_and_add_membership(instance, request.user)
Expand Down Expand Up @@ -138,7 +151,9 @@ def songbook_details(self, request, session_key=None):

return Response(
status=status.HTTP_200_OK,
data=SongbookDetailSerializer(instance, context={"request": request}).data,
data=SongbookDetailSerializer(
instance, context=self.get_serializer_context()
).data,
)

@action(
Expand Down
Binary file added frontend/.yarn/install-state.gz
Binary file not shown.
1 change: 1 addition & 0 deletions frontend/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
6 changes: 3 additions & 3 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@
},
"proxy": "http://localhost:8080/",
"resolutions": {
"jest": "29.2.x",
"react-scripts/@svgr/webpack/@svgr/plugin-svgo/svgo/css-select/nth-check": "^2.0.1"
"jest": "29.2.x"
},
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@types/react-cookies": "^0.1.0",
"jest-environment-jsdom": "^29.3.1",
"prettier": "2.8.1"
}
},
"packageManager": "yarn@4.5.1+sha512.341db9396b6e289fecc30cd7ab3af65060e05ebff4b3b47547b278b9e67b08f485ecd8c79006b405446262142c7a38154445ef7f17c1d5d1de7d90bf9ce7054d"
}
Loading
Loading