Ver Fonte

Add initial games app

Colin Powell há 3 anos atrás
pai
commit
59791d2323

+ 39 - 35
emus/settings.py

@@ -20,7 +20,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
 # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
 
 # SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = 'l2-2d4dmvb0un0s)=5z%c87t*tg_hu&bt6*o^ks9r7f-3(mp$$'
+SECRET_KEY = "l2-2d4dmvb0un0s)=5z%c87t*tg_hu&bt6*o^ks9r7f-3(mp$$"
 
 # SECURITY WARNING: don't run with debug turned on in production!
 DEBUG = True
@@ -31,52 +31,54 @@ ALLOWED_HOSTS = []
 # Application definition
 
 INSTALLED_APPS = [
-    'django.contrib.admin',
-    'django.contrib.auth',
-    'django.contrib.contenttypes',
-    'django.contrib.sessions',
-    'django.contrib.messages',
-    'django.contrib.staticfiles',
+    "django.contrib.admin",
+    "django.contrib.auth",
+    "django.contrib.contenttypes",
+    "django.contrib.sessions",
+    "django.contrib.messages",
+    "django.contrib.staticfiles",
+    "django_extensions",
+    "games",
 ]
 
 MIDDLEWARE = [
-    'django.middleware.security.SecurityMiddleware',
-    'django.contrib.sessions.middleware.SessionMiddleware',
-    'django.middleware.common.CommonMiddleware',
-    'django.middleware.csrf.CsrfViewMiddleware',
-    'django.contrib.auth.middleware.AuthenticationMiddleware',
-    'django.contrib.messages.middleware.MessageMiddleware',
-    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+    "django.middleware.security.SecurityMiddleware",
+    "django.contrib.sessions.middleware.SessionMiddleware",
+    "django.middleware.common.CommonMiddleware",
+    "django.middleware.csrf.CsrfViewMiddleware",
+    "django.contrib.auth.middleware.AuthenticationMiddleware",
+    "django.contrib.messages.middleware.MessageMiddleware",
+    "django.middleware.clickjacking.XFrameOptionsMiddleware",
 ]
 
-ROOT_URLCONF = 'emus.urls'
+ROOT_URLCONF = "emus.urls"
 
 TEMPLATES = [
     {
-        'BACKEND': 'django.template.backends.django.DjangoTemplates',
-        'DIRS': [],
-        'APP_DIRS': True,
-        'OPTIONS': {
-            'context_processors': [
-                'django.template.context_processors.debug',
-                'django.template.context_processors.request',
-                'django.contrib.auth.context_processors.auth',
-                'django.contrib.messages.context_processors.messages',
+        "BACKEND": "django.template.backends.django.DjangoTemplates",
+        "DIRS": [str(BASE_DIR.joinpath("templates"))],  # new
+        "APP_DIRS": True,
+        "OPTIONS": {
+            "context_processors": [
+                "django.template.context_processors.debug",
+                "django.template.context_processors.request",
+                "django.contrib.auth.context_processors.auth",
+                "django.contrib.messages.context_processors.messages",
             ],
         },
     },
 ]
 
-WSGI_APPLICATION = 'emus.wsgi.application'
+WSGI_APPLICATION = "emus.wsgi.application"
 
 
 # Database
 # https://docs.djangoproject.com/en/3.1/ref/settings/#databases
 
 DATABASES = {
-    'default': {
-        'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': BASE_DIR / 'db.sqlite3',
+    "default": {
+        "ENGINE": "django.db.backends.sqlite3",
+        "NAME": BASE_DIR / "db.sqlite3",
     }
 }
 
@@ -86,16 +88,16 @@ DATABASES = {
 
 AUTH_PASSWORD_VALIDATORS = [
     {
-        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
     },
     {
-        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
     },
     {
-        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
     },
     {
-        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
     },
 ]
 
@@ -103,9 +105,9 @@ AUTH_PASSWORD_VALIDATORS = [
 # Internationalization
 # https://docs.djangoproject.com/en/3.1/topics/i18n/
 
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = "en-us"
 
-TIME_ZONE = 'UTC'
+TIME_ZONE = "UTC"
 
 USE_I18N = True
 
@@ -117,4 +119,6 @@ USE_TZ = True
 # Static files (CSS, JavaScript, Images)
 # https://docs.djangoproject.com/en/3.1/howto/static-files/
 
-STATIC_URL = '/static/'
+STATIC_URL = "/static/"
+MEDIA_URL = "/media/"
+MEDIA_ROOT = "/home/powellc/RetroPie/roms"

+ 2 - 17
emus/urls.py

@@ -1,21 +1,6 @@
-"""emus URL Configuration
-
-The `urlpatterns` list routes URLs to views. For more information please see:
-    https://docs.djangoproject.com/en/3.1/topics/http/urls/
-Examples:
-Function views
-    1. Add an import:  from my_app import views
-    2. Add a URL to urlpatterns:  path('', views.home, name='home')
-Class-based views
-    1. Add an import:  from other_app.views import Home
-    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
-Including another URLconf
-    1. Import the include() function: from django.urls import include, path
-    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
-"""
 from django.contrib import admin
-from django.urls import path
+from django.urls import path, include
 
 urlpatterns = [
-    path('admin/', admin.site.urls),
+    path("admin/", admin.site.urls),
 ]

+ 0 - 0
games/__init__.py


+ 15 - 0
games/admin.py

@@ -0,0 +1,15 @@
+from django.contrib import admin
+
+from games.models import Developer, Game, GameSystem, Genre, Publisher
+
+
+class GameAdmin(admin.ModelAdmin):
+    list_display = ("name", "game_system", "english_patched", "undub", "region")
+    list_filter = ("game_system", "undub", "english_patched", "hack")
+
+
+admin.site.register(GameSystem)
+admin.site.register(Developer)
+admin.site.register(Publisher)
+admin.site.register(Genre)
+admin.site.register(Game, GameAdmin)

+ 6 - 0
games/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class GamesConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'games'

+ 1 - 0
games/management/__init__.py

@@ -0,0 +1 @@
+#!/usr/bin/env python3

+ 1 - 0
games/management/commands/__init__.py

@@ -0,0 +1 @@
+#!/usr/bin/env python3

+ 65 - 0
games/management/commands/export_gamelist_xml_file.py

@@ -0,0 +1,65 @@
+import xml.etree.ElementTree as ET
+
+from django.core.management.base import BaseCommand, CommandError
+
+from dateutil import parser
+from games.models import Developer, Game, GameSystem, Genre, Publisher
+
+
+def export_gamelist_file_to_path_for_system(file_path, game_system):
+    exported_games = []
+    root = ET.Element("gameList")
+
+    tree = ET.ElementTree(root)
+    tree.write("filename.xml")
+    games = Game.objects.filter(game_system=game_system)
+    for game in games:
+        game_node = ET.SubElement(root, "game")
+
+        genre_str = ", ".join(game.genre.all().values_list("name", flat=True))
+        release_date_str = ""
+        if game.release_date:
+            release_date_str = game.release_date.strftime("%Y%m%dT00000")
+        ET.SubElement(game_node, "path").text = (
+            game.rom_file.path if game.rom_file else ""
+        )
+        ET.SubElement(game_node, "name").text = game.name
+        ET.SubElement(game_node, "thumbnail").text = ""
+        ET.SubElement(game_node, "image").text = (
+            game.screenshot.path if game.screenshot else ""
+        )
+        ET.SubElement(game_node, "marquee").text = (
+            game.marquee.path if game.marquee else ""
+        )
+        ET.SubElement(game_node, "video").text = game.video.path if game.video else ""
+        ET.SubElement(game_node, "rating").text = str(game.rating)
+        ET.SubElement(game_node, "desc").text = game.description
+        ET.SubElement(game_node, "releasedate").text = release_date_str
+        ET.SubElement(game_node, "developer").text = str(game.developer)
+        ET.SubElement(game_node, "publisher").text = str(game.publisher)
+        ET.SubElement(game_node, "genre").text = genre_str
+        ET.SubElement(game_node, "players").text = str(game.players)
+        if game.kid_game:
+            ET.SubElement(game_node, "kidgame").text = "true"
+
+        exported_games.append(game)
+    tree = ET.ElementTree(root)
+    tree.write(file_path, xml_declaration=True, encoding="utf-8")
+
+    return exported_games
+
+
+class Command(BaseCommand):
+    help = "Export all games found to a given gamelist XML file"
+
+    def add_arguments(self, parser):
+        parser.add_argument("file_path", nargs="+", type=str)
+        parser.add_argument("system", nargs="+", type=str)
+
+    def handle(self, *args, **options):
+        game_system = GameSystem.objects.get(retropie_slug=options["system"][0])
+        games = export_gamelist_file_to_path_for_system(
+            options["file_path"][0],
+            game_system,
+        )
+        self.stdout.write(self.style.SUCCESS(f"Successfully exported {len(games)}"))

+ 105 - 0
games/management/commands/import_gamelist_xml_file.py

@@ -0,0 +1,105 @@
+import xml.etree.ElementTree as ET
+
+from django.core.management.base import BaseCommand, CommandError
+
+from dateutil import parser
+from games.models import Developer, Game, GameSystem, Genre, Publisher
+
+US_STRINGS = ["(u)", "(usa)", "(us)"]
+JP_STRINGS = ["(j)", "japan", "jp"]
+EU_STRINGS = ["(e)", "eur", "europe", "pal"]
+
+
+def import_gamelist_file_to_db_for_system(file_path, game_system):
+    imported_games = []
+    gamelist = ET.parse(file_path)
+
+    games = gamelist.findall("game")
+    for game in games:
+        name = game.find("name").text
+        english_patched = "patched" in name.lower()
+        undub = "undub" in name.lower()
+        hack = "hack" in name.lower()
+
+        region = None
+
+        if any(us in name.lower() for us in US_STRINGS):
+            region = Game.Region.US.name
+        if any(jp in name.lower() for jp in JP_STRINGS):
+            region = Game.Region.JP.name
+        if any(eu in name.lower() for eu in EU_STRINGS):
+            region = Game.Region.EU.name
+
+        release_date_str = game.find("releasedate").text
+        developer_str = game.find("developer").text
+        publisher_str = game.find("publisher").text
+        genres_str = game.find("genre").text
+
+        rating_str = game.find("rating").text
+        players_str = game.find("players").text
+        try:
+            kid_game = game.find("kidgame").text == "true"
+        except AttributeError:
+            kid_game = False
+
+        genre_str_list = genres_str.split(", ")
+        genre_list = []
+        if genres_str:
+            for genre_str in genre_str_list:
+                genre, _created = Genre.objects.get_or_create(name=genre_str)
+                genre_list.append(genre)
+
+        players = int(players_str) if players_str else 1
+        rating = float(rating_str) if rating_str else None
+        publisher = None
+        if publisher_str:
+            publisher, _created = Publisher.objects.get_or_create(name=publisher_str)
+        developer = None
+        if developer_str:
+            developer, _created = Developer.objects.get_or_create(name=developer_str)
+        release_date = parser.parse(release_date_str) if release_date_str else None
+        screenshot_path = game.find("image").text
+        rom_path = game.find("path").text
+        video_path = game.find("video").text
+        marquee_path = game.find("marquee").text
+        description = game.find("desc").text
+
+        obj, created = Game.objects.get_or_create(name=name)
+
+        obj.game_system = game_system
+        obj.developer = developer
+        obj.publisher = publisher
+        obj.players = players
+        obj.description = description
+        obj.release_date = release_date
+        obj.rating = rating
+        obj.genre.set(genre_list)
+        obj.screenshot = screenshot_path
+        obj.rom_file = rom_path
+        obj.video = video_path
+        obj.marquee = marquee_path
+        obj.kid_game = kid_game
+        obj.english_patched = english_patched
+        obj.hack = hack
+        obj.undub = undub
+        obj.region = region
+        obj.save()
+
+        imported_games.append(game)
+    return imported_games
+
+
+class Command(BaseCommand):
+    help = "Import all games found in a given gamelist XML file"
+
+    def add_arguments(self, parser):
+        parser.add_argument("file_path", nargs="+", type=str)
+        parser.add_argument("system", nargs="+", type=str)
+
+    def handle(self, *args, **options):
+        game_system = GameSystem.objects.get(retropie_slug=options["system"][0])
+        games = import_gamelist_file_to_db_for_system(
+            options["file_path"][0],
+            game_system,
+        )
+        self.stdout.write(self.style.SUCCESS(f"Successfully imported {len(games)}"))

+ 87 - 0
games/migrations/0001_initial.py

@@ -0,0 +1,87 @@
+# Generated by Django 4.0.3 on 2022-03-31 04:12
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import django_extensions.db.fields
+import games.models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Developer',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from='name')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='GameSystem',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from='name')),
+                ('retropie_slug', models.CharField(blank=True, max_length=50, null=True)),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='Genre',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from='name')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='Publisher',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from='name')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='Game',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from='name')),
+                ('release_date', models.DateTimeField(blank=True, null=True)),
+                ('players', models.SmallIntegerField()),
+                ('kid_game', models.BooleanField(default=False)),
+                ('description', models.TextField(blank=True, null=True)),
+                ('rating', models.FloatField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(1), django.core.validators.MinValueValidator(0)])),
+                ('video', models.FileField(blank=True, null=True, upload_to=games.models.get_video_upload_path)),
+                ('marquee', models.FileField(blank=True, null=True, upload_to=games.models.get_marquee_upload_path)),
+                ('screenshot', models.FileField(blank=True, null=True, upload_to=games.models.get_screenshot_upload_path)),
+                ('rom_file', models.FileField(blank=True, null=True, upload_to=games.models.get_rom_upload_path)),
+                ('developer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='games.publisher')),
+                ('game_system', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='games.gamesystem')),
+                ('genre', models.ManyToManyField(to='games.genre')),
+                ('publisher', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='games.developer')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+    ]

+ 18 - 0
games/migrations/0002_alter_game_players.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.0.3 on 2022-03-31 04:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('games', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='game',
+            name='players',
+            field=models.SmallIntegerField(default=1),
+        ),
+    ]

+ 24 - 0
games/migrations/0003_alter_game_developer_alter_game_publisher.py

@@ -0,0 +1,24 @@
+# Generated by Django 4.0.3 on 2022-03-31 04:55
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('games', '0002_alter_game_players'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='game',
+            name='developer',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='games.developer'),
+        ),
+        migrations.AlterField(
+            model_name='game',
+            name='publisher',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='games.publisher'),
+        ),
+    ]

+ 33 - 0
games/migrations/0004_game_english_patched_game_english_patched_version_and_more.py

@@ -0,0 +1,33 @@
+# Generated by Django 4.0.3 on 2022-03-31 05:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('games', '0003_alter_game_developer_alter_game_publisher'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='game',
+            name='english_patched',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='game',
+            name='english_patched_version',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AddField(
+            model_name='game',
+            name='hack',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='game',
+            name='hack_version',
+            field=models.CharField(blank=True, max_length=255, null=True),
+        ),
+    ]

+ 33 - 0
games/migrations/0005_game_region_game_undub.py

@@ -0,0 +1,33 @@
+# Generated by Django 4.0.3 on 2022-03-31 06:05
+
+from django.db import migrations, models
+import games.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("games", "0004_game_english_patched_game_english_patched_version_and_more"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="game",
+            name="region",
+            field=models.CharField(
+                choices=[
+                    ("US", "USA"),
+                    ("EU", "Europe"),
+                    ("JP", "Japan"),
+                    ("X", "Unknown"),
+                ],
+                default=games.models.Game.Region["X"],
+                max_length=2,
+            ),
+        ),
+        migrations.AddField(
+            model_name="game",
+            name="undub",
+            field=models.BooleanField(default=False),
+        ),
+    ]

+ 27 - 0
games/migrations/0006_alter_game_region.py

@@ -0,0 +1,27 @@
+# Generated by Django 4.0.3 on 2022-03-31 06:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("games", "0005_game_region_game_undub"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="game",
+            name="region",
+            field=models.CharField(
+                blank=True,
+                choices=[
+                    ("US", "USA"),
+                    ("EU", "Europe"),
+                    ("JP", "Japan"),
+                ],
+                max_length=2,
+                null=True,
+            ),
+        ),
+    ]

+ 0 - 0
games/migrations/__init__.py


+ 162 - 0
games/models.py

@@ -0,0 +1,162 @@
+from enum import Enum
+
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.conf import settings
+
+from django_extensions.db.fields import AutoSlugField
+from games.utils import ChoiceEnum
+
+
+def get_screenshot_upload_path(instance, filename):
+    return f"/{instance.game_system.retropie_slug}/screenshots/{filename}"
+
+
+def get_marquee_upload_path(instance, filename):
+    return f"/{instance.game_system.retropie_slug}/marquee/{filename}"
+
+
+def get_video_upload_path(instance, filename):
+    return f"/{instance.game_system.retropie_slug}/videos/{filename}"
+
+
+def get_rom_upload_path(instance, filename):
+    return f"/{instance.game_system.retropie_slug}/{filename}"
+
+
+class Region(Enum):
+    USA = "US"
+    EUROPE = "EU"
+    JAPAN = "JP"
+
+
+class BaseModel(models.Model):
+    """A base model for providing name and slugged fields for organizational models"""
+
+    name = models.CharField(max_length=255)
+    slug = AutoSlugField(populate_from="name")
+
+    class Meta:
+        abstract = True
+
+    def slugify_function(self, content):
+        return content.replace("_", "-").lower()
+
+    def __str__(self):
+        return self.name
+
+
+class Genre(BaseModel):
+    ...
+
+
+class Publisher(BaseModel):
+    ...
+
+
+class Developer(BaseModel):
+    ...
+
+
+class GameSystem(BaseModel):
+    retropie_slug = models.CharField(
+        blank=True,
+        null=True,
+        max_length=50,
+    )
+
+
+class Game(BaseModel):
+    class Region(ChoiceEnum):
+        US = "USA"
+        EU = "Europe"
+        JP = "Japan"
+        X = "Unknown"
+
+    game_system = models.ForeignKey(
+        GameSystem,
+        on_delete=models.SET_NULL,
+        null=True,
+    )
+    release_date = models.DateTimeField(
+        blank=True,
+        null=True,
+    )
+    developer = models.ForeignKey(
+        Developer,
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+    )
+    publisher = models.ForeignKey(
+        Publisher,
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+    )
+    genre = models.ManyToManyField(
+        Genre,
+    )
+    players = models.SmallIntegerField(
+        default=1,
+    )
+    kid_game = models.BooleanField(
+        default=False,
+    )
+    description = models.TextField(
+        blank=True,
+        null=True,
+    )
+    rating = models.FloatField(
+        blank=True,
+        null=True,
+        validators=[MaxValueValidator(1), MinValueValidator(0)],
+    )
+    video = models.FileField(
+        blank=True,
+        null=True,
+        upload_to=get_video_upload_path,
+    )
+    marquee = models.FileField(
+        blank=True,
+        null=True,
+        upload_to=get_marquee_upload_path,
+    )
+    screenshot = models.FileField(
+        blank=True,
+        null=True,
+        upload_to=get_screenshot_upload_path,
+    )
+    rom_file = models.FileField(
+        blank=True,
+        null=True,
+        upload_to=get_rom_upload_path,
+    )
+    hack = models.BooleanField(
+        default=False,
+    )
+    hack_version = models.CharField(
+        max_length=255,
+        blank=True,
+        null=True,
+    )
+    english_patched = models.BooleanField(
+        default=False,
+    )
+    english_patched_version = models.CharField(
+        max_length=50,
+        blank=True,
+        null=True,
+    )
+    undub = models.BooleanField(
+        default=False,
+    )
+    region = models.CharField(
+        max_length=2,
+        choices=Region.choices(),
+        blank=True,
+        null=True,
+    )
+
+    def __str__(self):
+        return f"{self.name} for {self.game_system}"

+ 3 - 0
games/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 1 - 0
games/urls.py

@@ -0,0 +1 @@
+#!/usr/bin/env python3

+ 7 - 0
games/utils.py

@@ -0,0 +1,7 @@
+from enum import Enum
+
+
+class ChoiceEnum(Enum):
+    @classmethod
+    def choices(cls):
+        return tuple((x.name, x.value) for x in cls)

+ 9 - 0
games/views.py

@@ -0,0 +1,9 @@
+from django.shortcuts import render
+
+from django.views.generic import ListView
+
+from ..games.models import Game
+
+
+class GameList(ListView):
+    model = Game

+ 43 - 1
poetry.lock

@@ -38,6 +38,36 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
 argon2 = ["argon2-cffi (>=19.1.0)"]
 bcrypt = ["bcrypt"]
 
+[[package]]
+name = "django-extensions"
+version = "3.1.5"
+description = "Extensions for Django"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+Django = ">=2.2"
+
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
 [[package]]
 name = "sqlparse"
 version = "0.4.2"
@@ -57,7 +87,7 @@ python-versions = ">=2"
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.8"
-content-hash = "4365ca19a8d8ba3865b42425f223755d584bdab5c538c7615b624bea6b6c6b5b"
+content-hash = "940ba0ba20765fcece803e2a2db780aa96866fb798add03c6987ce919d92d816"
 
 [metadata.files]
 asgiref = [
@@ -86,6 +116,18 @@ django = [
     {file = "Django-4.0.3-py3-none-any.whl", hash = "sha256:1239218849e922033a35d2a2f777cb8bee18bd725416744074f455f34ff50d0c"},
     {file = "Django-4.0.3.tar.gz", hash = "sha256:77ff2e7050e3324c9b67e29b6707754566f58514112a9ac73310f60cd5261930"},
 ]
+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"},
+]
+python-dateutil = [
+    {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+    {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+]
+six = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
 sqlparse = [
     {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"},
     {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"},

+ 2 - 0
pyproject.toml

@@ -7,6 +7,8 @@ authors = ["Colin Powell <colin@unbl.ink>"]
 [tool.poetry.dependencies]
 python = "^3.8"
 Django = "^4.0.3"
+django-extensions = "^3.1.5"
+python-dateutil = "^2.8.2"
 
 [tool.poetry.dev-dependencies]
 

+ 27 - 0
templates/base.html

@@ -0,0 +1,27 @@
+<!doctype html>
+<html class="no-js" lang="">
+    <head>
+        <meta charset="utf-8">
+        <meta http-equiv="x-ua-compatible" content="ie=edge">
+        <title>Emus - EmulationStation Web Interface</title>
+        <meta name="description" content="">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+
+        <link rel="apple-touch-icon" href="/apple-touch-icon.png">
+        <!-- Place favicon.ico in the root directory -->
+
+    </head>
+    <body>
+        <!--[if lt IE 8]>
+            <p class="browserupgrade">
+            You are using an <strong>outdated</strong> browser. Please
+            <a href="http://browsehappy.com/">upgrade your browser</a> to improve
+            your experience.
+            </p>
+        <![endif]-->
+        <h1>{% block title %}{% endblock %}</h1>
+
+        {% block content %}
+        {% endblock %}
+    </body>
+</html>

+ 6 - 0
templates/games/list.html

@@ -0,0 +1,6 @@
+{% extends 'base.html' %}
+
+{% block title %}Games{% endblock %}
+{% block content %}
+    <p>Something should go here.</p>
+{% endblock %}