diff --git a/api-collection/CreateAccount/Invalid identifier.bru b/api-collection/Auth/CreateAccount/Invalid identifier.bru similarity index 82% rename from api-collection/CreateAccount/Invalid identifier.bru rename to api-collection/Auth/CreateAccount/Invalid identifier.bru index 6eabf16..9f3a941 100644 --- a/api-collection/CreateAccount/Invalid identifier.bru +++ b/api-collection/Auth/CreateAccount/Invalid identifier.bru @@ -12,7 +12,7 @@ post { body:json { { - "account_identifier": "' 'DROP TABLE USERS;", + "unique_identifiers": "' 'DROP TABLE USERS;", "username": "My Name", "password": "qweasd123", "email": "myname@email.com" diff --git a/api-collection/CreateAccount/Valid.bru b/api-collection/Auth/CreateAccount/Valid.bru similarity index 86% rename from api-collection/CreateAccount/Valid.bru rename to api-collection/Auth/CreateAccount/Valid.bru index ec150aa..fb0b4fa 100644 --- a/api-collection/CreateAccount/Valid.bru +++ b/api-collection/Auth/CreateAccount/Valid.bru @@ -12,7 +12,7 @@ post { body:json { { - "account_identifier": "myname", + "unique_identifier": "myname", "username": "My Name", "password": "qweasd123", "email": "myname@email.com" diff --git a/api-collection/LoginWithCreds/non existent account.bru b/api-collection/Auth/LoginWithCreds/non existent account.bru similarity index 100% rename from api-collection/LoginWithCreds/non existent account.bru rename to api-collection/Auth/LoginWithCreds/non existent account.bru diff --git a/api-collection/LoginWithCreds/not verified.bru b/api-collection/Auth/LoginWithCreds/not verified.bru similarity index 100% rename from api-collection/LoginWithCreds/not verified.bru rename to api-collection/Auth/LoginWithCreds/not verified.bru diff --git a/api-collection/LoginWithCreds/success.bru b/api-collection/Auth/LoginWithCreds/success.bru similarity index 100% rename from api-collection/LoginWithCreds/success.bru rename to api-collection/Auth/LoginWithCreds/success.bru diff --git a/api-collection/LoginWithToken/invalid token.bru b/api-collection/Auth/LoginWithToken/invalid token.bru similarity index 100% rename from api-collection/LoginWithToken/invalid token.bru rename to api-collection/Auth/LoginWithToken/invalid token.bru diff --git a/api-collection/LoginWithToken/missing token.bru b/api-collection/Auth/LoginWithToken/missing token.bru similarity index 100% rename from api-collection/LoginWithToken/missing token.bru rename to api-collection/Auth/LoginWithToken/missing token.bru diff --git a/api-collection/LoginWithToken/success.bru b/api-collection/Auth/LoginWithToken/success.bru similarity index 100% rename from api-collection/LoginWithToken/success.bru rename to api-collection/Auth/LoginWithToken/success.bru diff --git a/api-collection/Characters/Create.bru b/api-collection/Characters/Create.bru new file mode 100644 index 0000000..6fdf5b6 --- /dev/null +++ b/api-collection/Characters/Create.bru @@ -0,0 +1,26 @@ +meta { + name: Create + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/persistence/characters/create + body: json + auth: none +} + +headers { + Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac +} + +body:json { + { + "character_sheet_version": "1.0.0", + "fork_compatibility": "Not compatible", + "data": { + "name": "My Name", + "age": 31 + } + } +} diff --git a/api-collection/Characters/Delete.bru b/api-collection/Characters/Delete.bru new file mode 100644 index 0000000..a4d03d2 --- /dev/null +++ b/api-collection/Characters/Delete.bru @@ -0,0 +1,15 @@ +meta { + name: Delete + type: http + seq: 5 +} + +delete { + url: {{baseUrl}}/persistence/characters/7/delete + body: none + auth: none +} + +headers { + Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac +} diff --git a/api-collection/Characters/GetAll.bru b/api-collection/Characters/GetAll.bru new file mode 100644 index 0000000..d0a8af7 --- /dev/null +++ b/api-collection/Characters/GetAll.bru @@ -0,0 +1,15 @@ +meta { + name: GetAll + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/persistence/characters + body: none + auth: none +} + +headers { + Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac +} diff --git a/api-collection/Characters/GetCharacter.bru b/api-collection/Characters/GetCharacter.bru new file mode 100644 index 0000000..5f62c64 --- /dev/null +++ b/api-collection/Characters/GetCharacter.bru @@ -0,0 +1,15 @@ +meta { + name: GetCharacter + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/persistence/characters/8 + body: none + auth: none +} + +headers { + Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac +} diff --git a/api-collection/Characters/GetCompatible.bru b/api-collection/Characters/GetCompatible.bru new file mode 100644 index 0000000..a5e067e --- /dev/null +++ b/api-collection/Characters/GetCompatible.bru @@ -0,0 +1,20 @@ +meta { + name: GetCompatible + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/persistence/characters/compatible?fork_compatibility=Unitystation&character_sheet_version=1.0.0 + body: none + auth: none +} + +query { + fork_compatibility: Unitystation + character_sheet_version: 1.0.0 +} + +headers { + Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac +} diff --git a/api-collection/Characters/Update.bru b/api-collection/Characters/Update.bru new file mode 100644 index 0000000..4ec818f --- /dev/null +++ b/api-collection/Characters/Update.bru @@ -0,0 +1,27 @@ +meta { + name: Update + type: http + seq: 6 +} + +patch { + url: {{baseUrl}}/persistence/characters/8/update + body: json + auth: none +} + +headers { + Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac +} + +body:json { + { + "id": 20, + "account": "AnotherAccount", + "fork_compatibility": "Not compatible", + "data": { + "age": 31, + "name": "Another name" + } + } +} diff --git a/src/accounts/admin.py b/src/accounts/admin.py index 8410661..3232d57 100644 --- a/src/accounts/admin.py +++ b/src/accounts/admin.py @@ -7,12 +7,11 @@ class AccountAdminView(admin.ModelAdmin): list_display = ( "email", - "account_identifier", + "is_active", + "unique_identifier", "username", "is_verified", "legacy_id", - "characters_data", - "is_authorized_server", ) fieldsets = ( ( @@ -21,18 +20,20 @@ class AccountAdminView(admin.ModelAdmin): "classes": ("wide",), "fields": ( "email", - "account_identifier", + "unique_identifier", "username", "verification_token", ), }, ), - ("Characters", {"classes": ("wide",), "fields": ("characters_data",)}), ( "Authorization", { "classes": ("wide",), - "fields": ("is_active", "is_verified", "is_authorized_server"), + "fields": ( + "is_active", + "is_verified", + ), }, ), ("Legacy", {"classes": ("wide",), "fields": ("legacy_id",)}), diff --git a/src/accounts/api/serializers.py b/src/accounts/api/serializers.py index 4db424c..795fbf3 100644 --- a/src/accounts/api/serializers.py +++ b/src/accounts/api/serializers.py @@ -10,19 +10,17 @@ class PublicAccountDataSerializer(serializers.ModelSerializer): class Meta: model = Account fields = ( - "account_identifier", + "unique_identifier", "username", "legacy_id", "is_verified", - "is_authorized_server", - "characters_data", ) class RegisterAccountSerializer(serializers.ModelSerializer): class Meta: model = Account - fields = ("account_identifier", "username", "password", "email") + fields = ("unique_identifier", "username", "password", "email") extra_kwargs = {"password": {"write_only": True}} def create(self, validated_data): @@ -68,23 +66,12 @@ def update(self, instance, validated_data): return instance -class UpdateCharactersSerializer(serializers.ModelSerializer): - class Meta: - model = Account - fields = ("characters_data",) - - def update(self, instance, validated_data): - instance.characters_data = validated_data.get("characters_data", instance.characters_data) - instance.save() - return instance - - class VerifyAccountSerializer(serializers.Serializer): - account_identifier = serializers.CharField() + unique_identifier = serializers.CharField() verification_token = serializers.UUIDField() def validate(self, data): - account = Account.objects.get(account_identifier=data["account_identifier"]) + account = Account.objects.get(unique_identifier=data["unique_identifier"]) data_token = data["verification_token"] account_token = account.verification_token diff --git a/src/accounts/api/urls.py b/src/accounts/api/urls.py index 60f7219..f8c0849 100644 --- a/src/accounts/api/urls.py +++ b/src/accounts/api/urls.py @@ -8,7 +8,6 @@ RegisterAccountView, RequestVerificationTokenView, UpdateAccountView, - UpdateCharactersView, VerifyAccountView, ) @@ -23,7 +22,6 @@ ), path("register", RegisterAccountView.as_view(), name="register"), path("update-account", UpdateAccountView.as_view(), name="update"), - path("update-characters", UpdateCharactersView.as_view(), name="update-characters"), path("account", PublicAccountDataView.as_view(), name="public-data"), path("account/", PublicAccountDataView.as_view(), name="public-data"), path("logout", knox_views.LogoutView.as_view(), name="logout"), diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 410b291..7506f45 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -16,7 +16,6 @@ PublicAccountDataSerializer, RegisterAccountSerializer, UpdateAccountSerializer, - UpdateCharactersSerializer, VerifyAccountSerializer, ) @@ -140,33 +139,6 @@ def post(self, request): return Response(serializer.data, status=status.HTTP_200_OK) -class UpdateCharactersView(GenericAPIView): - serializer_class = UpdateCharactersSerializer - - def post(self, request): - try: - account = Account.objects.get(pk=request.user.pk) - if request.user != account: - raise PermissionDenied - except ObjectDoesNotExist: - return Response({"error": "Account does not exist."}, status=status.HTTP_404_NOT_FOUND) - except PermissionDenied: - return Response( - {"error": "You have no permission to do this action."}, - status=status.HTTP_403_FORBIDDEN, - ) - - serializer = self.get_serializer(account, data=request.data) - try: - serializer.is_valid(raise_exception=True) - except ValidationError as e: - return Response(data={"error": str(e)}, status=e.status_code) - except Exception as e: - return Response(data={"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - - class RequestVerificationTokenView(GenericAPIView): def get(self, *args, **kwargs): verification_token = uuid4() @@ -185,7 +157,7 @@ def get(self, *args, **kwargs): account.save() return Response( { - "account_identifier": account.account_identifier, + "unique_identifier": account.unique_identifier, "verification_token": verification_token, }, status=status.HTTP_200_OK, @@ -205,7 +177,7 @@ def post(self, request): except Exception as e: return Response(data={"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - account = Account.objects.get(account_identifier=serializer.data["account_identifier"]) + account = Account.objects.get(unique_identifier=serializer.data["unique_identifier"]) public_data = PublicAccountDataSerializer(account).data return Response(public_data, status=status.HTTP_200_OK) diff --git a/src/accounts/migrations/0001_initial.py b/src/accounts/migrations/0001_initial.py index d97fdc2..bf4adae 100644 --- a/src/accounts/migrations/0001_initial.py +++ b/src/accounts/migrations/0001_initial.py @@ -1,10 +1,9 @@ -# Generated by Django 3.2.9 on 2022-02-07 03:55 +# Generated by Django 3.2.22 on 2023-11-11 22:10 import accounts.validators import django.contrib.auth.models from django.db import migrations, models import django.utils.timezone -import uuid class Migration(migrations.Migration): @@ -28,13 +27,11 @@ class Migration(migrations.Migration): ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('email', models.EmailField(help_text='Email address must be unique. It is used to login and confirm the account.', max_length=254, unique=True, verbose_name='Email address')), - ('account_identifier', models.CharField(help_text="Account identifier is used to identify your account. This will be used for bans, job bans, etc and can't ever be changed", max_length=28, primary_key=True, serialize=False, validators=[accounts.validators.AccountNameValidator()], verbose_name='Account identifier')), + ('unique_identifier', models.CharField(help_text="Unique identifier is used to identify your account. This will be used for bans, job bans, etc and can't ever be changed", max_length=28, primary_key=True, serialize=False, validators=[accounts.validators.AccountNameValidator()], verbose_name='Unique identifier')), ('username', models.CharField(help_text='Public username is used to identify your account publicly and shows in OOC. This can be changed at any time', max_length=28, validators=[accounts.validators.UsernameValidator()], verbose_name='Public username')), ('is_verified', models.BooleanField(default=False, help_text='Is this account verified to be who they claim to be? Are they famous?!', verbose_name='Verified')), ('legacy_id', models.CharField(blank=True, default='null', help_text="Legacy ID is used to identify your account in the old database. This is used for bans, job bans, etc and can't ever be changed", max_length=28, verbose_name='Legacy ID')), - ('characters_data', models.JSONField(default=dict, help_text='Characters data is used to store all the characters associated with this account.', verbose_name='Characters data')), - ('is_authorized_server', models.BooleanField(default=False, help_text='Can this account broadcast the server state to the server list api? Can this account write to persistence layer?', verbose_name='Authorized server')), - ('verification_token', models.UUIDField(blank=True, default=uuid.UUID('05e74bff-c4b1-4452-ac03-b20805ac4fef'), verbose_name='Verification token')), + ('verification_token', models.UUIDField(blank=True, null=True, verbose_name='Verification token')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), ], diff --git a/src/accounts/migrations/0002_alter_account_verification_token.py b/src/accounts/migrations/0002_alter_account_verification_token.py deleted file mode 100644 index e765f46..0000000 --- a/src/accounts/migrations/0002_alter_account_verification_token.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.2.12 on 2022-03-13 06:37 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='account', - name='verification_token', - field=models.UUIDField(blank=True, default=uuid.UUID('387d8683-ef09-4042-b9e5-1b487fb37152'), verbose_name='Verification token'), - ), - ] diff --git a/src/accounts/migrations/0003_alter_account_verification_token.py b/src/accounts/migrations/0003_alter_account_verification_token.py deleted file mode 100644 index bd451dc..0000000 --- a/src/accounts/migrations/0003_alter_account_verification_token.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.2.12 on 2022-03-13 06:41 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0002_alter_account_verification_token'), - ] - - operations = [ - migrations.AlterField( - model_name='account', - name='verification_token', - field=models.UUIDField(blank=True, default=uuid.UUID('e617579c-8c17-45f6-9994-f93d8f28cae6'), verbose_name='Verification token'), - ), - ] diff --git a/src/accounts/migrations/0004_alter_account_verification_token.py b/src/accounts/migrations/0004_alter_account_verification_token.py deleted file mode 100644 index ccfce44..0000000 --- a/src/accounts/migrations/0004_alter_account_verification_token.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.2.15 on 2023-10-23 15:34 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0003_alter_account_verification_token'), - ] - - operations = [ - migrations.AlterField( - model_name='account', - name='verification_token', - field=models.UUIDField(blank=True, default=uuid.UUID('6c74ff29-b92d-4175-8581-e91c38b84195'), verbose_name='Verification token'), - ), - ] diff --git a/src/accounts/migrations/0005_alter_account_verification_token.py b/src/accounts/migrations/0005_alter_account_verification_token.py deleted file mode 100644 index 43fcab6..0000000 --- a/src/accounts/migrations/0005_alter_account_verification_token.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.2.15 on 2023-10-23 15:44 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0004_alter_account_verification_token'), - ] - - operations = [ - migrations.AlterField( - model_name='account', - name='verification_token', - field=models.UUIDField(blank=True, default=uuid.UUID('32dfaa59-45fc-4c7a-bbd5-b195075e5f89'), verbose_name='Verification token'), - ), - ] diff --git a/src/accounts/migrations/0006_alter_account_verification_token.py b/src/accounts/migrations/0006_alter_account_verification_token.py deleted file mode 100644 index 91359f4..0000000 --- a/src/accounts/migrations/0006_alter_account_verification_token.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.2.22 on 2023-10-23 15:53 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0005_alter_account_verification_token'), - ] - - operations = [ - migrations.AlterField( - model_name='account', - name='verification_token', - field=models.UUIDField(blank=True, default=uuid.UUID('f4a76c91-a5b1-444e-af33-32c82ac1dc4b'), verbose_name='Verification token'), - ), - ] diff --git a/src/accounts/models.py b/src/accounts/models.py index 5790fc6..3ec431b 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -13,13 +13,13 @@ class Account(AbstractUser): help_text="Email address must be unique. It is used to login and confirm the account.", ) - account_identifier = models.CharField( - verbose_name="Account identifier", + unique_identifier = models.CharField( + verbose_name="Unique identifier", max_length=28, primary_key=True, validators=[AccountNameValidator()], help_text=( - "Account identifier is used to identify your account. " + "Unique identifier is used to identify your account. " "This will be used for bans, job bans, etc and can't ever be changed" ), ) @@ -52,29 +52,20 @@ class Account(AbstractUser): ), ) - characters_data = models.JSONField( - verbose_name="Characters data", - default=dict, - help_text="Characters data is used to store all the characters associated with this account.", - ) - - is_authorized_server = models.BooleanField( - default=False, - verbose_name="Authorized server", - help_text=( - "Can this account broadcast the server state to the server list api? " - "Can this account write to persistence layer?" - ), - ) - verification_token = models.UUIDField( verbose_name="Verification token", blank=True, - default=uuid4(), + null=True, ) USERNAME_FIELD = "email" - REQUIRED_FIELDS = ["account_identifier", "username"] + REQUIRED_FIELDS = ["unique_identifier", "username"] + + def save(self, *args, **kwargs): + if self.verification_token is None: + self.verification_token = uuid4() + + super().save(*args, **kwargs) def __str__(self): - return f"{self.account_identifier} as {self.username}" + return f"{self.unique_identifier} as {self.username}" diff --git a/src/persistence/admin.py b/src/persistence/admin.py index 3bbf9ca..e948d1c 100644 --- a/src/persistence/admin.py +++ b/src/persistence/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Other, PolyPhrase +from .models import Character, Other, PolyPhrase @admin.register(Other) @@ -11,3 +11,8 @@ class OtherAdminView(admin.ModelAdmin): @admin.register(PolyPhrase) class PolyPhraseAdminView(admin.ModelAdmin): pass + + +@admin.register(Character) +class CharacterAdminView(admin.ModelAdmin): + pass diff --git a/src/persistence/api/serializers.py b/src/persistence/api/serializers.py index 047c9db..ff98635 100644 --- a/src/persistence/api/serializers.py +++ b/src/persistence/api/serializers.py @@ -1,6 +1,39 @@ from rest_framework import serializers -from ..models import Other, PolyPhrase +from ..models import Character, Other, PolyPhrase + + +class CompatibleCharactersRequestSerializer(serializers.Serializer): + fork_compatibility = serializers.CharField(max_length=25) + character_sheet_version = serializers.CharField(max_length=10) + + +class CharacterSerializer(serializers.ModelSerializer): + class Meta: + model = Character + fields = ( + "id", + "account", + "fork_compatibility", + "character_sheet_version", + "data", + ) + + read_only_fields = ("id", "last_updated") + + +class UpdateCharacterSerializer(serializers.ModelSerializer): + class Meta: + model = Character + fields = ( + "id", + "account", + "fork_compatibility", + "character_sheet_version", + "data", + ) + + read_only_fields = ("id", "account", "last_updated") class OtherSerializer(serializers.ModelSerializer): diff --git a/src/persistence/api/urls.py b/src/persistence/api/urls.py index f38192d..f0b30fc 100644 --- a/src/persistence/api/urls.py +++ b/src/persistence/api/urls.py @@ -1,8 +1,14 @@ from django.urls import path from .views import ( + CreateCharacterView, + DeleteCharacterView, + GetAllCharactersByAccountView, + GetCharacterByIdView, + GetCompatibleCharacters, RandomPolyPhraseView, ReadOtherDataView, + UpdateCharacterView, WriteOtherDataView, WritePolyPhraseView, ) @@ -10,6 +16,12 @@ app_name = "persistence" urlpatterns = [ + path("characters", GetAllCharactersByAccountView.as_view(), name="characters-all"), + path("characters/create", CreateCharacterView.as_view(), name="characters-create"), + path("characters/", GetCharacterByIdView.as_view(), name="characters-by-id"), + path("characters/compatible", GetCompatibleCharacters.as_view(), name="characters-compatible"), + path("characters//update", UpdateCharacterView.as_view(), name="characters-patch"), + path("characters//delete", DeleteCharacterView.as_view(), name="characters-delete"), path("other-data/read", ReadOtherDataView.as_view(), name="read"), path("other-data/write", WriteOtherDataView.as_view(), name="write"), path("poly-says", RandomPolyPhraseView.as_view(), name="poly-says"), diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index 846ef46..efccf00 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -1,11 +1,145 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from rest_framework import permissions, status from rest_framework.exceptions import ValidationError -from rest_framework.generics import GenericAPIView +from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.response import Response -from ..models import Other, PolyPhrase -from .serializers import OtherSerializer, PolyPhraseSerializer +from ..models import Character, Other, PolyPhrase +from .serializers import ( + CharacterSerializer, + CompatibleCharactersRequestSerializer, + OtherSerializer, + PolyPhraseSerializer, + UpdateCharacterSerializer, +) + + +class GetCharacterByIdView(GenericAPIView): + serializer_class = CharacterSerializer + + def get_queryset(self): + return Character.objects.filter(account__unique_identifier=self.request.user.unique_identifier) + + def get(self, request, pk): + try: + character = Character.objects.get(pk=pk) + except ObjectDoesNotExist: + data = {"error": "No character with this ID could be found!"} + return Response(data, status=status.HTTP_404_NOT_FOUND) + except PermissionDenied: + data = {"error": "You do not have permission to view this character!"} + return Response(data, status=status.HTTP_403_FORBIDDEN) + serializer = self.serializer_class(character) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class GetCompatibleCharacters(ListAPIView): + serializer_class = CharacterSerializer + + def get_queryset(self): + """ + Retrieves a list of compatible characters. + + Query Parameters: + - fork_compatibility: The fork compatibility string. + - character_sheet_version: The version string of the character sheet. + + Example usage: + /api/characters/fork_compatibility=Unitystation&character_sheet_version=1.0.0 + """ + query_serializer = CompatibleCharactersRequestSerializer(data=self.request.query_params) + if not query_serializer.is_valid(): + raise ValidationError(query_serializer.errors) + + fork_compatibility = query_serializer.validated_data["fork_compatibility"] + character_sheet_version = query_serializer.validated_data["character_sheet_version"] + + queryset = Character.objects.filter( + account__unique_identifier=self.request.user.pk, + fork_compatibility=fork_compatibility, + character_sheet_version=character_sheet_version, + ) + + return queryset + + +class GetAllCharactersByAccountView(ListAPIView): + serializer_class = CharacterSerializer + + def get_queryset(self): + """ + Retrieves a list of all characters of an account. + """ + unique_identifier = self.request.user.pk + queryset = Character.objects.filter(account__unique_identifier=unique_identifier) + return queryset + + +class UpdateCharacterView(GenericAPIView): + serializer_class = UpdateCharacterSerializer + queryset = Character.objects.all() + + def patch(self, request, pk): + try: + character = Character.objects.get(pk=pk) + if character.account != request.user: + raise PermissionDenied + + except ObjectDoesNotExist: + data = {"error": "No character with this ID could be found!"} + return Response(data, status=status.HTTP_404_NOT_FOUND) + except PermissionDenied: + data = {"error": "You do not have permission to edit this character!"} + return Response(data, status=status.HTTP_403_FORBIDDEN) + + serializer = self.get_serializer(character, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class DeleteCharacterView(GenericAPIView): + serializer_class = CharacterSerializer + + def delete(self, request, pk): + try: + character = Character.objects.get(pk=pk) + if character.account != request.user: + raise PermissionDenied + + except ObjectDoesNotExist: + data = {"error": "No character with this ID could be found!"} + return Response(data, status=status.HTTP_404_NOT_FOUND) + except PermissionDenied: + data = {"error": "You do not have permission to delete this character!"} + return Response(data, status=status.HTTP_403_FORBIDDEN) + + character.delete() + data = {"success": "Character deleted successfully!"} + return Response(data, status=status.HTTP_200_OK) + + +class CreateCharacterView(GenericAPIView): + serializer_class = CharacterSerializer + + def post(self, request): + data_with_account = request.data.copy() + data_with_account["account"] = request.user.pk + + serializer = self.serializer_class(data=data_with_account) + serializer.account = request.user + try: + serializer.is_valid(raise_exception=True) + except ValidationError as e: + data = {"error": str(e)} + return Response(data, status=status.HTTP_400_BAD_REQUEST) + except PermissionDenied: + data = {"error": "You do not have permission to write this data!"} + return Response(data, status=status.HTTP_403_FORBIDDEN) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) class ReadOtherDataView(GenericAPIView): diff --git a/src/persistence/migrations/0001_initial.py b/src/persistence/migrations/0001_initial.py index d738357..8766789 100644 --- a/src/persistence/migrations/0001_initial.py +++ b/src/persistence/migrations/0001_initial.py @@ -1,7 +1,9 @@ -# Generated by Django 3.2.9 on 2022-02-07 03:55 +# 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): @@ -10,6 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('accounts', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -28,4 +31,14 @@ class Migration(migrations.Migration): ('phrase', models.CharField(max_length=128)), ], ), + 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 new file mode 100644 index 0000000..6b47916 --- /dev/null +++ b/src/persistence/migrations/0002_character_last_updated.py @@ -0,0 +1,18 @@ +# 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/persistence/models.py b/src/persistence/models.py index b4a97fc..46d2052 100644 --- a/src/persistence/models.py +++ b/src/persistence/models.py @@ -1,5 +1,39 @@ from django.db import models +from .validators import validate_semantic_version + + +class Character(models.Model): + account = models.ForeignKey("accounts.Account", on_delete=models.CASCADE) + """To what account/server is this character related to?""" + + fork_compatibility = models.CharField( + max_length=25, + help_text='What fork is this character compatible with? This is a simple string, like "Unitystation" or ' + '"tg".', + default="Unitystation", + ) + + character_sheet_version = models.CharField( + max_length=10, + help_text="What character sheet version is this character compatible with? This should be semantically " + 'versioned, like "1.0.0" or "0.1.0".', + validators=[validate_semantic_version], + ) + + data = models.JSONField( + name="data", verbose_name="Character data", help_text="Unstructured character data in JSON format." + ) + """The character data.""" + + last_updated = models.DateTimeField( + auto_now=True, + help_text="When was this character last updated? Useful for conciliation with the local cache of a character.", + ) + + def __str__(self): + return f"{self.account.unique_identifier}'s character" + class Other(models.Model): account = models.OneToOneField("accounts.Account", on_delete=models.CASCADE, primary_key=True) diff --git a/src/persistence/validators.py b/src/persistence/validators.py new file mode 100644 index 0000000..3e0ed2a --- /dev/null +++ b/src/persistence/validators.py @@ -0,0 +1,8 @@ +import re + +from django.core.exceptions import ValidationError + + +def validate_semantic_version(version: str) -> None: + if not re.match(r"^\d+\.\d+\.\d+$", version): + raise ValidationError(f"{version} is not a valid semantic version. Format should be: MAJOR.MINOR.PATCH")