Browse Source

[scrobbles] Fix dataclass parsing and add puzzles to urls

Colin Powell 1 month ago
parent
commit
d04db0ecb5

+ 9 - 2
vrobbler/apps/beers/models.py

@@ -1,18 +1,25 @@
+from dataclasses import dataclass
+from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
 
 
-from beers.untappd import get_beer_from_untappd_id, get_rating_from_soup
+from beers.untappd import get_beer_from_untappd_id
 from django.apps import apps
 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
 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 BeerLogData
+from scrobbles.dataclasses import BaseLogData
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 
 
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 
 
 
 
+@dataclass
+class BeerLogData(BaseLogData):
+    rating: Optional[str] = None
+
+
 class BeerStyle(TimeStampedModel):
 class BeerStyle(TimeStampedModel):
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)

+ 77 - 2
vrobbler/apps/boardgames/models.py

@@ -1,4 +1,6 @@
+from functools import cached_property
 import logging
 import logging
+from dataclasses import dataclass
 from datetime import datetime
 from datetime import datetime
 from typing import Optional
 from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
@@ -12,14 +14,87 @@ 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 ScrobblableConstants, ScrobblableMixin
 from locations.models import GeoLocation
 from locations.models import GeoLocation
+from people.models import Person
+from scrobbles.dataclasses import BaseLogData, LongPlayLogData
+from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 
 
 
 
+@dataclass
+class BoardGameScoreLogData(BaseLogData):
+    person_id: Optional[int] = None
+    bgg_username: Optional[str] = None
+    color: Optional[str] = None
+    character: Optional[str] = None
+    team: Optional[str] = None
+    score: Optional[int] = None
+    win: Optional[bool] = None
+    new: Optional[bool] = None
+    rank: Optional[int] = None
+    seat_order: Optional[int] = None
+    role: Optional[str] = None
+    rank: Optional[int] = None
+    seat_order: Optional[int] = None
+    role: Optional[str] = None
+    lichess_username: Optional[str] = None
+
+    @property
+    def person(self) -> Optional[Person]:
+        return Person.objects.filter(id=self.person_id).first()
+
+    @property
+    def name(self) -> str:
+        name = ""
+        if self.person:
+            name = self.person.name
+        return name
+
+    def __str__(self) -> str:
+        out = self.name
+        if self.score:
+            out += f" {self.score}"
+        if self.color:
+            out += f" ({self.color})"
+        if self.win:
+            out += f" [W]"
+        return out
+
+
+@dataclass
+class BoardGameLogData(BaseLogData, LongPlayLogData):
+    players: Optional[list[BoardGameScoreLogData]] = None
+    location_id: Optional[int] = None
+    difficulty: Optional[int] = None
+    solo: Optional[bool] = None
+    two_handed: Optional[bool] = None
+    expansion_ids: Optional[int] = None
+    moves: Optional[list] = None
+    rated: Optional[str] = None
+    speed: Optional[str] = None
+    variant: Optional[str] = None
+    lichess_id: Optional[int] = None
+    board: Optional[str] = None
+    rounds: Optional[int] = None
+    details: Optional[str] = None
+    # Legacy
+    learning: Optional[bool] = None
+    scenario: Optional[str] = None
+
+    @cached_property
+    def player_log(self) -> str:
+        if self.players:
+            return ", ".join(
+                [
+                    BoardGameScoreLogData(**player).__str__()
+                    for player in self.players
+                ]
+            )
+        return ""
+
+
 class BoardGamePublisher(TimeStampedModel):
 class BoardGamePublisher(TimeStampedModel):
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)

+ 32 - 6
vrobbler/apps/books/models.py

@@ -1,6 +1,8 @@
 from collections import OrderedDict
 from collections import OrderedDict
+from dataclasses import dataclass
 import logging
 import logging
 from datetime import timedelta, datetime
 from datetime import timedelta, datetime
+from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
 
 
 import requests
 import requests
@@ -35,9 +37,9 @@ from vrobbler.apps.books.locg import (
     lookup_comic_from_locg,
     lookup_comic_from_locg,
     lookup_comic_writer_by_locg_slug,
     lookup_comic_writer_by_locg_slug,
 )
 )
-from vrobbler.apps.books.sources.google import lookup_book_from_google
-from vrobbler.apps.books.sources.semantic import lookup_paper_from_semantic
-from vrobbler.apps.scrobbles.dataclasses import BookLogData
+from books.sources.google import lookup_book_from_google
+from books.sources.semantic import lookup_paper_from_semantic
+from scrobbles.dataclasses import BaseLogData, LongPlayLogData
 
 
 COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
 COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
 
 
@@ -46,6 +48,23 @@ User = get_user_model()
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 
 
 
 
+@dataclass
+class BookPageLogData(BaseLogData):
+    page_number: Optional[int] = None
+    end_ts: Optional[int] = None
+    start_ts: Optional[int] = None
+    duration: Optional[int] = None
+
+
+@dataclass
+class BookLogData(BaseLogData, LongPlayLogData):
+    koreader_hash: Optional[str] = None
+    page_data: Optional[dict[int, BookPageLogData]] = None
+    pages_read: Optional[int] = None
+    page_start: Optional[int] = None
+    page_end: Optional[int] = None
+
+
 class Author(TimeStampedModel):
 class Author(TimeStampedModel):
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
@@ -161,7 +180,9 @@ class Book(LongPlayScrobblableMixin):
         return reverse("books:book_detail", kwargs={"slug": self.uuid})
         return reverse("books:book_detail", kwargs={"slug": self.uuid})
 
 
     @classmethod
     @classmethod
-    def find_or_create(cls, title: str, enrich: bool = False, commit: bool = True):
+    def find_or_create(
+        cls, title: str, enrich: bool = False, commit: bool = True
+    ):
         """Given a title, get a Book instance.
         """Given a title, get a Book instance.
 
 
         If the book is not already in our database, or overwrite is True,
         If the book is not already in our database, or overwrite is True,
@@ -173,10 +194,15 @@ class Book(LongPlayScrobblableMixin):
         # TODO use either a Google Books id identifier or author name like for tracks
         # TODO use either a Google Books id identifier or author name like for tracks
         book, created = cls.objects.get_or_create(title=title)
         book, created = cls.objects.get_or_create(title=title)
         if not created:
         if not created:
-            logger.info("Found exact match for book by title", extra={"title": title})
+            logger.info(
+                "Found exact match for book by title", extra={"title": title}
+            )
 
 
         if not enrich:
         if not enrich:
-            logger.info("Found book by title, but not enriching", extra={"title": title})
+            logger.info(
+                "Found book by title, but not enriching",
+                extra={"title": title},
+            )
             return book
             return book
 
 
         book_dict = lookup_book_from_google(title)
         book_dict = lookup_book_from_google(title)

+ 11 - 3
vrobbler/apps/bricksets/models.py

@@ -1,15 +1,23 @@
-from django.apps import apps
+from dataclasses import dataclass
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-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 BrickSetLogData
 from scrobbles.mixins import LongPlayScrobblableMixin
 from scrobbles.mixins import LongPlayScrobblableMixin
+from vrobbler.apps.scrobbles.dataclasses import (
+    BaseLogData,
+    LongPlayLogData,
+    WithPeopleLogData,
+)
 
 
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 
 
 
 
+@dataclass
+class BrickSetLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
+    pass
+
+
 class BrickSet(LongPlayScrobblableMixin):
 class BrickSet(LongPlayScrobblableMixin):
     """"""
     """"""
 
 

+ 9 - 1
vrobbler/apps/foods/models.py

@@ -1,3 +1,5 @@
+from dataclasses import dataclass
+from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
 
 
 from django.apps import apps
 from django.apps import apps
@@ -6,12 +8,18 @@ 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 FoodLogData
+from scrobbles.dataclasses import BaseLogData
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 
 
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 
 
 
 
+@dataclass
+class FoodLogData(BaseLogData):
+    meal: Optional[str] = None
+    rating: Optional[str] = None
+
+
 class FoodCategory(TimeStampedModel):
 class FoodCategory(TimeStampedModel):
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)

+ 8 - 1
vrobbler/apps/lifeevents/models.py

@@ -1,12 +1,19 @@
+from dataclasses import dataclass
+from typing import Optional
 from django.apps import apps
 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
-from scrobbles.dataclasses import LifeEventLogData
+from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 
 
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 
 
 
 
+@dataclass
+class LifeEventLogData(BaseLogData, WithPeopleLogData):
+    pass
+
+
 class LifeEvent(ScrobblableMixin):
 class LifeEvent(ScrobblableMixin):
     description = models.TextField(**BNULL)
     description = models.TextField(**BNULL)
 
 

+ 8 - 2
vrobbler/apps/moods/models.py

@@ -1,19 +1,25 @@
 import logging
 import logging
+from dataclasses import dataclass
+from typing import Optional
 
 
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from imagekit.models import ImageSpecField
 from imagekit.models import ImageSpecField
 from imagekit.processors import ResizeToFit
 from imagekit.processors import ResizeToFit
+from scrobbles.dataclasses import BaseLogData
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 
 
-from vrobbler.apps.scrobbles.dataclasses import MoodLogData
-
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 User = get_user_model()
 User = get_user_model()
 
 
 
 
+@dataclass
+class MoodLogData(BaseLogData):
+    reasons: Optional[str] = None
+
+
 class Mood(ScrobblableMixin):
 class Mood(ScrobblableMixin):
     description = models.TextField(**BNULL)
     description = models.TextField(**BNULL)
     image = models.ImageField(upload_to="moods/", **BNULL)
     image = models.ImageField(upload_to="moods/", **BNULL)

+ 14 - 1
vrobbler/apps/puzzles/models.py

@@ -1,3 +1,5 @@
+from dataclasses import dataclass
+from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
 
 
 import requests
 import requests
@@ -9,12 +11,19 @@ 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 puzzles.sources import ipdb
 from puzzles.sources import ipdb
-from scrobbles.dataclasses import PuzzleLogData
+from scrobbles.dataclasses import JSONDataclass
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 
 
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 
 
 
 
+@dataclass
+class PuzzleLogData(JSONDataclass):
+    with_people: Optional[int] = None
+    rating: Optional[str] = None
+    notes: Optional[str] = None
+
+
 class PuzzleManufacturer(TimeStampedModel):
 class PuzzleManufacturer(TimeStampedModel):
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
@@ -58,6 +67,10 @@ class Puzzle(ScrobblableMixin):
     def __str__(self):
     def __str__(self):
         return f"{self.title} ({self.pieces_count}) by {self.manufacturer}"
         return f"{self.title} ({self.pieces_count}) by {self.manufacturer}"
 
 
+    @property
+    def logdata_cls(self):
+        return PuzzleLogData
+
     @property
     @property
     def subtitle(self):
     def subtitle(self):
         return self.manufacturer.name
         return self.manufacturer.name

+ 12 - 203
vrobbler/apps/scrobbles/dataclasses.py

@@ -1,5 +1,3 @@
-from functools import cached_property
-import inspect
 import json
 import json
 from dataclasses import asdict, dataclass
 from dataclasses import asdict, dataclass
 from typing import Optional
 from typing import Optional
@@ -33,214 +31,25 @@ class JSONDataclass(JSONWizard):
 
 
 
 
 @dataclass
 @dataclass
-class ScrobbleLogData(JSONDataclass):
-    description: Optional[str] = None
-
-
-class LongPlayLogData(JSONDataclass):
-    serial_scrobble_id: Optional[int]
-    long_play_complete: bool = False
-
-
-class WithOthersLogData(JSONDataclass):
-    with_user_ids: Optional[list[int]] = None
-    with_names_str: Optional[list[str]] = None
-
-    @property
-    def with_names(self) -> list[str]:
-        with_names = []
-        if self.with_user_ids:
-            with_names += [u.full_name for u in self.with_users if u]
-        if self.with_names_str:
-            with_names += [u for u in self.with_names_str]
-        return with_names
-
-    @property
-    def with_users(self) -> list[User]:
-        with_users = []
-        if self.with_user_ids:
-            with_users = [
-                User.objects.filter(id=i).first() for i in self.with_user_ids
-            ]
-        return with_users
-
-
-@dataclass
-class BoardGameScoreLogData(JSONDataclass):
-    person_id: Optional[int] = None
-    bgg_username: str = ""
-    color: Optional[str] = None
-    character: Optional[str] = None
-    team: Optional[str] = None
-    score: Optional[int] = None
-    win: Optional[bool] = None
-    new: Optional[bool] = None
-    rank: Optional[int] = None
-    seat_order: Optional[int] = None
-    role: Optional[str] = None
-    rank: Optional[int] = None
-    seat_order: Optional[int] = None
-    role: Optional[str] = None
-    lichess_username: Optional[str] = None
-    # Legacy
-    user_id: Optional[int] = None
-    name_str: Optional[str] = None
-
-
-    @property
-    def person(self) -> Optional[Person]:
-        return Person.objects.filter(id=self.person_id).first()
-
-    @property
-    def name(self) -> str:
-        name = ""
-        if self.person:
-            name = self.person.name
-        return name
-
-    def __str__(self) -> str:
-        out = self.name
-        if self.score:
-            out += f" {self.score}"
-        if self.color:
-            out += f" ({self.color})"
-        if self.win:
-            out += f" [W]"
-        return out
-
-
-@dataclass
-class BoardGameLogData(LongPlayLogData):
-    serial_scrobble_id: Optional[int] = None
-    long_play_complete: Optional[bool] = None
-    players: Optional[list[BoardGameScoreLogData]] = None
-    location_id: Optional[int] = None
-    difficulty: Optional[int] = None
-    solo: Optional[bool] = None
-    two_handed: Optional[bool] = None
-    expansion_ids: Optional[int] = None
-    moves: Optional[list] = None
-    rated: Optional[str] = None
-    speed: Optional[str] = None
-    variant: Optional[str] = None
-    lichess_id: Optional[int] = None
-    board: Optional[str] = None
-    rounds: Optional[int] = None
-    details: Optional[str] = None
-    # Legacy
-    learning: Optional[bool] = None
-    location: Optional[str] = None
-    geo_location_id: Optional[int] = None
-    scenario: Optional[str] = None
-    @cached_property
-    def geo_location(self) -> Optional[GeoLocation]:
-        if self.geo_location_id:
-            return GeoLocation.objects.filter(id=self.geo_location_id).first()
-
-    @cached_property
-    def player_log(self) -> str:
-        if self.players:
-            return ", ".join([BoardGameScoreLogData(**player).__str__() for player in self.players])
-        return ""
-
-@dataclass
-class BookPageLogData(JSONDataclass):
-    page_number: Optional[int] = None
-    end_ts: Optional[int] = None
-    start_ts: Optional[int] = None
-    duration: Optional[int] = None
-
-
-@dataclass
-class BookLogData(LongPlayLogData):
-    long_play_complete: Optional[bool] = None
-    koreader_hash: Optional[str] = None
-    page_data: Optional[dict[int, BookPageLogData]] = None
-    pages_read: Optional[int] = None
-    page_start: Optional[int] = None
-    page_end: Optional[int] = None
-    serial_scrobble_id: Optional[int] = None
-    details: Optional[str] = None
-
-
-@dataclass
-class LifeEventLogData(WithOthersLogData):
-    with_user_ids: Optional[list[int]] = None
-    with_names_str: Optional[list[str]] = None
-    location: Optional[str] = None
-    geo_location_id: Optional[int] = None
+class BaseLogData(JSONDataclass):
     details: Optional[str] = None
     details: Optional[str] = None
-
-    def geo_location(self):
-        return GeoLocation.objects.filter(id=self.geo_location_id).first()
-
-
-@dataclass
-class MoodLogData(JSONDataclass):
-    reasons: Optional[str] = None
-
-
-@dataclass
-class VideoLogData(JSONDataclass):
-    title: str
-    video_type: str
-    run_time_seconds: int
-    kind: str
-    year: Optional[int]
-    episode_number: Optional[int] = None
-    source_url: Optional[str] = None
-    imdbID: Optional[str] = None
-    season_number: Optional[int] = None
-    cover_url: Optional[str] = None
-    next_imdb_id: Optional[int] = None
-    tv_series_id: Optional[str] = None
+    notes: Optional[str] = None
 
 
 
 
 @dataclass
 @dataclass
-class VideoGameLogData(LongPlayLogData):
+class LongPlayLogData(JSONDataclass):
+    complete: Optional[bool] = None
     serial_scrobble_id: Optional[int] = None
     serial_scrobble_id: Optional[int] = None
-    long_play_complete: Optional[bool] = False
-    console: Optional[str] = None
-    emulated: Optional[bool] = False
-    emulator: Optional[str] = None
 
 
 
 
 @dataclass
 @dataclass
-class BrickSetLogData(LongPlayLogData, WithOthersLogData):
-    serial_scrobble_id: Optional[int]
-    long_play_complete: bool = False
-    with_user_ids: Optional[list[int]] = None
-    with_names_str: Optional[list[str]] = None
-
-
-@dataclass
-class TrailLogData(WithOthersLogData):
-    with_user_ids: Optional[list[int]] = None
-    with_names_str: Optional[list[str]] = None
-    details: Optional[str] = None
-    effort: Optional[str] = None
-    difficulty: Optional[str] = None
-
-
-@dataclass
-class BeerLogData(WithOthersLogData):
-    with_user_ids: Optional[list[int]] = None
-    with_names_str: Optional[list[str]] = None
-    details: Optional[str] = None
-    rating: Optional[str] = None
-    notes: Optional[str] = None
-
-
-@dataclass
-class FoodLogData(JSONDataclass):
-    meal: Optional[str] = None
-    details: Optional[str] = None
-    rating: Optional[str] = None
-    notes: Optional[str] = None
+class WithPeopleLogData(JSONDataclass):
+    with_people_ids: Optional[list[int]] = None
 
 
+    @property
+    def with_people(self) -> list["Person"]:
+        from people.models import Person
 
 
-@dataclass
-class PuzzleLogData(JSONDataclass):
-    with_others: Optional[str] = None
-    rating: Optional[str] = None
-    notes: Optional[str] = None
+        if not self.with_people_ids:
+            return []
+        return [Person.objects.filter(id=pid) for pid in self.with_people_ids]

+ 11 - 3
vrobbler/apps/scrobbles/models.py

@@ -14,6 +14,7 @@ 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, Paper
 from books.models import Book, Paper
 from bricksets.models import BrickSet
 from bricksets.models import BrickSet
+from dataclass_wizard.errors import ParseError
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core.files import File
 from django.core.files import File
@@ -718,11 +719,11 @@ class Scrobble(TimeStampedModel):
                 )
                 )
 
 
     @property
     @property
-    def logdata(self) -> Optional[logdata.JSONDataclass]:
+    def logdata(self) -> Optional[logdata.BaseLogData]:
         if self.media_obj:
         if self.media_obj:
             logdata_cls = self.media_obj.logdata_cls
             logdata_cls = self.media_obj.logdata_cls
         else:
         else:
-            logdata_cls = logdata.ScrobbleLogData
+            logdata_cls = logdata.BaseLogData
 
 
         log_dict = self.log
         log_dict = self.log
         if isinstance(self.log, str):
         if isinstance(self.log, str):
@@ -736,7 +737,14 @@ class Scrobble(TimeStampedModel):
         if not log_dict:
         if not log_dict:
             log_dict = {}
             log_dict = {}
 
 
-        return logdata_cls(**log_dict)
+        try:
+            return logdata_cls.from_dict(log_dict)
+        except ParseError:
+            logger.warning(
+                "Could not parse log data",
+                extra={"log_dict": log_dict, "scrobble_id": self.id},
+            )
+            return logdata_cls()
 
 
     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()

+ 1 - 1
vrobbler/apps/tasks/models.py

@@ -34,7 +34,7 @@ class TaskLogData(JSONDataclass):
     source: Optional[str] = None
     source: Optional[str] = None
     source_id: Optional[str] = None
     source_id: Optional[str] = None
     timestamps: Optional[list] = None
     timestamps: Optional[list] = None
-
+    details: Optional[str] = None
 
 
     def notes_as_str(self) -> str:
     def notes_as_str(self) -> str:
         """Return formatted notes with line breaks and no keys"""
         """Return formatted notes with line breaks and no keys"""

+ 12 - 3
vrobbler/apps/trails/models.py

@@ -1,14 +1,23 @@
-from django.utils.translation import gettext_lazy as _
+from dataclasses import dataclass
+from typing import Optional
+
 from django.apps import apps
 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
-from scrobbles.dataclasses import TrailLogData
-from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
+from django.utils.translation import gettext_lazy as _
 from locations.models import GeoLocation
 from locations.models import GeoLocation
+from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
+from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
 
 
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 
 
 
 
+@dataclass
+class TrailLogData(BaseLogData, WithPeopleLogData):
+    effort: Optional[str] = None
+    difficulty: Optional[str] = None
+
+
 class Trail(ScrobblableMixin):
 class Trail(ScrobblableMixin):
     class PrincipalType(models.TextChoices):
     class PrincipalType(models.TextChoices):
         WOODS = "WOODS"
         WOODS = "WOODS"

+ 19 - 1
vrobbler/apps/videogames/models.py

@@ -1,4 +1,6 @@
+from dataclasses import dataclass
 import logging
 import logging
+from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
 
 
 from django.conf import settings
 from django.conf import settings
@@ -8,7 +10,11 @@ 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 VideoGameLogData
+from scrobbles.dataclasses import (
+    BaseLogData,
+    LongPlayLogData,
+    WithPeopleLogData,
+)
 from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableConstants
 from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableConstants
 from scrobbles.utils import get_scrobbles_for_media
 from scrobbles.utils import get_scrobbles_for_media
 from videogames.igdb import lookup_game_id_from_gdb
 from videogames.igdb import lookup_game_id_from_gdb
@@ -18,6 +24,18 @@ BNULL = {"blank": True, "null": True}
 User = get_user_model()
 User = get_user_model()
 
 
 
 
+@dataclass
+class VideoGameLogData(BaseLogData, LongPlayLogData, WithPeopleLogData):
+    platform_id: Optional[int] = None
+    emulated: Optional[bool] = False
+    emulator: Optional[str] = None
+
+    def platform(self):
+        if not self.platform_id:
+            return
+        return VideoGamePlatform.objects.filter(id=self.platform_id).first()
+
+
 class VideoGamePlatform(TimeStampedModel):
 class VideoGamePlatform(TimeStampedModel):
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)

+ 11 - 0
vrobbler/apps/videos/models.py

@@ -1,3 +1,4 @@
+from dataclasses import dataclass
 import logging
 import logging
 from typing import Optional
 from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
@@ -22,6 +23,7 @@ from videos.metadata import VideoMetadata
 from videos.sources.imdb import lookup_video_from_imdb
 from videos.sources.imdb import lookup_video_from_imdb
 from videos.sources.tmdb import lookup_video_from_tmdb
 from videos.sources.tmdb import lookup_video_from_tmdb
 from videos.sources.youtube import lookup_video_from_youtube
 from videos.sources.youtube import lookup_video_from_youtube
+from vrobbler.apps.scrobbles.dataclasses import BaseLogData, WithPeopleLogData
 
 
 YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
 YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
 YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/"
 YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/"
@@ -31,6 +33,11 @@ logger = logging.getLogger(__name__)
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 
 
 
 
+@dataclass
+class VideoLogData(BaseLogData, WithPeopleLogData):
+    rating: Optional[int] = None
+
+
 class Channel(TimeStampedModel):
 class Channel(TimeStampedModel):
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
@@ -243,6 +250,10 @@ class Video(ScrobblableMixin):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse("videos:video_detail", kwargs={"slug": self.uuid})
         return reverse("videos:video_detail", kwargs={"slug": self.uuid})
 
 
+    @property
+    def logdata_cls(self):
+        return VideoLogData
+
     @property
     @property
     def subtitle(self):
     def subtitle(self):
         if self.tv_series:
         if self.tv_series:

+ 5 - 2
vrobbler/urls.py

@@ -40,11 +40,13 @@ from vrobbler.apps.profiles import urls as profiles_urls
 from vrobbler.apps.trails import urls as trails_urls
 from vrobbler.apps.trails import urls as trails_urls
 from vrobbler.apps.beers import urls as beers_urls
 from vrobbler.apps.beers import urls as beers_urls
 from vrobbler.apps.foods import urls as foods_urls
 from vrobbler.apps.foods import urls as foods_urls
+from vrobbler.apps.puzzles import urls as puzzles_urls
 from vrobbler.apps.videogames import urls as videogame_urls
 from vrobbler.apps.videogames import urls as videogame_urls
 from vrobbler.apps.videos import urls as video_urls
 from vrobbler.apps.videos import urls as video_urls
 from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet
 from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet
 from vrobbler.apps.webpages import urls as webpages_urls
 from vrobbler.apps.webpages import urls as webpages_urls
-#from vrobbler.apps.modern_ui import urls as modern_ui_urls
+
+# from vrobbler.apps.modern_ui import urls as modern_ui_urls
 
 
 router = routers.DefaultRouter()
 router = routers.DefaultRouter()
 router.register(r"scrobbles", ScrobbleViewSet)
 router.register(r"scrobbles", ScrobbleViewSet)
@@ -73,7 +75,7 @@ urlpatterns = [
     path("admin/", admin.site.urls),
     path("admin/", admin.site.urls),
     path("accounts/", include("allauth.urls")),
     path("accounts/", include("allauth.urls")),
     path("o/", include(oauth2_urls)),
     path("o/", include(oauth2_urls)),
-    #path("modern_ui/", include(modern_ui_urls, namespace="modern_ui")),
+    # path("modern_ui/", include(modern_ui_urls, namespace="modern_ui")),
     path("", include(music_urls, namespace="music")),
     path("", include(music_urls, namespace="music")),
     path("", include(book_urls, namespace="books")),
     path("", include(book_urls, namespace="books")),
     path("", include(video_urls, namespace="videos")),
     path("", include(video_urls, namespace="videos")),
@@ -85,6 +87,7 @@ urlpatterns = [
     path("", include(trails_urls, namespace="trails")),
     path("", include(trails_urls, namespace="trails")),
     path("", include(beers_urls, namespace="beers")),
     path("", include(beers_urls, namespace="beers")),
     path("", include(foods_urls, namespace="foods")),
     path("", include(foods_urls, namespace="foods")),
+    path("", include(puzzles_urls, namespace="puzzles")),
     path("", include(tasks_urls, namespace="tasks")),
     path("", include(tasks_urls, namespace="tasks")),
     path("", include(webpages_urls, namespace="webpages")),
     path("", include(webpages_urls, namespace="webpages")),
     path("", include(podcast_urls, namespace="podcasts")),
     path("", include(podcast_urls, namespace="podcasts")),