[moods] Add moods

This commit is contained in:
2024-08-10 22:12:24 -04:00
parent b02b75fa90
commit f3fc58e2c0
16 changed files with 304 additions and 58 deletions

3
data/moods.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -12,8 +12,8 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BoardGameLogData
from scrobbles.mixins import ScrobblableMixin from scrobbles.mixins import ScrobblableMixin
from vrobbler.apps.boardgames.bgg import lookup_boardgame_id_from_bgg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -96,6 +96,10 @@ class BoardGame(ScrobblableMixin):
"boardgames:boardgame_detail", kwargs={"slug": self.uuid} "boardgames:boardgame_detail", kwargs={"slug": self.uuid}
) )
@property
def logdata_cls(self):
return BoardGameLogData
def primary_image_url(self) -> str: def primary_image_url(self) -> str:
url = "" url = ""
if self.cover: if self.cover:

View File

@ -2,7 +2,7 @@ from django.apps import apps
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
import pendulum import pendulum
from scrobbles.dataclasses import LifeEventMetadata from scrobbles.dataclasses import LifeEventLogData
from scrobbles.mixins import ScrobblableMixin from scrobbles.mixins import ScrobblableMixin
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -22,8 +22,8 @@ class LifeEvent(ScrobblableMixin):
) )
@property @property
def metadata_class(self): def logdata_cls(self):
return LifeEventMetadata return LifeEventLogData
@classmethod @classmethod
def find_or_create(cls, title: str) -> "LifeEvent": def find_or_create(cls, title: str) -> "LifeEvent":

View File

@ -0,0 +1,23 @@
from django.contrib import admin
from moods.models import Mood
from scrobbles.admin import ScrobbleInline
class MoodInline(admin.TabularInline):
model = Mood
extra = 0
@admin.register(Mood)
class MoodAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class MoodsConfig(AppConfig):
name = "moods"

View File

@ -0,0 +1,79 @@
# Generated by Django 4.2.13 on 2024-08-10 20:01
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
]
operations = [
migrations.CreateModel(
name="Mood",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
"title",
models.CharField(blank=True, max_length=255, null=True),
),
(
"run_time_seconds",
models.IntegerField(blank=True, null=True),
),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
("description", models.TextField(blank=True, null=True)),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,28 @@
import logging
from uuid import uuid4
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from scrobbles.mixins import ScrobblableMixin
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
User = get_user_model()
class Mood(ScrobblableMixin):
description = models.TextField(**BNULL)
def __str__(self):
if self.title:
return self.title
return str(self.uuid)
def get_absolute_url(self):
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
@property
def logdata_cls(self):
return MoodLogData

View File

@ -0,0 +1,14 @@
from django.urls import path
from moods import views
app_name = "moods"
urlpatterns = [
path("mood/", views.MoodListView.as_view(), name="mood-list"),
path(
"mood/<slug:slug>/",
views.MoodDetailView.as_view(),
name="mood-detail",
),
]

View File

@ -0,0 +1,12 @@
from django.db.models import Count
from django.views import generic
from moods.models import Mood
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class MoodListView(ScrobbleableListView):
model = Mood
class MoodDetailView(ScrobbleableDetailView):
model = Mood

View File

@ -1,3 +1,4 @@
from functools import cached_property
import inspect import inspect
import json import json
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
@ -10,17 +11,17 @@ from locations.models import GeoLocation
User = get_user_model() User = get_user_model()
class ScrobbleMetadataEncoder(json.JSONEncoder): class ScrobbleLogDataEncoder(json.JSONEncoder):
def default(self, o): def default(self, o):
return o.__dict__ return o.__dict__
class ScrobbleMetadataDecoder(json.JSONDecoder): class ScrobbleLogDataDecoder(json.JSONDecoder):
def default(self, o): def default(self, o):
return o.__dict__ return o.__dict__
class JSONMetadata(JSONWizard): class JSONDataclass(JSONWizard):
@property @property
def asdict(self): def asdict(self):
return asdict(self) return asdict(self)
@ -29,19 +30,9 @@ class JSONMetadata(JSONWizard):
def json(self): def json(self):
return json.dumps(self.asdict) return json.dumps(self.asdict)
@classmethod
def from_dict(cls, env):
return cls(
**{
k: v
for k, v in env.items()
if k in inspect.signature(cls).parameters
}
)
@dataclass @dataclass
class BoardGameScore(JSONMetadata): class BoardGameScoreLogData(JSONDataclass):
user_id: Optional[int] = None user_id: Optional[int] = None
name: Optional[str] = None name: Optional[str] = None
bgg_username: Optional[str] = None bgg_username: Optional[str] = None
@ -53,19 +44,22 @@ class BoardGameScore(JSONMetadata):
@dataclass @dataclass
class BoardGameMetadata(JSONMetadata): class BoardGameLogData(JSONDataclass):
players: Optional[list[BoardGameScore]] = None players: Optional[list[BoardGameScoreLogData]] = None
location: Optional[str] = None
geo_location_id: Optional[int] = None
difficulty: Optional[int] = None difficulty: Optional[int] = None
solo: Optional[bool] = None solo: Optional[bool] = None
two_handed: Optional[bool] = None two_handed: Optional[bool] = None
location: Optional[str] = None
def geo_location(self): @cached_property
return GeoLocation.objects.filter(id=self.geo_location_id).first() def geo_location(self) -> Optional[GeoLocation]:
if self.geo_location_id:
return GeoLocation.objects.filter(id=self.geo_location_id).first()
@dataclass @dataclass
class BookPageMetadata(JSONMetadata): class BookPageLogData(JSONDataclass):
page_number: Optional[int] = None page_number: Optional[int] = None
end_ts: Optional[int] = None end_ts: Optional[int] = None
start_ts: Optional[int] = None start_ts: Optional[int] = None
@ -73,14 +67,14 @@ class BookPageMetadata(JSONMetadata):
@dataclass @dataclass
class BookMetadata(JSONMetadata): class BookLogData(JSONDataclass):
koreader_hash: Optional[str] koreader_hash: Optional[str]
pages_read: Optional[int] pages_read: Optional[int]
page_data: Optional[list[BookPageMetadata]] page_data: Optional[list[BookPageLogData]]
@dataclass @dataclass
class LifeEventMetadata(JSONMetadata): class LifeEventLogData(JSONDataclass):
participant_user_ids: Optional[list[int]] = None participant_user_ids: Optional[list[int]] = None
participant_names: Optional[list[str]] = None participant_names: Optional[list[str]] = None
location: Optional[str] = None location: Optional[str] = None
@ -108,7 +102,12 @@ class LifeEventMetadata(JSONMetadata):
@dataclass @dataclass
class VideoMetadata(JSONMetadata): class MoodLogData(JSONDataclass):
reasons: Optional[str]
@dataclass
class VideoMetadata(JSONDataclass):
title: str title: str
video_type: str video_type: str
run_time_seconds: int run_time_seconds: int

View File

@ -0,0 +1,57 @@
# Generated by Django 4.2.13 on 2024-08-10 20:01
from django.db import migrations, models
import django.db.models.deletion
import scrobbles.dataclasses
class Migration(migrations.Migration):
dependencies = [
("moods", "0001_initial"),
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="mood",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="moods.mood",
),
),
migrations.AlterField(
model_name="scrobble",
name="log",
field=models.JSONField(
blank=True,
decoder=scrobbles.dataclasses.ScrobbleLogDataDecoder,
encoder=scrobbles.dataclasses.ScrobbleLogDataEncoder,
null=True,
),
),
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
],
default="Video",
max_length=14,
),
),
]

View File

@ -1,4 +1,3 @@
import pytz
import calendar import calendar
import datetime import datetime
import logging import logging
@ -7,6 +6,7 @@ from typing import Iterable, Optional
from uuid import uuid4 from uuid import uuid4
import pendulum import pendulum
import pytz
from boardgames.models import BoardGame from boardgames.models import BoardGame
from books.koreader import process_koreader_sqlite_file from books.koreader import process_koreader_sqlite_file
from books.models import Book from books.models import Book
@ -19,7 +19,9 @@ from django.utils.functional import cached_property
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from lifeevents.models import LifeEvent
from locations.models import GeoLocation from locations.models import GeoLocation
from moods.models import Mood
from music.lastfm import LastFM from music.lastfm import LastFM
from music.models import Artist, Track from music.models import Artist, Track
from podcasts.models import PodcastEpisode from podcasts.models import PodcastEpisode
@ -31,6 +33,7 @@ from profiles.utils import (
start_of_month, start_of_month,
start_of_week, start_of_week,
) )
from scrobbles import dataclasses as logdata
from scrobbles.constants import LONG_PLAY_MEDIA from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.stats import build_charts from scrobbles.stats import build_charts
from scrobbles.utils import media_class_to_foreign_key from scrobbles.utils import media_class_to_foreign_key
@ -38,17 +41,7 @@ from sports.models import SportEvent
from videogames import retroarch from videogames import retroarch
from videogames.models import VideoGame from videogames.models import VideoGame
from videos.models import Series, Video from videos.models import Series, Video
from scrobbles.dataclasses import (
BoardGameMetadata,
BookMetadata,
JSONMetadata,
LifeEventMetadata,
ScrobbleMetadataDecoder,
ScrobbleMetadataEncoder,
VideoMetadata,
)
from webpages.models import WebPage from webpages.models import WebPage
from lifeevents.models import LifeEvent
from vrobbler.apps.scrobbles.constants import MEDIA_END_PADDING_SECONDS from vrobbler.apps.scrobbles.constants import MEDIA_END_PADDING_SECONDS
@ -492,6 +485,7 @@ class Scrobble(TimeStampedModel):
GEO_LOCATION = "GeoLocation", "GeoLocation" GEO_LOCATION = "GeoLocation", "GeoLocation"
WEBPAGE = "WebPage", "Web Page" WEBPAGE = "WebPage", "Web Page"
LIFE_EVENT = "LifeEvent", "Life event" LIFE_EVENT = "LifeEvent", "Life event"
MOOD = "Mood", "Mood"
uuid = models.UUIDField(editable=False, **BNULL) uuid = models.UUIDField(editable=False, **BNULL)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL) video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
@ -516,6 +510,7 @@ class Scrobble(TimeStampedModel):
life_event = models.ForeignKey( life_event = models.ForeignKey(
LifeEvent, on_delete=models.DO_NOTHING, **BNULL LifeEvent, on_delete=models.DO_NOTHING, **BNULL
) )
mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
media_type = models.CharField( media_type = models.CharField(
max_length=14, choices=MediaType.choices, default=MediaType.VIDEO max_length=14, choices=MediaType.choices, default=MediaType.VIDEO
) )
@ -537,8 +532,8 @@ class Scrobble(TimeStampedModel):
source = models.CharField(max_length=255, **BNULL) source = models.CharField(max_length=255, **BNULL)
log = models.JSONField( log = models.JSONField(
**BNULL, **BNULL,
encoder=ScrobbleMetadataEncoder, encoder=logdata.ScrobbleLogDataEncoder,
decoder=ScrobbleMetadataDecoder, decoder=logdata.ScrobbleLogDataDecoder,
) )
timezone = models.CharField(max_length=50, **BNULL) timezone = models.CharField(max_length=50, **BNULL)
@ -582,7 +577,8 @@ class Scrobble(TimeStampedModel):
# Microseconds mess up Django's filtering, and we don't need be that specific # Microseconds mess up Django's filtering, and we don't need be that specific
if self.timestamp: if self.timestamp:
self.timestamp = self.timestamp.replace(microsecond=0) self.timestamp = self.timestamp.replace(microsecond=0)
self.media_type = self.MediaType(self.media_obj.__class__.__name__) if self.media_obj:
self.media_type = self.MediaType(self.media_obj.__class__.__name__)
return super(Scrobble, self).save(*args, **kwargs) return super(Scrobble, self).save(*args, **kwargs)
@ -608,25 +604,15 @@ class Scrobble(TimeStampedModel):
) )
@property @property
def metadata(self) -> Optional[JSONMetadata]: def logdata(self) -> dict:
metadata_cls = None if not self.media_obj.logdata_cls:
if self.media_type == self.MediaType.LIFE_EVENT:
metadata_cls = LifeEventMetadata
if self.media_type == self.MediaType.BOARD_GAME:
metadata_cls = BoardGameMetadata
if self.media_type == self.MediaType.VIDEO:
metadata_cls = VideoMetadata
if self.media_type == self.MediaType.BOOK:
metadata_cls = BookMetadata
if not metadata_cls:
logger.warn( logger.warn(
f"Media type has no metadata class", f"Media type has no log data class",
extra={"media_type": self.media_type, "scrobble_id": self.id}, extra={"media_type": self.media_type, "scrobble_id": self.id},
) )
return None return {}
return metadata_cls.from_dict(self.log) return self.media_obj.logdata_cls.from_dict(self.log)
def redirect_url(self, user_id) -> str: def redirect_url(self, user_id) -> str:
user = User.objects.filter(id=user_id).first() user = User.objects.filter(id=user_id).first()
@ -864,6 +850,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.web_page media_obj = self.web_page
if self.life_event: if self.life_event:
media_obj = self.life_event media_obj = self.life_event
if self.mood:
media_obj = self.mood
return media_obj return media_obj
def __str__(self): def __str__(self):

View File

@ -8,7 +8,7 @@ from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q from django.db.models import Count, Q
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import FileResponse, HttpResponseRedirect, JsonResponse from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -60,6 +60,37 @@ from scrobbles.utils import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ScrobbleableListView(ListView):
model = None
paginate_by = 20
def get_queryset(self):
queryset = super().get_queryset()
if not self.request.user.is_anonymous:
queryset = queryset.annotate(
scrobble_count=Count("scrobble"),
filter=Q(user=self.request.user),
).order_by("-scrobble_count")
else:
queryset = queryset.annotate(
scrobble_count=Count("scrobble")
).order_by("-scrobble_count")
class ScrobbleableDetailView(DetailView):
model = None
slug_field = "uuid"
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["scrobbles"] = list()
if not self.request.user.is_anonymous:
context_data["scrobbles"] = self.object.scrobbles(
self.request.user
)
return context_data
class RecentScrobbleList(ListView): class RecentScrobbleList(ListView):
model = Scrobble model = Scrobble

View File

@ -125,6 +125,7 @@ INSTALLED_APPS = [
"locations", "locations",
"webpages", "webpages",
"lifeevents", "lifeevents",
"moods",
"mathfilters", "mathfilters",
"rest_framework", "rest_framework",
"allauth", "allauth",

View File

@ -13,6 +13,7 @@ from vrobbler.apps.boardgames import urls as boardgame_urls
from vrobbler.apps.locations import urls as locations_urls from vrobbler.apps.locations import urls as locations_urls
from vrobbler.apps.lifeevents import urls as lifeevents_urls from vrobbler.apps.lifeevents import urls as lifeevents_urls
from vrobbler.apps.webpages import urls as webpages_urls from vrobbler.apps.webpages import urls as webpages_urls
from vrobbler.apps.moods import urls as moods_urls
from vrobbler.apps.music.api.views import ( from vrobbler.apps.music.api.views import (
AlbumViewSet, AlbumViewSet,
ArtistViewSet, ArtistViewSet,
@ -73,6 +74,7 @@ urlpatterns = [
path("", include(webpages_urls, namespace="webpages")), path("", include(webpages_urls, namespace="webpages")),
path("", include(podcast_urls, namespace="podcasts")), path("", include(podcast_urls, namespace="podcasts")),
path("", include(lifeevents_urls, namespace="life-events")), path("", include(lifeevents_urls, namespace="life-events")),
path("", include(moods_urls, namespace="moods")),
path("", include(scrobble_urls, namespace="scrobbles")), path("", include(scrobble_urls, namespace="scrobbles")),
path( path(
"", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home" "", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"