Bladeren bron

Add collections and celery config

Colin Powell 3 jaren geleden
bovenliggende
commit
c15e9ffc7d

+ 5 - 0
emus/__init__.py

@@ -0,0 +1,5 @@
+# This will make sure the app is always imported when
+# Django starts so that shared_task will use this app.
+from .celery import app as celery_app
+
+__all__ = ('celery_app',)

+ 13 - 0
emus/celery.py

@@ -0,0 +1,13 @@
+import os
+
+from celery import Celery
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'emus.settings')
+app = Celery('emus')
+app.config_from_object('django.conf:settings', namespace='CELERY')
+app.autodiscover_tasks()
+
+
+@app.task(bind=True)
+def debug_task(self):
+    print(f'Request: {self.request!r}')

+ 11 - 1
emus/settings.py

@@ -32,7 +32,15 @@ ALLOWED_HOSTS = ["*"]
 CSRF_TRUSTED_ORIGINS = [os.getenv("EMUS_TRUSTED_ORIGINS", "http://localhost:8000")]
 X_FRAME_OPTIONS = "SAMEORIGIN"
 
-# Application definition
+CELERY_DEFAULT_QUEUE = "emus"
+CELERY_TASK_ALWAYS_EAGER = os.getenv("EMUS_SKIP_CELERY", False)
+CELERY_BROKER_URL = os.getenv("EMUS_CELERY_BROKER_URL", "memory://localhost/")
+CELERY_RESULT_BACKEND = "django-db"
+CELERY_ACCEPT_CONTENT = ['application/json']
+CELERY_TASK_SERIALIZER = 'json'
+CELERY_RESULT_SERIALIZER = 'json'
+CELERY_TIMEZONE = os.getenv("EMUS_TIME_ZONE", "EST")
+CELERY_TASK_TRACK_STARTED = True
 
 INSTALLED_APPS = [
     "django.contrib.admin",
@@ -50,6 +58,7 @@ INSTALLED_APPS = [
     "rest_framework",
     "allauth",
     "allauth.account",
+    "django_celery_results",
 ]
 
 SITE_ID = 1
@@ -143,6 +152,7 @@ STATIC_ROOT = os.getenv("EMUS_STATIC_ROOT", os.path.join(BASE_DIR, "static"))
 MEDIA_URL = "/media/"
 MEDIA_ROOT = os.getenv("EMUS_MEDIA_ROOT", os.path.join(BASE_DIR, "media"))
 ROMS_DIR = os.path.join(MEDIA_ROOT, "roms")
+COLLECTIONS_DIR = os.path.join(ROMS_DIR, "emulationstation-collections")
 
 SCRAPER_BIN_PATH = os.getenv("EMUS_SCRAPER_BINPATH", "Skyscraper")
 SCRAPER_CONFIG_FILE = os.getenv("EMUS_SCRAPER_CONFIG_FILE", "skyscraper.ini")

+ 2 - 0
emus/urls.py

@@ -8,6 +8,7 @@ from games.api.views import (
     DeveloperViewSet,
     GameSystemViewSet,
     GameViewSet,
+    GameCollectionViewSet,
     PublisherViewSet,
     GenreViewSet,
 )
@@ -19,6 +20,7 @@ router.register(r"publishers", PublisherViewSet)
 router.register(r"developers", DeveloperViewSet)
 router.register(r"genre", GenreViewSet)
 router.register(r"game-systems", GameSystemViewSet)
+router.register(r"game-collections", GameCollectionViewSet)
 
 urlpatterns = [
     path("accounts/", include("allauth.urls")),

+ 9 - 1
games/admin.py

@@ -1,13 +1,21 @@
 from django.contrib import admin
 
-from games.models import Developer, Game, GameSystem, Genre, Publisher
+from games.models import Developer, Game, GameSystem, Genre, Publisher, GameCollection
 
 
 class GameAdmin(admin.ModelAdmin):
     list_display = ("name", "game_system", "rating", "region")
     list_filter = ("game_system", "undub", "english_patched", "hack")
 
+class GameInline(admin.TabularInline):
+    model = Game
 
+class GameCollectionAdmin(admin.ModelAdmin):
+    filter_horizontal = ('games',)
+    raw_id_fields = ('game_system', 'developer', 'publisher', 'genre',)
+
+
+admin.site.register(GameCollection, GameCollectionAdmin)
 admin.site.register(GameSystem)
 admin.site.register(Developer)
 admin.site.register(Publisher)

+ 12 - 2
games/api/serializers.py

@@ -1,14 +1,14 @@
-from games.models import Developer, Game, GameSystem, Genre, Publisher
+from games.models import Developer, Game, GameSystem, Genre, Publisher, GameCollection
 from rest_framework import serializers
 
 
-# Serializers define the API representation.
 class GameSerializer(serializers.HyperlinkedModelSerializer):
     class Meta:
         model = Game
         fields = (
             "id",
             "name",
+            "slug",
             "publisher",
             "developer",
             "players",
@@ -57,3 +57,13 @@ class GameSystemSerializer(serializers.HyperlinkedModelSerializer):
             "retropie_slug",
             "slug",
         )
+
+class GameCollectionSerializer(serializers.HyperlinkedModelSerializer):
+    class Meta:
+        model = GameCollection
+        fields = (
+            "id",
+            "name",
+            "slug",
+            "games",
+        )

+ 9 - 1
games/api/views.py

@@ -4,8 +4,9 @@ from games.api.serializers import (
     GameSystemSerializer,
     GenreSerializer,
     PublisherSerializer,
+    GameCollectionSerializer,
 )
-from games.models import Developer, Game, GameSystem, Genre, Publisher
+from games.models import Developer, Game, GameSystem, Genre, Publisher, GameCollection
 from rest_framework import viewsets
 
 
@@ -32,3 +33,10 @@ class GenreViewSet(viewsets.ModelViewSet):
 class GameSystemViewSet(viewsets.ModelViewSet):
     queryset = GameSystem.objects.all()
     serializer_class = GameSystemSerializer
+
+
+class GameCollectionViewSet(viewsets.ModelViewSet):
+    queryset = GameCollection.objects.all()
+    serializer_class = GameCollectionSerializer
+
+

+ 2 - 1
games/context_processors.py

@@ -1,7 +1,8 @@
-from games.models import GameSystem
+from games.models import GameSystem, GameCollection
 
 
 def game_systems(request):
     return {
         "game_systems": GameSystem.objects.all(),
+        "game_collections": GameCollection.objects.all(),
     }

+ 14 - 0
games/management/commands/export_collections.py

@@ -0,0 +1,14 @@
+from django.core.management.base import BaseCommand, CommandError
+from django.conf import settings
+
+from games.utils import export_collections
+
+
+class Command(BaseCommand):
+    help = "Export all collections to media directory"
+
+    def handle(self, *args, **options):
+        export_collections(dryrun=False)
+        return
+
+

+ 20 - 5
games/models.py

@@ -273,6 +273,7 @@ class GameCollection(BaseModel):
     game_system = models.ForeignKey(
         GameSystem,
         on_delete=models.SET_NULL,
+        blank=True,
         null=True,
     )
     developer = models.ForeignKey(
@@ -290,17 +291,31 @@ class GameCollection(BaseModel):
     genre = models.ForeignKey(
         Genre,
         on_delete=models.SET_NULL,
+        blank=True,
         null=True,
     )
 
+    def __str__(self):
+        return f"{self.name}"
+
+    def get_absolute_url(self):
+        return reverse("games:gamecollection_detail", args=[self.slug])
+
+    @property
+    def rating_avg(self):
+        avg = self.games.aggregate(models.Avg("rating"))["rating__avg"]
+        if avg:
+            return int(100 * avg)
+        return 0
+
     def export_to_file(self, dryrun=True):
         """Will dump this collection to a .cfg file in /tmp or
         our COLLECTIONS_DIR configured path if dryrun=False"""
 
-        file_path = f"/tmp/custom-{collection.slug}.cfg"
+        file_path = f"/tmp/custom-{self.slug}.cfg"
         if not dryrun:
-            file_path = os.path.join(settings.COLLECTIONS_DIR, "custom-{collection.slug}.cfg")
+            file_path = os.path.join(settings.COLLECTIONS_DIR, f"custom-{self.slug}.cfg")
 
-        with open(file_path, "a") as outfile:
-            for game in self.game_set.all():
-                outfile.write(game.rom_file.path)
+        with open(file_path, "w") as outfile:
+            for game in self.games.all():
+                outfile.write(game.rom_file.path + "\n")

+ 12 - 0
games/tasks.py

@@ -0,0 +1,12 @@
+from django_celery_results.models import TaskResult
+from celery import shared_task
+
+from games.utils import skyscrape_console, import_gamelist_file_to_db_for_system
+
+@shared_task
+def update_roms(game_system_slugs: list, full_scan=False):
+    for game_system_slug in game_system_slugs:
+        scrape_out, load_out = skyscrape_console(game_system_slug)
+        import_dict = import_gamelist_file_to_db_for_system(game_system_slug, full_scan=full_scan)
+    return import_dict
+

+ 10 - 0
games/urls.py

@@ -30,6 +30,11 @@ urlpatterns = [
         views.DeveloperList.as_view(),
         name="developer_list",
     ),
+    path(
+        "collection/",
+        views.GameCollectionList.as_view(),
+        name="gamecollection_list",
+    ),
     path(
         "<str:slug>/",
         views.GameDetail.as_view(),
@@ -60,4 +65,9 @@ urlpatterns = [
         views.DeveloperDetail.as_view(),
         name="developer_detail",
     ),
+    path(
+        "collection/<str:slug>/",
+        views.GameCollectionDetail.as_view(),
+        name="gamecollection_detail",
+    ),
 ]

+ 9 - 1
games/utils.py

@@ -5,7 +5,7 @@ import subprocess
 from dateutil import parser
 from django.conf import settings
 
-from .models import Developer, Game, GameSystem, Genre, Publisher
+from .models import Developer, Game, GameSystem, Genre, Publisher, GameCollection
 
 import logging
 
@@ -226,3 +226,11 @@ def skyscrape_console(game_system_slug):
     print(scrape_output)
     print(load_output)
     return scrape_output, load_output
+
+
+def export_collections(dryrun=True):
+    exported = []
+    for collection in GameCollection.objects.all():
+        collection.export_to_file(dryrun)
+        exported.append(collection)
+    return exported

+ 23 - 5
games/views.py

@@ -5,7 +5,7 @@ from django.db.models import Avg, Count, F
 from django.views.generic import DetailView, ListView
 from django.views.generic.list import MultipleObjectMixin
 
-from .models import Developer, Game, GameSystem, Genre, Publisher
+from .models import Developer, Game, GameSystem, Genre, Publisher, GameCollection
 
 logger = logging.Logger(__name__)
 
@@ -40,7 +40,6 @@ class LibraryGameList(ListView, LoginRequiredMixin):
 
 
 class FilterableBaseListView(ListView, LoginRequiredMixin):
-    VALID_ORDERING = ["name", "num_games", "rating"]
 
     def get_queryset(self, **kwargs):
         order_by = self.request.GET.get("order_by", "name")
@@ -48,9 +47,6 @@ class FilterableBaseListView(ListView, LoginRequiredMixin):
         queryset = queryset.annotate(num_games=Count("game")).annotate(
             rating=Avg("game__rating")
         )
-        if order_by.replace("-", "") not in self.VALID_ORDERING:
-            logger.warning(f"Received invalid filter {order_by}")
-            return queryset
 
         if order_by[0] == "-":
             order_by = order_by[1:]
@@ -127,3 +123,25 @@ class DeveloperDetail(DetailView, MultipleObjectMixin, LoginRequiredMixin):
             object_list=object_list, **kwargs
         )
         return context
+
+
+class GameCollectionList(ListView, LoginRequiredMixin):
+    model = GameCollection
+
+
+class GameCollectionDetail(DetailView, LoginRequiredMixin):
+    model = GameCollection
+
+    def get_context_data(self, **kwargs):
+        collection = self.get_object()
+        order_by = self.request.GET.get("order_by", "release_date")
+        object_list = collection.games.all()
+
+        if order_by[0] == "-":
+            order_by = order_by[1:]
+            object_list = object_list.order_by(F(order_by).desc(nulls_last=True))
+        else:
+            object_list = object_list.order_by(F(order_by).asc(nulls_last=True))
+
+        context = super(GameCollectionDetail, self).get_context_data(object_list=object_list, **kwargs)
+        return context

+ 234 - 1
poetry.lock

@@ -1,3 +1,14 @@
+[[package]]
+name = "amqp"
+version = "5.1.0"
+description = "Low-level AMQP client for Python (fork of amqplib)."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+vine = ">=5.0.0"
+
 [[package]]
 name = "asgiref"
 version = "3.5.0"
@@ -20,6 +31,65 @@ python-versions = ">=3.6"
 [package.extras]
 tzdata = ["tzdata"]
 
+[[package]]
+name = "billiard"
+version = "3.6.4.0"
+description = "Python multiprocessing fork with improvements and bugfixes"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "celery"
+version = "5.2.6"
+description = "Distributed Task Queue."
+category = "main"
+optional = false
+python-versions = ">=3.7,"
+
+[package.dependencies]
+billiard = ">=3.6.4.0,<4.0"
+click = ">=8.0.3,<9.0"
+click-didyoumean = ">=0.0.3"
+click-plugins = ">=1.1.1"
+click-repl = ">=0.2.0"
+kombu = ">=5.2.3,<6.0"
+pytz = ">=2021.3"
+vine = ">=5.0.0,<6.0"
+
+[package.extras]
+arangodb = ["pyArango (>=1.3.2)"]
+auth = ["cryptography"]
+azureblockblob = ["azure-storage-blob (==12.9.0)"]
+brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"]
+cassandra = ["cassandra-driver (<3.21.0)"]
+consul = ["python-consul2"]
+cosmosdbsql = ["pydocumentdb (==2.3.2)"]
+couchbase = ["couchbase (>=3.0.0)"]
+couchdb = ["pycouchdb"]
+django = ["Django (>=1.11)"]
+dynamodb = ["boto3 (>=1.9.178)"]
+elasticsearch = ["elasticsearch"]
+eventlet = ["eventlet (>=0.32.0)"]
+gevent = ["gevent (>=1.5.0)"]
+librabbitmq = ["librabbitmq (>=1.5.0)"]
+memcache = ["pylibmc"]
+mongodb = ["pymongo[srv] (>=3.11.1)"]
+msgpack = ["msgpack"]
+pymemcache = ["python-memcached"]
+pyro = ["pyro4"]
+pytest = ["pytest-celery"]
+redis = ["redis (>=3.4.1,!=4.0.0,!=4.0.1)"]
+s3 = ["boto3 (>=1.9.125)"]
+slmq = ["softlayer-messaging (>=1.0.3)"]
+solar = ["ephem"]
+sqlalchemy = ["sqlalchemy"]
+sqs = ["kombu"]
+tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"]
+yaml = ["PyYAML (>=3.10)"]
+zookeeper = ["kazoo (>=1.3.1)"]
+zstd = ["zstandard"]
+
 [[package]]
 name = "certifi"
 version = "2021.10.8"
@@ -50,6 +120,55 @@ python-versions = ">=3.5.0"
 [package.extras]
 unicode_backport = ["unicodedata2"]
 
+[[package]]
+name = "click"
+version = "8.1.2"
+description = "Composable command line interface toolkit"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "click-didyoumean"
+version = "0.3.0"
+description = "Enables git-like *did-you-mean* feature in click"
+category = "main"
+optional = false
+python-versions = ">=3.6.2,<4.0.0"
+
+[package.dependencies]
+click = ">=7"
+
+[[package]]
+name = "click-plugins"
+version = "1.1.1"
+description = "An extension module for click to enable registering CLI commands via setuptools entry-points."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+click = ">=4.0"
+
+[package.extras]
+dev = ["pytest (>=3.6)", "pytest-cov", "wheel", "coveralls"]
+
+[[package]]
+name = "click-repl"
+version = "0.2.0"
+description = "REPL plugin for Click"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+click = "*"
+prompt-toolkit = "*"
+six = "*"
+
 [[package]]
 name = "colorama"
 version = "0.4.4"
@@ -140,6 +259,17 @@ python3-openid = ">=3.0.8"
 requests = "*"
 requests-oauthlib = ">=0.3.0"
 
+[[package]]
+name = "django-celery-results"
+version = "2.3.0"
+description = "Celery result backends for Django."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+celery = ">=5.2.3,<6.0"
+
 [[package]]
 name = "django-extensions"
 version = "3.1.5"
@@ -206,6 +336,34 @@ docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
 perf = ["ipython"]
 testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
 
+[[package]]
+name = "kombu"
+version = "5.2.4"
+description = "Messaging library for Python."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+amqp = ">=5.0.9,<6.0.0"
+vine = "*"
+
+[package.extras]
+azureservicebus = ["azure-servicebus (>=7.0.0)"]
+azurestoragequeues = ["azure-storage-queue"]
+consul = ["python-consul (>=0.6.0)"]
+librabbitmq = ["librabbitmq (>=2.0.0)"]
+mongodb = ["pymongo (>=3.3.0,<3.12.1)"]
+msgpack = ["msgpack"]
+pyro = ["pyro4"]
+qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"]
+redis = ["redis (>=3.4.1,!=4.0.0,!=4.0.1)"]
+slmq = ["softlayer-messaging (>=1.0.3)"]
+sqlalchemy = ["sqlalchemy"]
+sqs = ["boto3 (>=1.9.12)", "pycurl (>=7.44.1,<7.45.0)", "urllib3 (>=1.26.7)"]
+yaml = ["PyYAML (>=3.10)"]
+zookeeper = ["kazoo (>=1.3.1)"]
+
 [[package]]
 name = "markdown"
 version = "3.3.6"
@@ -245,6 +403,17 @@ python-versions = ">=3.7"
 docs = ["olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinx-rtd-theme (>=1.0)", "sphinxext-opengraph"]
 tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
 
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.29"
+description = "Library for building powerful interactive command lines in Python"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+wcwidth = "*"
+
 [[package]]
 name = "psycopg2"
 version = "2.9.3"
@@ -401,6 +570,22 @@ brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
 secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
 socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 
+[[package]]
+name = "vine"
+version = "5.0.0"
+description = "Promises, promises, promises."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "wcwidth"
+version = "0.2.5"
+description = "Measures the displayed width of unicode strings in a terminal"
+category = "main"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "zipp"
 version = "3.8.0"
@@ -416,9 +601,13 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.8"
-content-hash = "659bbf0a948fe5a67b77b290da5e1c2153b174515db0b807c9c13a33f5cc5646"
+content-hash = "3bf869b6bc4f7f7d56835e4b8e2a3e6d9f45ac46887ff06f56f8a9e01d1e2a71"
 
 [metadata.files]
+amqp = [
+    {file = "amqp-5.1.0-py3-none-any.whl", hash = "sha256:a575f4fa659a2290dc369b000cff5fea5c6be05fe3f2d5e511bcf56c7881c3ef"},
+    {file = "amqp-5.1.0.tar.gz", hash = "sha256:446b3e8a8ebc2ceafd424ffcaab1c353830d48161256578ed7a65448e601ebed"},
+]
 asgiref = [
     {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"},
     {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"},
@@ -441,6 +630,14 @@ asgiref = [
     {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"},
     {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"},
 ]
+billiard = [
+    {file = "billiard-3.6.4.0-py3-none-any.whl", hash = "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b"},
+    {file = "billiard-3.6.4.0.tar.gz", hash = "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547"},
+]
+celery = [
+    {file = "celery-5.2.6-py3-none-any.whl", hash = "sha256:da31f8eae7607b1582e5ee2d3f2d6f58450585afd23379491e3d9229d08102d0"},
+    {file = "celery-5.2.6.tar.gz", hash = "sha256:d1398cadf30f576266b34370e28e880306ec55f7a4b6307549b0ae9c15663481"},
+]
 certifi = [
     {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
     {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
@@ -501,6 +698,22 @@ charset-normalizer = [
     {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
     {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
 ]
+click = [
+    {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"},
+    {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"},
+]
+click-didyoumean = [
+    {file = "click-didyoumean-0.3.0.tar.gz", hash = "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035"},
+    {file = "click_didyoumean-0.3.0-py3-none-any.whl", hash = "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667"},
+]
+click-plugins = [
+    {file = "click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b"},
+    {file = "click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8"},
+]
+click-repl = [
+    {file = "click-repl-0.2.0.tar.gz", hash = "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8"},
+    {file = "click_repl-0.2.0-py3-none-any.whl", hash = "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b"},
+]
 colorama = [
     {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
     {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
@@ -546,6 +759,10 @@ django = [
 django-allauth = [
     {file = "django-allauth-0.50.0.tar.gz", hash = "sha256:ee3a174e249771caeb1d037e64b2704dd3c56cfec44f2058fae2214b224d35e8"},
 ]
+django-celery-results = [
+    {file = "django_celery_results-2.3.0-py2.py3-none-any.whl", hash = "sha256:37b8734ad0038cdeabe9efb09721dfdbe1ff7bf6e1d81ff3e10a1eb23a2b321f"},
+    {file = "django_celery_results-2.3.0.tar.gz", hash = "sha256:203cf7321081d09be91738aff715c97bcc769db8c727621049e2786118059dac"},
+]
 django-extensions = [
     {file = "django-extensions-3.1.5.tar.gz", hash = "sha256:28e1e1bf49f0e00307ba574d645b0af3564c981a6dfc87209d48cb98f77d0b1a"},
     {file = "django_extensions-3.1.5-py3-none-any.whl", hash = "sha256:9238b9e016bb0009d621e05cf56ea8ce5cce9b32e91ad2026996a7377ca28069"},
@@ -570,6 +787,10 @@ importlib-metadata = [
     {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"},
     {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"},
 ]
+kombu = [
+    {file = "kombu-5.2.4-py3-none-any.whl", hash = "sha256:8b213b24293d3417bcf0d2f5537b7f756079e3ea232a8386dcc89a59fd2361a4"},
+    {file = "kombu-5.2.4.tar.gz", hash = "sha256:37cee3ee725f94ea8bb173eaab7c1760203ea53bbebae226328600f9d2799610"},
+]
 markdown = [
     {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"},
     {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"},
@@ -618,6 +839,10 @@ pillow = [
     {file = "Pillow-9.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8d79c6f468215d1a8415aa53d9868a6b40c4682165b8cb62a221b1baa47db458"},
     {file = "Pillow-9.1.0.tar.gz", hash = "sha256:f401ed2bbb155e1ade150ccc63db1a4f6c1909d3d378f7d1235a44e90d75fb97"},
 ]
+prompt-toolkit = [
+    {file = "prompt_toolkit-3.0.29-py3-none-any.whl", hash = "sha256:62291dad495e665fca0bda814e342c69952086afb0f4094d0893d357e5c78752"},
+    {file = "prompt_toolkit-3.0.29.tar.gz", hash = "sha256:bd640f60e8cecd74f0dc249713d433ace2ddc62b65ee07f96d358e0b152b6ea7"},
+]
 psycopg2 = [
     {file = "psycopg2-2.9.3-cp310-cp310-win32.whl", hash = "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362"},
     {file = "psycopg2-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca"},
@@ -683,6 +908,14 @@ urllib3 = [
     {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"},
     {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"},
 ]
+vine = [
+    {file = "vine-5.0.0-py2.py3-none-any.whl", hash = "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30"},
+    {file = "vine-5.0.0.tar.gz", hash = "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"},
+]
+wcwidth = [
+    {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
+    {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
+]
 zipp = [
     {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"},
     {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"},

+ 1 - 0
pyproject.toml

@@ -20,6 +20,7 @@ psycopg2 = {version = "^2.9.3", extras = ["production"]}
 dj-database-url = "^0.5.0"
 django-mathfilters = "^1.0.0"
 django-allauth = "^0.50.0"
+django-celery-results = "^2.3.0"
 
 [build-system]
 requires = ["poetry-core>=1.0.0"]

+ 9 - 0
templates/base.html

@@ -76,6 +76,15 @@
                         {% endfor %}
                     </div>
                 </li>
+                <li class="nav-item dropdown">
+                    <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Collections</a>
+                    <div class="dropdown-menu" aria-labelledby="navbarDropdown">
+			<a class="dropdown-item" href="{% url "games:gamecollection_list" %}">All</a>
+                        {% for collection in game_collections %}
+                        <a class="dropdown-item" href="{{collection.get_absolute_url}}">{{collection.name}} ({{collection.games.count}})</a>
+                        {% endfor %}
+                    </div>
+                </li>
                 </ul>
                 <form class="form-inline my-2 my-lg-0" method="get" action="{% url 'search:search' %}">
                 <input class="form-control mr-sm-2" name="q" type="search" placeholder="Search" aria-label="Search">

+ 1 - 1
templates/games/_game_card.html

@@ -1,4 +1,4 @@
-<div class="card">
+<div class="card" >
     <div class="row no-gutters">
         <div class="col-auto">
         {% if game.screenshot %}

+ 28 - 0
templates/games/_game_table.html

@@ -0,0 +1,28 @@
+    <table class="table table-bordered table">
+    <thead>
+        <tr>
+        <th scope="col">#</th>
+        <th scope="col"><a href="?order_by=name">Name</a></th>
+        <th scope="col">System</th>
+        <th scope="col"><a href="?order_by={% if not '-rating' in request.get_full_path %}-{% endif %}rating">Rating</a></th>
+        <th scope="col"><a href="?order_by={% if not '-developer' in request.get_full_path %}-{% endif %}developer">Developer</a></th>
+        <th scope="col"><a href="?order_by={% if not '-publisher' in request.get_full_path %}-{% endif %}publisher">Publisher</a></th>
+        <th scope="col"><a href="?order_by={% if not '-genre' in request.get_full_path %}-{% endif %}genre">Genre</a></th>
+	<th scope="col"><a href="?order_by={% if not '-release_date' in request.get_full_path %}-{% endif %}release_date">Release</a></th>
+        </tr>
+    </thead>
+    <tbody>
+        {% for game in object_list %}
+        <tr>
+        <th scope="row"><a href="{{game.get_absolute_url}}">{{game.id}}</a></th>
+        <td>{{game.name}}</td>
+        <td><a href="{{game.game_system.get_absolute_url}}">{{game.game_system}}</td>
+        <td>{{game.rating_by_100}}/100</td>
+        <td><a href="{{game.developer.get_absolute_url}}">{{game.developer}}</a></td>
+        <td><a href="{{game.publisher.get_absolute_url}}">{{game.publisher}}</a></td>
+        <td style="font-size:smaller;">{% for genre in game.genre.all %}<a href="{{genre.get_absolute_url}}">{{genre}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
+	<td>{{game.release_date|date:"Y-m-d"}}</td>
+        </tr>
+        {% endfor %}
+    </tbody>
+    </table>

+ 1 - 26
templates/games/game_library_list.html

@@ -4,31 +4,6 @@
 {% block title %}All games by rating{% endblock %}
 
 {% block content %}
-    <table class="table table-bordered table">
-    <thead>
-        <tr>
-        <th scope="col">#</th>
-        <th scope="col"><a href="?order_by=name">Name</a></th>
-        <th scope="col">System</th>
-        <th scope="col"><a href="?order_by={% if not '-rating' in request.get_full_path %}-{% endif %}rating">Rating</a></th>
-        <th scope="col"><a href="?order_by={% if not '-developer' in request.get_full_path %}-{% endif %}developer">Developer</a></th>
-        <th scope="col"><a href="?order_by={% if not '-publisher' in request.get_full_path %}-{% endif %}publisher">Publisher</a></th>
-        <th scope="col"><a href="?order_by={% if not '-genre' in request.get_full_path %}-{% endif %}genre">Genre</a></th>
-        </tr>
-    </thead>
-    <tbody>
-        {% for  game in object_list %}
-        <tr>
-        <th scope="row"><a href="{{game.get_absolute_url}}">{{game.id}}</a></th>
-        <td>{{game.name}}</td>
-        <td><a href="{{game.game_system.get_absolute_url}}">{{game.game_system}}</td>
-        <td>{{game.rating_by_100}}/100</td>
-        <td><a href="{{game.developer.get_absolute_url}}">{{game.developer}}</a></td>
-        <td><a href="{{game.publisher.get_absolute_url}}">{{game.publisher}}</a></td>
-        <td style="font-size:smaller;">{% for genre in game.genre.all %}<a href="{{genre.get_absolute_url}}">{{genre}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
-        </tr>
-        {% endfor %}
-    </tbody>
-    </table>
+    {% include "games/_game_table.html" %}
     {% include "games/_pagination.html" %}
 {% endblock %}

+ 3 - 3
templates/games/game_list.html

@@ -5,10 +5,10 @@
 {% block content %}
      <div class="d-flex flex-column">
         <div class="image-grid-container">
-        {% for  game in object_list %}
-        <h4>{{game.created}}</h4>
+            {% for  game in object_list %}
+            <h4>{{game.created}}</h4>
             {% include 'games/_game_card.html' %}
-        {% endfor %}
+            {% endfor %}
         </div>
     </div>
 

+ 8 - 0
templates/games/gamecollection_detail.html

@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% block page_title %}{{object}}{% endblock %}
+
+{% block title %}{{object}}{% endblock %}
+
+{% block content %}
+{% include "games/_game_table.html" %}
+{% endblock %}

+ 27 - 0
templates/games/gamecollection_list.html

@@ -0,0 +1,27 @@
+{% extends "base.html" %}
+{% block page_title %}{{object}}{% endblock %}
+
+{% block title %}{{object}}{% endblock %}
+
+{% block content %}
+    <table class="table table-bordered table">
+    <thead>
+        <tr>
+        <th scope="col">#</th>
+        <th scope="col">Name</th>
+        <th scope="col">Games</th>
+        <th scope="col">Rating</th>
+        </tr>
+    </thead>
+    <tbody>
+        {% for  collection in object_list %}
+        <tr>
+        <th scope="row"><a href="{{collection.get_absolute_url}}">{{collection.id}}</a></th>
+        <td>{{collection.name}}</td>
+        <td>{{collection.games.count}}</td>
+        <td>{{collection.rating_avg}}/100</td>
+        </tr>
+        {% endfor %}
+    </tbody>
+    </table>
+{% endblock %}