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 %}
+
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)