diff --git a/backend/.vscode/launch.json b/backend/.vscode/launch.json index eef617a2..0f2e8f90 100644 --- a/backend/.vscode/launch.json +++ b/backend/.vscode/launch.json @@ -12,6 +12,6 @@ "args": ["runserver"], "django": true, "justMyCode": true - } + }, ] } diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json index f2291a5c..da5aa2f9 100644 --- a/backend/.vscode/settings.json +++ b/backend/.vscode/settings.json @@ -1,4 +1,5 @@ { "editor.tabSize": 4, - "editor.insertSpaces": true + "editor.insertSpaces": true, + "python.testing.pytestEnabled": true, } diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 00000000..d3b32653 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE=rorapp.settings +python_files=*tests.py \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index c69ac9a2..ed7dcd02 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,8 +5,9 @@ daphne==4.0.0 dj-database-url==2.0.0 Django==4.2.3 django-cors-headers==3.13.0 -django-debug-toolbar==4.1.0 djangorestframework==3.14.0 djangorestframework-simplejwt==5.2.2 psycopg2-binary==2.9.6 python-dotenv==0.21.0 +pytest==7.4.0 +pytest-django==4.7.0 diff --git a/backend/rorapp/admin/action.py b/backend/rorapp/admin/action.py index fe4f7f28..20b0ac3d 100644 --- a/backend/rorapp/admin/action.py +++ b/backend/rorapp/admin/action.py @@ -5,8 +5,16 @@ # Admin configuration for actions @admin.register(Action) class ActionAdmin(admin.ModelAdmin): - list_display = ("__str__", "game", "step", "faction", "type", "required", "completed") - list_filter = ("type", "required", "completed") + list_display = ( + "__str__", + "game", + "step", + "faction", + "type", + "required", + "completed", + ) + list_filter = ("required", "completed") search_fields = ("step__phase__turn__game__id", "step__id", "faction__id", "type") @admin.display(ordering="step__phase__turn__game__id") diff --git a/backend/rorapp/admin/senator.py b/backend/rorapp/admin/senator.py index bb1b2294..8e388f3d 100644 --- a/backend/rorapp/admin/senator.py +++ b/backend/rorapp/admin/senator.py @@ -5,4 +5,22 @@ # Admin configuration for senators @admin.register(Senator) class SenatorAdmin(admin.ModelAdmin): - list_display = ("__str__", 'name', 'game', 'faction', 'death_step', 'code', 'generation', 'rank') + list_display = ( + "__str__", + "game", + "faction", + "name", + "code", + "generation", + "death_step", + "rank", + ) + search_fields = ( + "game__id", + "faction__id", + "name", + "code", + "generation", + "death_step__id", + "rank", + ) diff --git a/backend/rorapp/functions/faction_leader_helper.py b/backend/rorapp/functions/faction_leader_helper.py index 7e92ae46..b41ba9c7 100644 --- a/backend/rorapp/functions/faction_leader_helper.py +++ b/backend/rorapp/functions/faction_leader_helper.py @@ -6,7 +6,6 @@ ) from rorapp.functions.mortality_phase_starter import setup_mortality_phase from rorapp.functions.websocket_message_helper import ( - send_websocket_messages, create_websocket_message, destroy_websocket_message, ) @@ -30,7 +29,7 @@ ) -def select_faction_leader(action_id, data) -> Response: +def select_faction_leader(action_id, data) -> (Response, dict): """ Select a faction leader. @@ -58,7 +57,7 @@ def select_faction_leader(action_id, data) -> Response: return set_faction_leader(senator.id) -def set_faction_leader(senator_id: int) -> Response: +def set_faction_leader(senator_id: int) -> (Response, dict): senator = Senator.objects.get(id=senator_id) game = Game.objects.get(id=senator.game.id) faction = Faction.objects.get(id=senator.faction.id) @@ -87,9 +86,8 @@ def set_faction_leader(senator_id: int) -> Response: messages_to_send.extend(proceed_to_next_step_if_faction_phase(game.id, step)) messages_to_send.extend(proceed_to_next_step_if_forum_phase(game.id, step, faction)) messages_to_send.extend(delete_old_actions(game.id)) - send_websocket_messages(game.id, messages_to_send) - return Response({"message": "Faction leader selected"}, status=200) + return Response({"message": "Faction leader selected"}, status=200), messages_to_send def get_previous_title(faction) -> Optional[Title]: diff --git a/backend/rorapp/functions/mortality_phase_helper.py b/backend/rorapp/functions/mortality_phase_helper.py index 77381286..ea3e2567 100644 --- a/backend/rorapp/functions/mortality_phase_helper.py +++ b/backend/rorapp/functions/mortality_phase_helper.py @@ -8,7 +8,6 @@ from rorapp.functions.mortality_chit_helper import draw_mortality_chits from rorapp.functions.rank_helper import rank_senators_and_factions from rorapp.functions.websocket_message_helper import ( - send_websocket_messages, create_websocket_message, destroy_websocket_message, ) @@ -33,7 +32,9 @@ ) -def face_mortality(action_id: int, chit_codes: List[int] | None = None) -> Response: +def face_mortality( + action_id: int, chit_codes: List[int] | None = None +) -> (Response, dict): """ Ready up for facing mortality. @@ -60,8 +61,7 @@ def face_mortality(action_id: int, chit_codes: List[int] | None = None) -> Respo if Action.objects.filter(step__id=action.step.id, completed=False).count() == 0: messages_to_send.extend(resolve_mortality(game.id, chit_codes)) - send_websocket_messages(game.id, messages_to_send) - return Response({"message": "Ready for mortality"}, status=200) + return Response({"message": "Ready for mortality"}, status=200), messages_to_send def resolve_mortality(game_id: int, chit_codes: List[int] | None = None) -> dict: diff --git a/backend/rorapp/tests/mortality_phase_tests.py b/backend/rorapp/tests/mortality_phase_tests.py index 54353f68..4d70b19b 100644 --- a/backend/rorapp/tests/mortality_phase_tests.py +++ b/backend/rorapp/tests/mortality_phase_tests.py @@ -127,7 +127,9 @@ def kill_hrao(self, game_id: int) -> None: game_id, "Temporary Rome Consul" )[0] - latest_action_log = self.kill_senators(game_id, [highest_ranking_senator.id])[0] + action_logs, messages = self.kill_senators(game_id, [highest_ranking_senator.id]) + self.assertEqual(len(messages), 18) + latest_action_log = action_logs[0] self.assertIsNone(latest_action_log.data["heir_senator"]) self.assertEqual( @@ -146,7 +148,9 @@ def kill_faction_leader(self, game_id: int) -> None: self.assertEqual(faction_leader.name, "Aurelius") faction_leader_title = Title.objects.get(senator=faction_leader) - latest_action_log = self.kill_senators(game_id, [faction_leader.id])[0] + action_logs, messages = self.kill_senators(game_id, [faction_leader.id]) + self.assertEqual(len(messages), 11) + latest_action_log = action_logs[0] heir_id = latest_action_log.data["heir_senator"] heir = Senator.objects.get(id=heir_id) @@ -170,7 +174,9 @@ def kill_regular_senator(self, game_id: int) -> None: ).count() regular_senator = self.get_senators_with_title(game_id, None)[0] - latest_action_log = self.kill_senators(game_id, [regular_senator.id])[0] + action_logs, messages = self.kill_senators(game_id, [regular_senator.id]) + self.assertEqual(len(messages), 8) + latest_action_log = action_logs[0] self.assertIsNone(latest_action_log.data["heir_senator"]) self.assertIsNone(latest_action_log.data["major_office"]) @@ -185,17 +191,18 @@ def kill_two_senators(self, game_id: int) -> None: ).count() two_regular_senators = self.get_senators_with_title(game_id, None)[0:2] senator_ids = [senator.id for senator in two_regular_senators] - self.kill_senators(game_id, senator_ids) + latest_action_log, messages = self.kill_senators(game_id, senator_ids) + self.assertEqual(len(messages), 14) post_death_living_senator_count = Senator.objects.filter( game=game_id, death_step=None ).count() self.assertEqual(living_senator_count - 2, post_death_living_senator_count) - def kill_senators(self, game_id: int, senator_ids: List[int]) -> List[ActionLog]: + def kill_senators(self, game_id: int, senator_ids: List[int]) -> (List[ActionLog], dict): senators = Senator.objects.filter(id__in=senator_ids) senator_codes = [senator.code for senator in senators] self.assertEqual(len(senator_codes), len(senator_ids)) - resolve_mortality(game_id, senator_codes) + messages = resolve_mortality(game_id, senator_codes) latest_step = Step.objects.filter(phase__turn__game=game_id).order_by("-index")[ 1 ] @@ -209,7 +216,7 @@ def kill_senators(self, game_id: int, senator_ids: List[int]) -> List[ActionLog] self.assertEqual( action_log.faction.position, matching_senator.faction.position ) - return latest_action_logs + return latest_action_logs, messages def get_senators_with_title( self, game_id: int, title_name: str | None diff --git a/backend/rorapp/views/submit_action.py b/backend/rorapp/views/submit_action.py index 1abc5594..38f6054c 100644 --- a/backend/rorapp/views/submit_action.py +++ b/backend/rorapp/views/submit_action.py @@ -1,8 +1,10 @@ from django.db import transaction +from django.http import HttpRequest from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response from rorapp.functions import face_mortality, select_faction_leader +from rorapp.functions import send_websocket_messages from rorapp.models import Game, Faction, Step, Action @@ -13,7 +15,7 @@ class SubmitActionViewSet(viewsets.ViewSet): @action(detail=True, methods=["post"]) @transaction.atomic - def submit_action(self, request, game_id, action_id=None): + def submit_action(self, request: HttpRequest, game_id: int, action_id: int | None =None): # Try to get the game try: game = Game.objects.get(id=game_id) @@ -51,11 +53,18 @@ def submit_action(self, request, game_id, action_id=None): {"message": "Action is not related to the current step"}, status=403 ) - # Action-specific logic + return self.perform_action(game.id, action, request) + + def perform_action(self, game_id: int, action: Action, request: HttpRequest) -> Response: + response = None + messages = None match action.type: case "select_faction_leader": - return select_faction_leader(action.id, request.data) + response, messages = select_faction_leader(action.id, request.data) case "face_mortality": - return face_mortality(action.id) + response, messages = face_mortality(action.id) case _: return Response({"message": "Action type is invalid"}, status=400) + + send_websocket_messages(game_id, messages) + return response diff --git a/backend/rorsite/middleware.py b/backend/rorsite/middleware.py deleted file mode 100644 index 8becdbe6..00000000 --- a/backend/rorsite/middleware.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.conf import settings -from debug_toolbar.middleware import DebugToolbarMiddleware - -class InternalIPsDebugToolbarMiddleware(DebugToolbarMiddleware): - def show_toolbar(self, request): - return settings.DEBUG diff --git a/backend/rorsite/settings.py b/backend/rorsite/settings.py index bb110bd0..a789e5dc 100644 --- a/backend/rorsite/settings.py +++ b/backend/rorsite/settings.py @@ -20,103 +20,87 @@ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent -load_dotenv(os.path.join(BASE_DIR, '.env')) +load_dotenv(os.path.join(BASE_DIR, ".env")) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.getenv('SECRET_KEY') +SECRET_KEY = os.getenv("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True if os.getenv('DEBUG') == "True" else False +DEBUG = True if os.getenv("DEBUG") == "True" else False -ALLOWED_HOSTS = [ - 'localhost', - '127.0.0.1', - 'api.roronline.com', - 'www.roronline.com' -] +ALLOWED_HOSTS = ["localhost", "127.0.0.1", "api.roronline.com", "www.roronline.com"] -CORS_ALLOWED_ORIGINS = [ - os.getenv('FRONTEND_ORIGIN') -] +CORS_ALLOWED_ORIGINS = [os.getenv("FRONTEND_ORIGIN")] -CSRF_TRUSTED_ORIGINS = [ - 'https://api.roronline.com', - 'http://127.0.0.1:8000' -] +CSRF_TRUSTED_ORIGINS = ["https://api.roronline.com", "http://127.0.0.1:8000"] -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # Application definition INSTALLED_APPS = [ - 'daphne', - 'rorapp.apps.RorappConfig', - 'rest_framework', - 'corsheaders', - 'channels', - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "daphne", + "rorapp.apps.RorappConfig", + "rest_framework", + "corsheaders", + "channels", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", ] -if 'test' not in sys.argv: - # Add debug toolbar app when not testing - INSTALLED_APPS.append('debug_toolbar') - MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - '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', + "corsheaders.middleware.CorsMiddleware", + "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", ] -if 'test' not in sys.argv: - # Add debug toolbar middleware when not testing - MIDDLEWARE.insert(0, 'rorsite.middleware.InternalIPsDebugToolbarMiddleware') - -ROOT_URLCONF = 'rorsite.urls' +ROOT_URLCONF = "rorsite.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": [], + "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 = 'rorsite.wsgi.application' +WSGI_APPLICATION = "rorsite.wsgi.application" # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases DATABASES = { - 'default': dj_database_url.config(default='postgres://{}:{}@{}:{}/{}'.format( - os.getenv('RDS_USERNAME'), - os.getenv('RDS_PASSWORD'), - os.getenv('RDS_HOSTNAME'), - os.getenv('RDS_PORT'), - os.getenv('RDS_NAME') - )) + "default": dj_database_url.config( + default="postgres://{}:{}@{}:{}/{}".format( + os.getenv("RDS_USERNAME"), + os.getenv("RDS_PASSWORD"), + os.getenv("RDS_HOSTNAME"), + os.getenv("RDS_PORT"), + os.getenv("RDS_NAME"), + ) + ) } @@ -125,16 +109,16 @@ 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", }, ] @@ -142,9 +126,9 @@ # Internationalization # https://docs.djangoproject.com/en/4.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -154,36 +138,29 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.1/howto/static-files/ -STATIC_URL = '/static/' -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Authentication REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', - 'rest_framework.authentication.BasicAuthentication', - 'rest_framework.authentication.SessionAuthentication', + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.BasicAuthentication", + "rest_framework.authentication.SessionAuthentication", ) } SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), - "REFRESH_TOKEN_LIFETIME": timedelta(days=1) -} - - -# Debug toolbar - -DEBUG_TOOLBAR_CONFIG = { - "SHOW_TOOLBAR_CALLBACK": lambda r: DEBUG, + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), } @@ -195,7 +172,7 @@ "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "hosts": [(os.getenv('REDIS_HOSTNAME'), 6379)], + "hosts": [(os.getenv("REDIS_HOSTNAME"), 6379)], }, }, } diff --git a/backend/rorsite/urls.py b/backend/rorsite/urls.py index 3398277e..4c9ecbf3 100644 --- a/backend/rorsite/urls.py +++ b/backend/rorsite/urls.py @@ -1,14 +1,4 @@ from django.urls import path, include from django.contrib import admin -from django.conf import settings -urlpatterns = [ - path('', include('rorapp.urls')), - path('admin/', admin.site.urls) -] - -if settings.DEBUG: - import debug_toolbar - urlpatterns = [ - path('__debug__/', include(debug_toolbar.urls)), - ] + urlpatterns +urlpatterns = [path("", include("rorapp.urls")), path("admin/", admin.site.urls)] diff --git a/frontend/components/GamePage.tsx b/frontend/components/GamePage.tsx index afbac9cc..c8dad043 100644 --- a/frontend/components/GamePage.tsx +++ b/frontend/components/GamePage.tsx @@ -566,6 +566,29 @@ const GamePage = (props: GamePageProps) => { } } + // Faction updates + if (message?.instance?.class === "faction") { + // Update a faction + if ( + message?.operation === "create" + ) { + const updatedInstance = deserializeToInstance( + Faction, + message.instance.data + ) + if (updatedInstance) { + setAllFactions((instances) => { + if (instances.allIds.includes(updatedInstance.id)) { + instances = instances.remove(updatedInstance.id) + return instances.add(updatedInstance) + } else { + return instances.add(updatedInstance) + } + }) + } + } + } + // Senator updates if (message?.instance?.class === "senator") { // Update a senator