diff --git a/catalog/book/models.py b/catalog/book/models.py index 131c89de..0bcad665 100644 --- a/catalog/book/models.py +++ b/catalog/book/models.py @@ -24,6 +24,7 @@ from .utils import * class Edition(Item): + category = ItemCategory.Book isbn = PrimaryLookupIdDescriptor(IdType.ISBN) asin = PrimaryLookupIdDescriptor(IdType.ASIN) cubn = PrimaryLookupIdDescriptor(IdType.CUBN) @@ -58,18 +59,14 @@ class Edition(Item): class Work(Item): - # douban_work = PrimaryLookupIdDescriptor(IdType.DoubanBook_Work) - # goodreads_work = PrimaryLookupIdDescriptor(IdType.Goodreads_Work) - editions = models.ManyToManyField(Edition, related_name='works') # , through='WorkEdition' - - # def __str__(self): - # return self.title - - # class Meta: - # proxy = True + category = ItemCategory.Book + douban_work = PrimaryLookupIdDescriptor(IdType.DoubanBook_Work) + goodreads_work = PrimaryLookupIdDescriptor(IdType.Goodreads_Work) + editions = models.ManyToManyField(Edition, related_name='works') class Series(Item): + category = ItemCategory.Book # douban_serie = LookupIdDescriptor(IdType.DoubanBook_Serie) # goodreads_serie = LookupIdDescriptor(IdType.Goodreads_Serie) diff --git a/catalog/book/tests.py b/catalog/book/tests.py index 51e55353..942db2bb 100644 --- a/catalog/book/tests.py +++ b/catalog/book/tests.py @@ -11,6 +11,11 @@ class BookTestCase(TestCase): hyperion.save() # hyperion.isbn10 = '0553283685' + def test_url(self): + hyperion = Edition.objects.get(title="Hyperion") + hyperion2 = Edition.get_by_url(hyperion.url) + self.assertEqual(hyperion, hyperion2) + def test_properties(self): hyperion = Edition.objects.get(title="Hyperion") self.assertEqual(hyperion.title, "Hyperion") diff --git a/catalog/common/__init__.py b/catalog/common/__init__.py index 9a0a165b..ab4fe928 100644 --- a/catalog/common/__init__.py +++ b/catalog/common/__init__.py @@ -5,4 +5,4 @@ from .scrapers import * from . import jsondata -__all__ = ('IdType', 'Item', 'ExternalResource', 'ResourceContent', 'ParseError', 'AbstractSite', 'SiteList', 'jsondata', 'PrimaryLookupIdDescriptor', 'LookupIdDescriptor', 'get_mock_mode', 'get_mock_file', 'use_local_response', 'RetryDownloader', 'BasicDownloader', 'ProxiedDownloader', 'BasicImageDownloader', 'RESPONSE_OK', 'RESPONSE_NETWORK_ERROR', 'RESPONSE_INVALID_CONTENT', 'RESPONSE_CENSORSHIP') +__all__ = ('IdType', 'ItemCategory', 'Item', 'ExternalResource', 'ResourceContent', 'ParseError', 'AbstractSite', 'SiteList', 'jsondata', 'PrimaryLookupIdDescriptor', 'LookupIdDescriptor', 'get_mock_mode', 'get_mock_file', 'use_local_response', 'RetryDownloader', 'BasicDownloader', 'ProxiedDownloader', 'BasicImageDownloader', 'RESPONSE_OK', 'RESPONSE_NETWORK_ERROR', 'RESPONSE_INVALID_CONTENT', 'RESPONSE_CENSORSHIP') diff --git a/catalog/common/models.py b/catalog/common/models.py index 586e7d95..46651dd5 100644 --- a/catalog/common/models.py +++ b/catalog/common/models.py @@ -5,6 +5,8 @@ from django.utils.translation import gettext_lazy as _ from django.utils import timezone from django.core.files.uploadedfile import SimpleUploadedFile from django.contrib.contenttypes.models import ContentType +from django.utils.baseconv import base62 +from simple_history.models import HistoricalRecords import uuid from .utils import DEFAULT_ITEM_COVER, item_cover_path # from django.conf import settings @@ -66,6 +68,19 @@ class ItemType(models.TextChoices): Exhibition = 'exhibition', _('展览') +class ItemCategory(models.TextChoices): + Book = 'book', _('书') + Movie = 'movie', _('电影') + TV = 'tv', _('剧集') + Music = 'music', _('音乐') + Game = 'game', _('游戏') + Boardgame = 'boardgame', _('桌游') + Podcast = 'podcast', _('播客') + FanFic = 'fanfic', _('网文') + Performance = 'performance', _('演出') + Exhibition = 'exhibition', _('展览') + + class SubItemType(models.TextChoices): Season = 'season', _('剧集分季') Episode = 'episode', _('剧集分集') @@ -139,7 +154,9 @@ class LookupIdDescriptor(object): # TODO make it mixin of Field class Item(PolymorphicModel): - uid = models.UUIDField(default=uuid.uuid4, editable=False) + URL_PATH = None # subclass must specify this + category = None # subclass must specify this + uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) # item_type = models.CharField(_("类型"), choices=ItemType.choices, blank=False, max_length=50) title = models.CharField(_("title in primary language"), max_length=1000, default="") # title_ml = models.JSONField(_("title in different languages {['lang':'zh-cn', 'text':'', primary:True], ...}"), null=True, blank=True, default=list) @@ -152,6 +169,9 @@ class Item(PolymorphicModel): cover = models.ImageField(upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True) created_time = models.DateTimeField(auto_now_add=True) edited_time = models.DateTimeField(auto_now=True) + is_deleted = models.BooleanField(default=False, db_index=True) + history = HistoricalRecords() + merged_to_item = models.ForeignKey('Item', null=True, on_delete=models.SET_NULL, default=None, related_name="merged_from_items") # parent_item = models.ForeignKey('Item', null=True, on_delete=models.SET_NULL, related_name='child_items') # identical_item = models.ForeignKey('Item', null=True, on_delete=models.SET_NULL, related_name='identical_items') # def get_lookup_id(self, id_type: str) -> str: @@ -161,6 +181,15 @@ class Item(PolymorphicModel): class Meta: unique_together = [['polymorphic_ctype_id', 'primary_lookup_id_type', 'primary_lookup_id_value']] + def delete(self, using=None, soft=True, *args, **kwargs): + if soft: + self.primary_lookup_id_value = None + self.primary_lookup_id_type = None + self.is_deleted = True + self.save(using=using) + else: + return super().delete(using=using, *args, **kwargs) + def __str__(self): return f"{self.id}{' ' + self.primary_lookup_id_type + ':' + self.primary_lookup_id_value if self.primary_lookup_id_value else ''} ({self.title})" @@ -168,9 +197,9 @@ class Item(PolymorphicModel): def get_best_lookup_id(cls, lookup_ids): """ get best available lookup id, ideally commonly used """ best_id_types = [ - IdType.ISBN, IdType.CUBN, IdType.ASIN, + IdType.ISBN, IdType.CUBN, IdType.ASIN, IdType.GTIN, IdType.ISRC, IdType.MusicBrainz, - IdType.Feed, + IdType.Feed, IdType.IMDB, IdType.TMDB_TVSeason ] for t in best_id_types: @@ -178,6 +207,25 @@ class Item(PolymorphicModel): return t, lookup_ids[t] return list(lookup_ids.items())[0] + def merge(self, to_item): + if to_item is None: + raise(ValueError('cannot merge to an empty item')) + elif to_item.merged_to_item is not None: + raise(ValueError('cannot merge with an item aleady merged')) + elif to_item.__class__ != self.__class__: + raise(ValueError('cannot merge with an item in different class')) + else: + self.merged_to_item = to_item + + @property + def url(self): + return f'/{self.URL_PATH}/{base62.encode(self.uid.int)}' + + @classmethod + def get_by_url(cls, url_or_b62): + b62 = url_or_b62.split('/')[-1] + return cls.objects.get(uid=uuid.UUID(int=base62.decode(b62))) + def update_lookup_ids(self, lookup_ids): # TODO # ll = set(lookup_ids) diff --git a/catalog/game/models.py b/catalog/game/models.py index 08c07dc3..ba8604d1 100644 --- a/catalog/game/models.py +++ b/catalog/game/models.py @@ -2,6 +2,7 @@ from catalog.common import * class Game(Item): + category = ItemCategory.Game igdb = PrimaryLookupIdDescriptor(IdType.IGDB) steam = PrimaryLookupIdDescriptor(IdType.Steam) douban_game = PrimaryLookupIdDescriptor(IdType.DoubanGame) diff --git a/catalog/movie/models.py b/catalog/movie/models.py index 9679a18c..b8b7a1fe 100644 --- a/catalog/movie/models.py +++ b/catalog/movie/models.py @@ -2,6 +2,7 @@ from catalog.common import * class Movie(Item): + category = ItemCategory.Movie imdb = PrimaryLookupIdDescriptor(IdType.IMDB) tmdb_movie = PrimaryLookupIdDescriptor(IdType.TMDB_Movie) douban_movie = PrimaryLookupIdDescriptor(IdType.DoubanMovie) diff --git a/catalog/music/models.py b/catalog/music/models.py index 11d008ba..f386bb8a 100644 --- a/catalog/music/models.py +++ b/catalog/music/models.py @@ -2,6 +2,7 @@ from catalog.common import * class Album(Item): + category = ItemCategory.Music barcode = PrimaryLookupIdDescriptor(IdType.GTIN) douban_music = PrimaryLookupIdDescriptor(IdType.DoubanMusic) spotify_album = PrimaryLookupIdDescriptor(IdType.Spotify_Album) diff --git a/catalog/performance/models.py b/catalog/performance/models.py index 68760eb6..4895876c 100644 --- a/catalog/performance/models.py +++ b/catalog/performance/models.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _ class Performance(Item): + category = ItemCategory.Performance douban_drama = LookupIdDescriptor(IdType.DoubanDrama) versions = jsondata.ArrayField(_('版本'), null=False, blank=False, default=list) directors = jsondata.ArrayField(_('导演'), null=False, blank=False, default=list) diff --git a/catalog/podcast/models.py b/catalog/podcast/models.py index 1df67b49..cf066363 100644 --- a/catalog/podcast/models.py +++ b/catalog/podcast/models.py @@ -2,6 +2,7 @@ from catalog.common import * class Podcast(Item): + category = ItemCategory.Podcast feed_url = PrimaryLookupIdDescriptor(IdType.Feed) apple_podcast = PrimaryLookupIdDescriptor(IdType.ApplePodcast) # ximalaya = LookupIdDescriptor(IdType.Ximalaya) diff --git a/catalog/tv/models.py b/catalog/tv/models.py index ea1044f1..b7ddb614 100644 --- a/catalog/tv/models.py +++ b/catalog/tv/models.py @@ -29,6 +29,7 @@ from django.db import models class TVShow(Item): + category = ItemCategory.TV imdb = PrimaryLookupIdDescriptor(IdType.IMDB) tmdb_tv = PrimaryLookupIdDescriptor(IdType.TMDB_TV) imdb = PrimaryLookupIdDescriptor(IdType.IMDB) @@ -36,6 +37,7 @@ class TVShow(Item): class TVSeason(Item): + category = ItemCategory.TV douban_movie = PrimaryLookupIdDescriptor(IdType.DoubanMovie) imdb = PrimaryLookupIdDescriptor(IdType.IMDB) tmdb_tvseason = PrimaryLookupIdDescriptor(IdType.TMDB_TVSeason) @@ -55,6 +57,7 @@ class TVSeason(Item): class TVEpisode(Item): + category = ItemCategory.TV show = models.ForeignKey(TVShow, null=True, on_delete=models.SET_NULL, related_name='episodes') season = models.ForeignKey(TVSeason, null=True, on_delete=models.SET_NULL, related_name='episodes') episode_number = models.PositiveIntegerField() diff --git a/journal/__init__.py b/journal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/journal/admin.py b/journal/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/journal/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/journal/apps.py b/journal/apps.py new file mode 100644 index 00000000..afe76cb9 --- /dev/null +++ b/journal/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class JournalConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'journal' diff --git a/journal/models.py b/journal/models.py new file mode 100644 index 00000000..9e23e70d --- /dev/null +++ b/journal/models.py @@ -0,0 +1,326 @@ +from django.db import models +from polymorphic.models import PolymorphicModel +from users.models import User +from catalog.common.models import Item, ItemCategory +from decimal import * +from enum import Enum +from markdownx.models import MarkdownxField +from django.utils import timezone +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.utils.translation import gettext_lazy as _ +from django.core.validators import RegexValidator +from functools import cached_property + + +class UserOwnedEntity(PolymorphicModel): + class Meta: + abstract = True + + owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='%(class)ss') + visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only + metadata = models.JSONField(default=dict) + created_time = models.DateTimeField(auto_now_add=True) + edited_time = models.DateTimeField(auto_now=True) + + def is_visible_to(self, viewer): + if not viewer.is_authenticated: + return self.visibility == 0 + owner = self.owner + if owner == viewer: + return True + if not owner.is_active: + return False + if self.visibility == 2: + return False + if viewer.is_blocking(owner) or owner.is_blocking(viewer) or viewer.is_muting(owner): + return False + if self.visibility == 1: + return viewer.is_following(owner) + else: + return True + + def is_editable_by(self, viewer): + return True if viewer.is_staff or viewer.is_superuser or viewer == self.owner else False + + @classmethod + def get_available(cls, entity, request_user, following_only=False): + # e.g. SongMark.get_available(song, request.user) + query_kwargs = {entity.__class__.__name__.lower(): entity} + all_entities = cls.objects.filter(**query_kwargs).order_by("-created_time") # get all marks for song + visible_entities = list(filter(lambda _entity: _entity.is_visible_to(request_user) and (_entity.owner.mastodon_username in request_user.mastodon_following if following_only else True), all_entities)) + return visible_entities + + +class Content(UserOwnedEntity): + target: models.ForeignKey(Item, on_delete=models.PROTECT) + + def __str__(self): + return f"{self.id}({self.target})" + + +class Note(Content): + pass + + +class Review(Content): + warning = models.BooleanField(default=False) + title = models.CharField(max_length=500, blank=False, null=True) + body = MarkdownxField() + pass + + +class Rating(Content): + grade = models.IntegerField(default=1, validators=[MaxValueValidator(10), MinValueValidator(0)]) + + +class Reply(Content): + reply_to_content = models.ForeignKey(Content, on_delete=models.PROTECT, related_name='replies') + title = models.CharField(max_length=500, null=True) + body = MarkdownxField() + pass + + +""" +List (abstract class) +""" + + +class List(UserOwnedEntity): + class Meta: + abstract = True + + MEMBER_CLASS = None # subclass must override this + # subclass must add this: + # items = models.ManyToManyField(Item, through='ListMember') + + @property + def ordered_members(self): + return self.members.all().order_by('position', 'item_id') + + @property + def ordered_items(self): + return self.items.all().order_by('collectionmember__position') + + def has_item(self, item): + return self.members.filter(item=item).count() > 0 + + def append_item(self, item, **params): + if item is None or self.has_item(item): + return None + else: + ml = self.ordered_members + p = {self.__class__.__name__.lower(): self} + p.update(params) + i = self.MEMBER_CLASS.objects.create(position=ml.last().position + 1 if ml.count() else 1, item=item, **p) + return i + + def remove_item(self, item): + member = self.members.all().filter(item=item).first() + if member: + member.delete() + + def move_up_item(self, item): + members = self.ordered_members + member = members.filter(item=item).first() + if member: + other = members.filter(position__lt=member.position).last() + if other: + p = other.position + other.position = member.position + member.position = p + other.save() + member.save() + + def move_down_item(self, item): + members = self.ordered_members + member = members.filter(item=item).first() + if member: + other = members.filter(position__gt=member.position).first() + if other: + p = other.position + other.position = member.position + member.position = p + other.save() + member.save() + + +class ListMember(models.Model): + # subclass must add this: + # list = models.ForeignKey('ListClass', related_name='members', on_delete=models.CASCADE) + item = models.ForeignKey(Item, on_delete=models.PROTECT) + position = models.PositiveIntegerField() + metadata = models.JSONField(default=dict) + comment = models.ForeignKey(Review, on_delete=models.SET_NULL, null=True) + + class Meta: + abstract = True + + +""" +Queue +""" + + +class QueueType(models.TextChoices): + WISHED = ('wished', '未开始') + STARTED = ('started', '进行中') + DONE = ('done', '完成') + # DISCARDED = ('discarded', '放弃') + + +QueueTypeNames = [ + [ItemCategory.Book, QueueType.WISHED, _('想读')], + [ItemCategory.Book, QueueType.STARTED, _('在读')], + [ItemCategory.Book, QueueType.DONE, _('读过')], + [ItemCategory.Movie, QueueType.WISHED, _('想看')], + [ItemCategory.Movie, QueueType.STARTED, _('在看')], + [ItemCategory.Movie, QueueType.DONE, _('看过')], + [ItemCategory.TV, QueueType.WISHED, _('想看')], + [ItemCategory.TV, QueueType.STARTED, _('在看')], + [ItemCategory.TV, QueueType.DONE, _('看过')], + [ItemCategory.Music, QueueType.WISHED, _('想听')], + [ItemCategory.Music, QueueType.STARTED, _('在听')], + [ItemCategory.Music, QueueType.DONE, _('听过')], + [ItemCategory.Game, QueueType.WISHED, _('想玩')], + [ItemCategory.Game, QueueType.STARTED, _('在玩')], + [ItemCategory.Game, QueueType.DONE, _('玩过')], + # TODO add more combinations +] + + +class QueueMember(ListMember): + queue = models.ForeignKey('Queue', related_name='members', on_delete=models.CASCADE) + + +class Queue(List): + class Meta: + unique_together = [['owner', 'item_category', 'queue_type']] + + MEMBER_CLASS = QueueMember + items = models.ManyToManyField(Item, through='QueueMember', related_name=None) + item_category = models.CharField(choices=ItemCategory.choices, max_length=100, null=False, blank=False) + queue_type = models.CharField(choices=QueueType.choices, max_length=100, null=False, blank=False) + + def __str__(self): + return f'{self.id} {self.title}' + + @cached_property + def queue_type_name(self): + return next(iter([n[2] for n in iter(QueueTypeNames) if n[0] == self.item_category and n[1] == self.queue_type]), self.queue_type) + + @cached_property + def title(self): + q = _("{item_category} {queue_type_name} list").format(queue_type_name=self.queue_type_name, item_category=self.item_category) + return _("{user}'s {queue_name}").format(user=self.owner.mastodon_username, queue_name=q) + + +class QueueLogEntry(models.Model): + owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='%(class)ss') + queue = models.ForeignKey(Queue, on_delete=models.PROTECT, related_name='entries', null=True) # None means removed from any queue + item = models.ForeignKey(Item, on_delete=models.PROTECT) + metadata = models.JSONField(default=dict) + created_time = models.DateTimeField(auto_now_add=True) + edited_time = models.DateTimeField(auto_now=True) + queued_time = models.DateTimeField(default=timezone.now) + + def __str__(self): + return f'{self.owner}:{self.queue}:{self.item}:{self.metadata}' + + +class QueueManager: + """ + QueueManager + + all queue operations should go thru this class so that QueueLogEntry can be properly populated + QueueLogEntry can later be modified if user wish to change history + """ + + def __init__(self, user): + self.owner = user + + def initialize(self): + for ic in ItemCategory: + for qt in QueueType: + Queue.objects.create(owner=self.owner, item_category=ic, queue_type=qt) + + def _queue_member_for_item(self, item): + return QueueMember.objects.filter(item=item, queue__in=self.owner.queues.all()).first() + + def _queue_for_item_and_type(item, queue_type): + if not item or not queue_type: + return None + return self.owner.queues.all().filter(item_category=item.category, queue_type=queue_type) + + def update_for_item(self, item, queue_type, metadata=None): + # None means no change for metadata, comment + if not item: + raise ValueError('empty item') + lastqm = self._queue_member_for_item(item) + lastqmm = lastqm.metadata if lastqm else None + lastq = lastqm.queue if lastqm else None + lastqt = lastq.queue_type if lastq else None + queue = self.get_queue(item.category, queue_type) if queue_type else None + if lastq != queue: + if lastq: + lastq.remove_item(item) + if queue: + queue.append_item(item, metadata=metadata or {}) + elif metadata is not None: + lastqm.metadata = metadata + lastqm.save() + elif lastqm: + metadata = lastqm.metadata + if lastqt != queue_type or (lastqt and metadata != lastqmm): + QueueLogEntry.objects.create(owner=self.owner, queue=queue, item=item, metadata=metadata or {}) + + def get_log(self): + return QueueLogEntry.objects.filter(owner=self.owner) + + def get_log_for_item(self, item): + return QueueLogEntry.objects.filter(owner=self.owner, item=item) + + def get_queue(self, item_category, queue_type): + return self.owner.queues.all().filter(item_category=item_category, queue_type=queue_type).first() + + +""" +Collection +""" + + +class CollectionMember(ListMember): + collection = models.ForeignKey('Collection', related_name='members', on_delete=models.CASCADE) + + +class Collection(Item, List): + MEMBER_CLASS = CollectionMember + items = models.ManyToManyField(Item, through='CollectionMember', related_name="collections") + collaborative = models.PositiveSmallIntegerField(default=0) # 0: Editable by owner only / 1: Editable by bi-direction followers + + @property + def plain_description(self): + html = markdown(self.description) + return RE_HTML_TAG.sub(' ', html) + + +""" +Tag +""" + + +class TagMember(ListMember): + tag = models.ForeignKey('Tag', related_name='members', on_delete=models.CASCADE) + + +TagValidators = [RegexValidator(regex=r'\s+', inverse_match=True)] + + +class Tag(List): + MEMBER_CLASS = CollectionMember + title = models.CharField(max_length=100, null=False, blank=False, validators=TagValidators) + # TODO case convert and space removal on save + # TODO check on save + + class Meta: + unique_together = [['owner', 'title']] diff --git a/journal/tests.py b/journal/tests.py new file mode 100644 index 00000000..63e6fd76 --- /dev/null +++ b/journal/tests.py @@ -0,0 +1,65 @@ +from django.test import TestCase +from .models import * +from catalog.models import * +from users.models import User + + +class CollectionTest(TestCase): + def setUp(self): + self.book1 = Edition.objects.create(title="Hyperion") + self.book2 = Edition.objects.create(title="Andymion") + self.user = User.objects.create() + pass + + def test_collection(self): + collection = Collection.objects.create(title="test", owner=self.user) + collection.append_item(self.book1) + collection.append_item(self.book2) + self.assertEqual(list(collection.ordered_items), [self.book1, self.book2]) + collection.move_up_item(self.book1) + self.assertEqual(list(collection.ordered_items), [self.book1, self.book2]) + collection.move_up_item(self.book2) + self.assertEqual(list(collection.ordered_items), [self.book2, self.book1]) + + +class QueueTest(TestCase): + def setUp(self): + pass + + def test_queue(self): + user = User.objects.create(mastodon_site="site", username="name") + queue_manager = QueueManager(user=user) + queue_manager.initialize() + self.assertEqual(user.queues.all().count(), 30) + book1 = Edition.objects.create(title="Hyperion") + book2 = Edition.objects.create(title="Andymion") + q1 = queue_manager.get_queue(ItemCategory.Book, QueueType.WISHED) + q2 = queue_manager.get_queue(ItemCategory.Book, QueueType.STARTED) + self.assertIsNotNone(q1) + self.assertIsNotNone(q2) + self.assertEqual(q1.members.all().count(), 0) + self.assertEqual(q2.members.all().count(), 0) + queue_manager.update_for_item(book1, QueueType.WISHED) + queue_manager.update_for_item(book2, QueueType.WISHED) + self.assertEqual(q1.members.all().count(), 2) + queue_manager.update_for_item(book1, QueueType.STARTED) + self.assertEqual(q1.members.all().count(), 1) + self.assertEqual(q2.members.all().count(), 1) + queue_manager.update_for_item(book1, QueueType.STARTED, metadata={'progress': 1}) + self.assertEqual(q1.members.all().count(), 1) + self.assertEqual(q2.members.all().count(), 1) + log = queue_manager.get_log_for_item(book1) + self.assertEqual(log.count(), 3) + queue_manager.update_for_item(book1, QueueType.STARTED, metadata={'progress': 1}) + log = queue_manager.get_log_for_item(book1) + self.assertEqual(log.count(), 3) + queue_manager.update_for_item(book1, QueueType.STARTED, metadata={'progress': 10}) + log = queue_manager.get_log_for_item(book1) + self.assertEqual(log.count(), 4) + queue_manager.update_for_item(book1, QueueType.STARTED) + log = queue_manager.get_log_for_item(book1) + self.assertEqual(log.count(), 4) + self.assertEqual(log.order_by('queued_time').last().metadata, {'progress': 10}) + queue_manager.update_for_item(book1, QueueType.STARTED, metadata={'progress': 100}) + log = queue_manager.get_log_for_item(book1) + self.assertEqual(log.count(), 5) diff --git a/journal/views.py b/journal/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/journal/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.