diff --git a/dev-compose.yml b/dev-compose.yml index 03b6a8b..e98b6e0 100644 --- a/dev-compose.yml +++ b/dev-compose.yml @@ -10,6 +10,11 @@ services: ports: - "5432:5432" + memcached: + image: memcached:1.6-alpine + ports: + - "11211:11211" + web: depends_on: - db @@ -23,5 +28,3 @@ services: volumes: db-data: - static-volume: - media-volume: diff --git a/docker-compose.yml b/docker-compose.yml index cdbacbe..af753e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,9 @@ services: volumes: - db-data:/var/lib/postgresql/data + memcached: + image: memcached:1.6-alpine + web: image: unitystation/central-command:latest environment: diff --git a/example.env b/example.env index c30f9a8..922cdc1 100644 --- a/example.env +++ b/example.env @@ -13,6 +13,8 @@ DB_ENGINE=django.db.backends.postgresql DB_NAME=postgres DB_HOST=db DB_PORT=5432 +MEMCACHED_LOCATION=127.0.0.1 +MEMCACHED_PORT=11211 # Links to stuff WEBSITE_URL = http://localhost:8000 diff --git a/pyproject.toml b/pyproject.toml index 4f40742..e254ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "psycopg2-binary~=2.9.9", "python-dotenv~=0.19.2", "whitenoise~=6.6.0", + "pymemcache>=4.0.0", ] [build-system] diff --git a/src/central_command/settings.py b/src/central_command/settings.py index 8fe78b7..a03f986 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -51,6 +51,7 @@ "post_office", "accounts", "persistence", + "server", "drf_spectacular", ] @@ -154,6 +155,16 @@ } } +MEMCACHED_HOST = os.environ.get("MEMCACHED_HOST", "127.0.0.1") +MEMCACHED_PORT = os.environ.get("MEMCACHED_PORT", 11211) + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + } +} + REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_AUTHENTICATION_CLASSES": ["knox.auth.TokenAuthentication"], diff --git a/src/persistence/migrations/0001_initial.py b/src/persistence/migrations/0001_initial.py deleted file mode 100644 index 1fb9dab..0000000 --- a/src/persistence/migrations/0001_initial.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.2.22 on 2023-11-11 22:10 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import persistence.validators - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('accounts', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Character', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('fork_compatibility', models.CharField(default='Unitystation', help_text='What fork is this character compatible with? This is a simple string, like "Unitystation" or "tg".', max_length=25)), - ('character_sheet_version', models.CharField(help_text='What character sheet version is this character compatible with? This should be semantically versioned, like "1.0.0" or "0.1.0".', max_length=10, validators=[persistence.validators.validate_semantic_version])), - ('data', models.JSONField(help_text='Unstructured character data in JSON format.', verbose_name='Character data')), - ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/src/persistence/migrations/0002_character_last_updated.py b/src/persistence/migrations/0002_character_last_updated.py deleted file mode 100644 index 6b47916..0000000 --- a/src/persistence/migrations/0002_character_last_updated.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.22 on 2023-11-11 22:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('persistence', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='character', - name='last_updated', - field=models.DateTimeField(auto_now=True, help_text='When was this character last updated? Useful for conciliation with the local cache of a character.'), - ), - ] diff --git a/src/server/__init__.py b/src/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/admin.py b/src/server/admin.py new file mode 100644 index 0000000..e0d7b6f --- /dev/null +++ b/src/server/admin.py @@ -0,0 +1,37 @@ +from django.contrib import admin + +from .models import ( + AccountModerationInfo, + CodeScanInformation, + ServerAdmonition, + ServerInformation, +) + + +class ServerAdmonitionInline(admin.TabularInline): + model = ServerAdmonition + extra = 1 + raw_id_fields = ["server"] + fields = ["server", "created_at", "reason", "severity"] + readonly_fields = ["created_at"] + + +@admin.register(CodeScanInformation) +class CodeScanInformationAdmin(admin.ModelAdmin): + list_display = ["version"] + + +@admin.register(ServerInformation) +class ServerInformationAdmin(admin.ModelAdmin): + list_display = ["name", "owner", "is_18_plus", "is_delisted"] + search_fields = ["name", "owner__username"] + list_filter = ["is_18_plus", "is_delisted"] + raw_id_fields = ["owner", "code_scan_version"] + + +@admin.register(AccountModerationInfo) +class AccountModerationInfoAdmin(admin.ModelAdmin): + list_display = ["account", "can_create_servers", "can_list_servers"] + search_fields = ["account__username"] + list_filter = ["can_create_servers", "can_list_servers"] + raw_id_fields = ["account"] diff --git a/src/server/api/__init__.py b/src/server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/api/urls.py b/src/server/api/urls.py new file mode 100644 index 0000000..ae94233 --- /dev/null +++ b/src/server/api/urls.py @@ -0,0 +1 @@ +app_name = "server" diff --git a/src/server/api/views.py b/src/server/api/views.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/apps.py b/src/server/apps.py new file mode 100644 index 0000000..40baeb8 --- /dev/null +++ b/src/server/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ServerConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "server" diff --git a/src/server/management/__init__.py b/src/server/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/management/commands/__init__.py b/src/server/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/management/commands/create_moderation_info.py b/src/server/management/commands/create_moderation_info.py new file mode 100644 index 0000000..4b8f1eb --- /dev/null +++ b/src/server/management/commands/create_moderation_info.py @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand + +from accounts.models import Account +from server.models import AccountModerationInfo + + +class Command(BaseCommand): + help = "creates AccountModerationInfo for existing accounts" + + def handle(self, *args, **options): + for account in Account.objects.all(): + AccountModerationInfo.objects.get_or_create(account=account) + self.stdout.write(self.style.SUCCESS("Successfully created AccountModerationInfo for all accounts")) diff --git a/src/server/migrations/0001_initial.py b/src/server/migrations/0001_initial.py new file mode 100644 index 0000000..ef79813 --- /dev/null +++ b/src/server/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 3.2.24 on 2024-06-19 10:17 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AccountModerationInfo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('can_create_servers', models.BooleanField(default=True)), + ('can_list_servers', models.BooleanField(default=True)), + ('account', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='moderation_info', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='CodeScanInformation', + fields=[ + ('version', models.TextField(primary_key=True, serialize=False)), + ], + ), + migrations.CreateModel( + name='ServerInformation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField(help_text='Name this server uses to present itself in the servers list', max_length=50, verbose_name='Name')), + ('description', models.TextField(help_text='A brief description of what this server is about', max_length=200, verbose_name='Description')), + ('icon', models.URLField(blank=True, help_text='URL of the image this server uses to present itself in the servers list', verbose_name='Icon')), + ('rules', models.TextField(blank=True, help_text='The rules that players must follow on this server', verbose_name='Rules')), + ('motd', models.TextField(help_text='Message displayed to players when they join the server', verbose_name='Message of the Day (MOTD)')), + ('is_18_plus', models.BooleanField(default=False, help_text='Indicates if this server is intended for players aged 18 and above', verbose_name='18+')), + ('is_delisted', models.BooleanField(help_text='Indicates if this server is delisted from the servers list', verbose_name='Is Delisted')), + ('listing_key', models.TextField(blank=True, help_text='A unique key used for listing this server. Do not lose this key!', null=True, unique=True, verbose_name='Listing Key')), + ('code_scan_version', models.ForeignKey(help_text='What version should this build of the game be tested against in CodeScan', on_delete=django.db.models.deletion.PROTECT, to='server.codescaninformation', verbose_name='CodeScan Version')), + ('owner', models.ForeignKey(help_text='Who created and/or is responsible for this server', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Owner')), + ], + ), + migrations.CreateModel( + name='ServerAdmonition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='When was this admonition created', verbose_name='Created at')), + ('reason', models.TextField(help_text='Why was this server warned?', verbose_name='Reason')), + ('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')], default='low', help_text='The severity level of the admonition', max_length=6)), + ('owner_moderation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admonitions', to='server.accountmoderationinfo')), + ('server', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='admonitions', to='server.serverinformation')), + ], + ), + ] diff --git a/src/server/migrations/0002_auto_20241019_2259.py b/src/server/migrations/0002_auto_20241019_2259.py new file mode 100644 index 0000000..097ef90 --- /dev/null +++ b/src/server/migrations/0002_auto_20241019_2259.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.25 on 2024-10-20 01:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('server', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='serverinformation', + name='description', + field=models.CharField(help_text='A brief description of what this server is about', max_length=200, verbose_name='Description'), + ), + migrations.AlterField( + model_name='serverinformation', + name='listing_key', + field=models.CharField(blank=True, help_text='A unique key used for listing this server. Do not lose this key!', max_length=30, null=True, unique=True, verbose_name='Listing Key'), + ), + migrations.AlterField( + model_name='serverinformation', + name='motd', + field=models.CharField(help_text='Message displayed to players when they join the server', max_length=255, verbose_name='Message of the Day (MOTD)'), + ), + migrations.AlterField( + model_name='serverinformation', + name='name', + field=models.CharField(help_text='Name this server uses to present itself in the servers list', max_length=50, verbose_name='Name'), + ), + migrations.CreateModel( + name='ServerTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField(max_length=50, verbose_name='Name')), + ('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='server.serverinformation')), + ], + ), + ] diff --git a/src/server/migrations/__init__.py b/src/server/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/models.py b/src/server/models.py new file mode 100644 index 0000000..ff0f865 --- /dev/null +++ b/src/server/models.py @@ -0,0 +1,169 @@ +import secrets + +from django.db import models +from django.db.models.signals import pre_save +from django.dispatch import receiver + +from accounts.models import Account + + +class CodeScanInformation(models.Model): + """Information regarding the CodeScan that will be used to scan the code for this build after downloading on clients""" + + version = models.TextField(primary_key=True) + + def __str__(self): + return self.version + + +def generate_listing_key(): + # 22 bytes produce 30 characters + return secrets.token_urlsafe(22) + + +class ServerInformation(models.Model): + """Basic information that describes and identifies a server""" + + owner = models.ForeignKey( + verbose_name="Owner", + to=Account, + on_delete=models.CASCADE, + help_text="Who created and/or is responsible for this server", + ) + name = models.CharField( + verbose_name="Name", + max_length=50, + help_text="Name this server uses to present itself in the servers list", + ) + description = models.CharField( + verbose_name="Description", + max_length=200, + help_text="A brief description of what this server is about", + ) + icon = models.URLField( + verbose_name="Icon", + blank=True, + help_text="URL of the image this server uses to present itself in the servers list", + ) + rules = models.TextField( + verbose_name="Rules", + blank=True, + help_text="The rules that players must follow on this server", + ) + motd = models.CharField( + verbose_name="Message of the Day (MOTD)", + help_text="Message displayed to players when they join the server", + max_length=255, + ) + is_18_plus = models.BooleanField( + verbose_name="18+", + default=False, + help_text="Indicates if this server is intended for players aged 18 and above", + ) + code_scan_version = models.ForeignKey( + verbose_name="CodeScan Version", + to=CodeScanInformation, + on_delete=models.PROTECT, + help_text="What version should this build of the game be tested against in CodeScan", + ) + is_delisted = models.BooleanField( + verbose_name="Is Delisted", + help_text="Indicates if this server is delisted from the servers list", + ) + listing_key = models.CharField( + unique=True, + verbose_name="Listing Key", + null=True, + blank=True, + max_length=30, + help_text="A unique key used for listing this server. Do not lose this key!", + ) + + def __str__(self): + return f"Server: {self.name} by: {self.owner.unique_identifier}" + + +# Binds the save event of ServerInformation model to this function so that the key is generated every time a new ServerInformation is created +@receiver(pre_save, sender=ServerInformation) +def set_listing_key(sender, instance: ServerInformation, **kwargs): + if not instance.listing_key: + unique_key_found = False + while not unique_key_found: + new_key = generate_listing_key() + if not ServerInformation.objects.filter(listing_key=new_key).exists(): + instance.listing_key = new_key + unique_key_found = True + + +class ServerTag(models.Model): + """Represents an individual tag a server could have attached to it to make them easier to find by categories""" + + server = models.ForeignKey(to=ServerInformation, related_name="tags", on_delete=models.CASCADE) + name = models.TextField(verbose_name="Name", max_length=50) + + # class ServerStatus(models.Model): + # server = models.ForeignKey( + # ServerInformation, related_name="status", on_delete=models.CASCADE + # ) + # is_passworded = models.BooleanField() + # fork_name = models.TextField() + # build_version = models.TextField() + # current_map = models.TextField() + # game_mode = models.TextField() + # ingame_time = models.TextField() + # round_time = models.TextField() + # player_count = models.PositiveSmallIntegerField() + # player_count_max = models.PositiveSmallIntegerField() + # ip = models.TextField() + # port = models.PositiveSmallIntegerField() + # windows_download = models.URLField() + # osx_download = models.URLField() + # linux_download = models.URLField() + # fps = models.PositiveSmallIntegerField() + + def __str__(self) -> str: + return self.name + + +class AccountModerationInfo(models.Model): + """Information an account has permanently attached to it for the purpose of moderating their behaviour on the hub""" + + account = models.OneToOneField(to=Account, related_name="moderation_info", on_delete=models.CASCADE) + can_create_servers = models.BooleanField(default=True) + can_list_servers = models.BooleanField(default=True) + + def __str__(self): + return f"Moderation for {self.account.unique_identifier}" + + +class ServerAdmonition(models.Model): + """Represents a warning or reprimand an account received for their misbehaviour or mismanagement of their server""" + + SEVERITY_LEVELS = [ + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ] + + owner_moderation = models.ForeignKey(AccountModerationInfo, related_name="admonitions", on_delete=models.CASCADE) + server = models.ForeignKey( + ServerInformation, + related_name="admonitions", + on_delete=models.SET_NULL, + null=True, + ) + created_at = models.DateTimeField( + verbose_name="Created at", + auto_now_add=True, + help_text="When was this admonition created", + ) + reason = models.TextField(verbose_name="Reason", help_text="Why was this server warned?") + severity = models.CharField( + max_length=6, + choices=SEVERITY_LEVELS, + default="low", + help_text="The severity level of the admonition", + ) + + def __str__(self): + return f"[{self.get_severity_display()}]: {self.reason}" diff --git a/uv.lock b/uv.lock index 9fdcec6..84e3e40 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,7 @@ dependencies = [ { name = "drf-spectacular" }, { name = "gunicorn" }, { name = "psycopg2-binary" }, + { name = "pymemcache" }, { name = "python-dotenv" }, { name = "whitenoise" }, ] @@ -71,6 +72,7 @@ requires-dist = [ { name = "drf-spectacular", specifier = "~=0.27.1" }, { name = "gunicorn", specifier = "~=20.1.0" }, { name = "psycopg2-binary", specifier = "~=2.9.9" }, + { name = "pymemcache", specifier = ">=4.0.0" }, { name = "python-dotenv", specifier = "~=0.19.2" }, { name = "whitenoise", specifier = "~=6.6.0" }, ] @@ -479,6 +481,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, ] +[[package]] +name = "pymemcache" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/b6/4541b664aeaad025dfb8e851dcddf8e25ab22607e674dd2b562ea3e3586f/pymemcache-4.0.0.tar.gz", hash = "sha256:27bf9bd1bbc1e20f83633208620d56de50f14185055e49504f4f5e94e94aff94", size = 70176 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/ba/2f7b22d8135b51c4fefb041461f8431e1908778e6539ff5af6eeaaee367a/pymemcache-4.0.0-py2.py3-none-any.whl", hash = "sha256:f507bc20e0dc8d562f8df9d872107a278df049fa496805c1431b926f3ddd0eab", size = 60772 }, +] + [[package]] name = "python-dotenv" version = "0.19.2"