diff --git a/.idea/charasheet.iml b/.idea/charasheet.iml index 22a11d0..955f981 100644 --- a/.idea/charasheet.iml +++ b/.idea/charasheet.iml @@ -18,6 +18,7 @@ + diff --git a/pyproject.toml b/pyproject.toml index 5497ef6..c274afb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ [tool.pytest.ini_options] addopts = """ --html=test_reports/pytest_result/pytest.html --color=yes --durations 20 ---no-cov-on-fail --strict-markers +--no-cov-on-fail --strict-markers --reuse-db --driver=Firefox -W error -W "ignore:capabilities and desired_capabilities have been deprecated:DeprecationWarning:pytest_selenium.pytest_selenium" @@ -68,3 +68,6 @@ flake8-bandit = [ "-S106", # Possible hardcoded password. "-S311", # Standard pseudo-random generators are not suitable for security/cryptographic purposes. ] +flake8-bugbear = [ + "-B011", # Do not call assert False since python -O removes these calls. +] diff --git a/src/common/static/style.css b/src/common/static/style.css index b6f990a..5fddcf2 100644 --- a/src/common/static/style.css +++ b/src/common/static/style.css @@ -22,3 +22,18 @@ img.profile-pic { max-height: 240px; object-fit: cover; } + +#effects-list { + display: flex; + flex-direction: row; +} + +.effect { + width: 100px; + margin-right: 3em; + align-self: flex-end; +} + +.effect .bar { + background-color: red; +} diff --git a/src/conftest.py b/src/conftest.py index 9c84d8f..7fbdccf 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -1,5 +1,6 @@ import pytest from django.core.management import call_command +from selenium.webdriver.remote.webdriver import WebDriver @pytest.fixture(scope="session", autouse=True) @@ -7,12 +8,25 @@ def collectstatic(): call_command("collectstatic", "--clear", "--noinput", "--verbosity=0") +@pytest.fixture +def live_server(settings, live_server): + settings.STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage" + return live_server + + @pytest.fixture def firefox_options(firefox_options): firefox_options.add_argument("-headless") return firefox_options +@pytest.fixture +def selenium(selenium: WebDriver) -> WebDriver: + selenium.implicitly_wait(3) + selenium.set_window_size(3860, 2140) + return selenium + + @pytest.fixture(autouse=True) def settings(settings): settings.DEBUG_TOOLBAR = False diff --git a/src/party/forms.py b/src/party/forms.py index 553845f..22f6b49 100644 --- a/src/party/forms.py +++ b/src/party/forms.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError from django.db.models import Q from character.models import Character -from party.models import Party +from party.models import BattleEffect, Party class PartyForm(forms.ModelForm): @@ -38,3 +38,9 @@ class PartyForm(forms.ModelForm): ValidationError(f"{character} is already a group member."), ) return invited + + +class BattleEffectForm(forms.ModelForm): + class Meta: + model = BattleEffect + fields = ["name", "target", "description", "remaining_rounds"] diff --git a/src/party/migrations/0003_battleeffect.py b/src/party/migrations/0003_battleeffect.py new file mode 100644 index 0000000..0c0bb64 --- /dev/null +++ b/src/party/migrations/0003_battleeffect.py @@ -0,0 +1,79 @@ +# Generated by Django 4.1.5 on 2023-01-16 16:21 + +import django.db.models.deletion +import django_extensions.db.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("party", "0002_party_invited_characters"), + ] + + operations = [ + migrations.CreateModel( + name="BattleEffect", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=100, verbose_name="nom")), + ("target", models.CharField(max_length=100, verbose_name="cible")), + ( + "description", + models.TextField(blank=True, verbose_name="description"), + ), + ( + "remaining_rounds", + models.SmallIntegerField( + default=-1, + help_text="-1 pour un effet permanent", + verbose_name="nombre de tours restants", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="effects", + to=settings.AUTH_USER_MODEL, + verbose_name="créé par", + ), + ), + ( + "party", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="effects", + to="party.party", + verbose_name="groupe", + ), + ), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + ] diff --git a/src/party/migrations/max_migration.txt b/src/party/migrations/max_migration.txt index 9b5eb09..a567840 100644 --- a/src/party/migrations/max_migration.txt +++ b/src/party/migrations/max_migration.txt @@ -1 +1 @@ -0002_party_invited_characters +0003_battleeffect diff --git a/src/party/models.py b/src/party/models.py index 9ba30de..fd541b1 100644 --- a/src/party/models.py +++ b/src/party/models.py @@ -14,6 +14,12 @@ class PartyQuerySet(models.QuerySet): def played_by(self, user): return self.filter(characters__in=Character.objects.filter(player=user)) + def played_or_mastered_by(self, user): + return self.filter( + Q(game_master=user) + | Q(characters__in=Character.objects.filter(player=user)) + ).distinct() + def related_to(self, user): return self.filter( Q(game_master=user) @@ -61,3 +67,36 @@ class Party(UniquelyNamedModel, TimeStampedModel, models.Model): def reset_stats(self): for character in self.characters.all(): character.reset_stats() + + +class BattleEffectManager(models.Manager): + def decrease_all_remaining_rounds(self): + pass + + +class BattleEffect(TimeStampedModel, models.Model): + name = models.CharField(max_length=100, blank=False, null=False, verbose_name="nom") + target = models.CharField( + max_length=100, blank=False, null=False, verbose_name="cible" + ) + description = models.TextField(blank=True, null=False, verbose_name="description") + remaining_rounds = models.SmallIntegerField( + blank=False, + default=-1, + verbose_name="nombre de tours restants", + help_text="-1 pour un effet permanent", + ) + party = models.ForeignKey( + "party.Party", + on_delete=models.CASCADE, + related_name="effects", + verbose_name="groupe", + ) + created_by = models.ForeignKey( + "common.User", + on_delete=models.CASCADE, + related_name="effects", + verbose_name="créé par", + ) + + objects = BattleEffectManager() diff --git a/src/party/templates/party/party_details.html b/src/party/templates/party/party_details.html index 9f96930..d884043 100644 --- a/src/party/templates/party/party_details.html +++ b/src/party/templates/party/party_details.html @@ -8,7 +8,8 @@

{{ party.name }}

MJ : {{ party.game_master.get_full_name|default:party.game_master.username }}

- Réinitialiser les stats + Réinitialiser + les stats

Personnages

@@ -28,4 +29,6 @@
{% endif %} {% endwith %} +

Combat

+ {% include "party/snippets/effects.html" %} {% endblock %} diff --git a/src/party/templates/party/snippets/add_effect_form.html b/src/party/templates/party/snippets/add_effect_form.html new file mode 100644 index 0000000..b3d3318 --- /dev/null +++ b/src/party/templates/party/snippets/add_effect_form.html @@ -0,0 +1,8 @@ +{% load django_bootstrap5 %} +
+ {% csrf_token %} + {% bootstrap_form form %} + +
diff --git a/src/party/templates/party/snippets/effects.html b/src/party/templates/party/snippets/effects.html new file mode 100644 index 0000000..b4f8866 --- /dev/null +++ b/src/party/templates/party/snippets/effects.html @@ -0,0 +1,22 @@ +
+
+ +
+
+ {% for effect in party.effects.all %} +
+
+
{{ effect.name }}
+
sur : {{ effect.target }}
+
{{ effect.description }}
+
+ {% endfor %} +
+
diff --git a/src/party/tests/test_interactions.py b/src/party/tests/test_interactions.py index 889911c..8ad7604 100644 --- a/src/party/tests/test_interactions.py +++ b/src/party/tests/test_interactions.py @@ -1,8 +1,8 @@ import pytest -from django.core.management import call_command from django.urls import reverse from model_bakery import baker from pytest_django.live_server_helper import LiveServer +from selenium.webdriver import Keys from selenium.webdriver.common.by import By from selenium.webdriver.firefox.webdriver import WebDriver from selenium.webdriver.support.select import Select @@ -10,13 +10,13 @@ from selenium.webdriver.support.select import Select from character.models import Character, Profile from character.tests.test_interactions import create_hurt_character, login from common.models import User -from party.models import Party +from party.models import BattleEffect, Party @pytest.mark.django_db -def test_add_character_to_existing_group(selenium: WebDriver, live_server: LiveServer): - call_command("loaddata", "initial_data") - +def test_add_character_to_existing_group( + selenium: WebDriver, live_server: LiveServer, initial_data: None +): username, password = "gm", "password" gm = User.objects.create_user(username, password=password) player = User.objects.create_user("player") @@ -40,10 +40,8 @@ def test_add_character_to_existing_group(selenium: WebDriver, live_server: LiveS @pytest.mark.django_db def test_gm_observe_invited_character_in_group( - selenium: WebDriver, live_server: LiveServer + selenium: WebDriver, live_server: LiveServer, initial_data: None ): - call_command("loaddata", "initial_data") - username, password = "gm", "password" gm = User.objects.create_user(username, password=password) player = User.objects.create_user("player") @@ -66,10 +64,8 @@ def test_gm_observe_invited_character_in_group( @pytest.mark.django_db def test_gm_observe_invited_character_in_two_groups( - selenium: WebDriver, live_server: LiveServer + selenium: WebDriver, live_server: LiveServer, initial_data: None ): - call_command("loaddata", "initial_data") - username, password = "gm", "password" gm = User.objects.create_user(username, password=password) player = User.objects.create_user("player") @@ -118,3 +114,102 @@ def test_reset_stats_view( assert character.mana_remaining == character.mana_max assert character.recovery_points_remaining == character.recovery_points_max assert character.luck_points_remaining == character.luck_points_max + + +@pytest.mark.django_db +def test_player_can_add_effect_to_group( + selenium: WebDriver, live_server: LiveServer, initial_data: None +): + """Any member of a group can add effects to the group.""" + user, password = "player", "password" + player = User.objects.create_user(user, password=password) + character = baker.make(Character, player=player) + party = baker.make(Party) + party.characters.add(character) + + assert BattleEffect.objects.count() == 0 + + login(selenium, live_server, user, password) + + url = reverse("party:details", kwargs={"pk": party.pk}) + selenium.get(live_server.url + url) + selenium.find_element(By.ID, "add-effect").click() + selenium.find_element(By.ID, "id_name").send_keys("Agrandissement") + selenium.find_element(By.ID, "id_target").send_keys("Joueur 4") + selenium.find_element(By.ID, "id_description").send_keys( + "Le Magicien ou une cible volontaire (au contact) voit sa taille augmenter de " + "50% pendant [5 + Mod. d'INT] tours. Il gagne +2 aux DM au contact et aux " + "tests de FOR. Pataud, il subit un malus de -2 aux tests de DEX." + ) + selenium.find_element(By.ID, "id_remaining_rounds").send_keys(Keys.DELETE, "8") + selenium.find_element(By.CSS_SELECTOR, "button[type=submit]").click() + + assert BattleEffect.objects.count() == 1 + # Todo: assert effect is displayed + + +@pytest.mark.django_db +def test_gm_can_add_effect_to_group( + selenium: WebDriver, live_server: LiveServer, initial_data: None +): + """The GM of a group can add effects to the group.""" + user, password = "gm", "password" + gm = User.objects.create_user(user, password=password) + party = baker.make(Party, game_master=gm) + + assert BattleEffect.objects.count() == 0 + + login(selenium, live_server, user, password) + + url = reverse("party:details", kwargs={"pk": party.pk}) + selenium.get(live_server.url + url) + selenium.find_element(By.ID, "add-effect").click() + selenium.find_element(By.ID, "id_name").send_keys("Brûlé") + selenium.find_element(By.ID, "id_target").send_keys("Boss 2") + selenium.find_element(By.ID, "id_description").send_keys( + "Le Magicien choisit une cible située à moins de 30 mètres. Si son attaque " + "magique réussit, la cible encaisse [1d6 + Mod. d'INT] DM et la flèche " + "enflamme ses vêtements. Chaque tour de combat suivant, le feu inflige 1d6 " + "dégâts supplémentaires. Sur un résultat de 1 à 2, les flammes s'éteignent et " + "le sort prend fin." + ) + selenium.find_element(By.ID, "id_remaining_rounds").send_keys(Keys.DELETE, "-1") + selenium.find_element(By.CSS_SELECTOR, "button[type=submit]").click() + + assert BattleEffect.objects.count() == 1 + # Todo: assert effect is displayed + + +@pytest.mark.django_db +def test_gm_can_change_remaining_rounds( + selenium: WebDriver, live_server: LiveServer, initial_data: None +): + """The GM of a group can increase or decrease the remaining rounds of effects.""" + + +@pytest.mark.django_db +def test_gm_can_update_existing_effect( + selenium: WebDriver, live_server: LiveServer, initial_data: None +): + """The GM of a group can update existing effect, except group and creator.""" + + +@pytest.mark.django_db +def test_gm_can_delete_any_existing_effect( + selenium: WebDriver, live_server: LiveServer, initial_data: None +): + """The GM of a group can delete any existing effect, running or terminated.""" + + +@pytest.mark.django_db +def test_player_cant_change_existing_running_effect( + selenium: WebDriver, live_server: LiveServer, initial_data: None +): + """Members of the group can only view existing running effects, no update.""" + + +@pytest.mark.django_db +def test_player_can_delete_terminated_effect( + selenium: WebDriver, live_server: LiveServer, initial_data: None +): + """Members of the group can delete terminated effects.""" diff --git a/src/party/urls.py b/src/party/urls.py index c7c3d8a..b5708e7 100644 --- a/src/party/urls.py +++ b/src/party/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path("/change/", views.party_change, name="change"), path("/delete/", views.party_delete, name="delete"), path("/reset_stats/", views.party_reset_stats, name="reset_stats"), + path("/add_effect/", views.party_add_effect, name="add_effect"), path("/leave//", views.party_leave, name="leave"), path("/join//", views.party_join, name="join"), path("/refuse//", views.party_refuse, name="refuse"), diff --git a/src/party/views.py b/src/party/views.py index 5fc7a00..a7bc196 100644 --- a/src/party/views.py +++ b/src/party/views.py @@ -3,7 +3,7 @@ from django.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404, redirect, render from character.models import Character, HarmfulState -from party.forms import PartyForm +from party.forms import BattleEffectForm, PartyForm from party.models import Party @@ -68,6 +68,24 @@ def party_reset_stats(request, pk): return render(request, "party/party_reset_stats.html", context) +@login_required +def party_add_effect(request, pk): + party = get_object_or_404(Party.objects.played_or_mastered_by(request.user), pk=pk) + context = {"party": party} + if request.method == "GET": + form = BattleEffectForm() + else: + form = BattleEffectForm(request.POST or None) + if form.is_valid(): + effect = form.save(commit=False) + effect.party = party + effect.created_by = request.user + effect.save() + return render(request, "party/snippets/effects.html", context) + context["form"] = form + return render(request, "party/snippets/add_effect_form.html", context) + + @login_required def party_change(request, pk): party = get_object_or_404(Party.objects.managed_by(request.user), pk=pk)