From f02da17f63b231c24d39a224ba116878cbd3b0e4 Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Wed, 1 Mar 2023 16:05:14 +0100 Subject: [PATCH] Implement pets (#48) --- src/character/forms.py | 25 +++++- src/character/migrations/0041_pet.py | 81 +++++++++++++++++++ src/character/migrations/max_migration.txt | 2 +- src/character/models/character.py | 3 + src/character/models/pet.py | 43 ++++++++++ .../character/character_details.html | 9 +++ .../templates/character/pet_delete.html | 18 +++++ .../templates/character/pet_form.html | 14 ++++ .../character_details/pet_health_bar.html | 7 ++ .../snippets/characters_list/pet_card.html | 60 ++++++++++++++ .../templatetags/character_extras.py | 5 ++ src/character/tests/test_pet.py | 21 +++-- src/character/urls.py | 8 ++ src/character/views.py | 79 ++++++++++++++++-- 14 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 src/character/migrations/0041_pet.py create mode 100644 src/character/models/pet.py create mode 100644 src/character/templates/character/pet_delete.html create mode 100644 src/character/templates/character/pet_form.html create mode 100644 src/character/templates/character/snippets/character_details/pet_health_bar.html create mode 100644 src/character/templates/character/snippets/characters_list/pet_card.html diff --git a/src/character/forms.py b/src/character/forms.py index c9bac4b..bb8cbf9 100644 --- a/src/character/forms.py +++ b/src/character/forms.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from django.db.models import Q from character.models import Character, Path, RacialCapability +from character.models.pet import Pet class EquipmentForm(forms.ModelForm): @@ -50,7 +51,7 @@ class AddPathForm(forms.Form): return cleaned_data -class CharacterCreateForm(forms.ModelForm): +class CharacterForm(forms.ModelForm): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.fields[ @@ -92,3 +93,25 @@ class CharacterCreateForm(forms.ModelForm): "damage_reduction", "notes", ] + + +class PetForm(forms.ModelForm): + class Meta: + model = Pet + fields = [ + "name", + "health_max", + "health_remaining", + "modifier_strength", + "modifier_dexterity", + "modifier_constitution", + "modifier_intelligence", + "modifier_wisdom", + "modifier_charisma", + "damage", + "initiative", + "defense", + "attack", + "recovery", + "notes", + ] diff --git a/src/character/migrations/0041_pet.py b/src/character/migrations/0041_pet.py new file mode 100644 index 0000000..83c776d --- /dev/null +++ b/src/character/migrations/0041_pet.py @@ -0,0 +1,81 @@ +# Generated by Django 4.1.7 on 2023-03-01 14:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("character", "0040_character_gm_notes"), + ] + + operations = [ + migrations.CreateModel( + name="Pet", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="nom")), + ( + "health_max", + models.PositiveIntegerField(verbose_name="points de vie maximum"), + ), + ( + "health_remaining", + models.PositiveIntegerField(verbose_name="points de vie restants"), + ), + ( + "modifier_strength", + models.IntegerField(verbose_name="modificateur force"), + ), + ( + "modifier_dexterity", + models.IntegerField(verbose_name="modificateur dextérité"), + ), + ( + "modifier_constitution", + models.IntegerField(verbose_name="modificateur constitution"), + ), + ( + "modifier_intelligence", + models.IntegerField(verbose_name="modificateur intelligence"), + ), + ( + "modifier_wisdom", + models.IntegerField(verbose_name="modificateur sagesse"), + ), + ( + "modifier_charisma", + models.IntegerField(verbose_name="modificateur charisme"), + ), + ("damage", models.PositiveIntegerField(verbose_name="dégâts")), + ("initiative", models.PositiveIntegerField(verbose_name="initiative")), + ("defense", models.PositiveIntegerField(verbose_name="défense")), + ("attack", models.PositiveIntegerField(verbose_name="attaque")), + ( + "recovery", + models.CharField( + blank=True, + max_length=100, + verbose_name="récupération", + ), + ), + ("notes", models.TextField(blank=True, verbose_name="notes")), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pets", + to="character.character", + ), + ), + ], + ), + ] diff --git a/src/character/migrations/max_migration.txt b/src/character/migrations/max_migration.txt index e90ad0b..f529b90 100644 --- a/src/character/migrations/max_migration.txt +++ b/src/character/migrations/max_migration.txt @@ -1 +1 @@ -0040_character_gm_notes +0041_pet diff --git a/src/character/models/character.py b/src/character/models/character.py index e2899b7..8cf0476 100644 --- a/src/character/models/character.py +++ b/src/character/models/character.py @@ -468,6 +468,9 @@ class Character(models.Model): def mastered_by(self, user): return self in Character.objects.mastered_by(user) + def owned_by(self, user): + return self in Character.objects.owned_by(user) + def reset_stats(self): self.health_remaining = self.health_max self.mana_remaining = self.mana_max diff --git a/src/character/models/pet.py b/src/character/models/pet.py new file mode 100644 index 0000000..6a1329b --- /dev/null +++ b/src/character/models/pet.py @@ -0,0 +1,43 @@ +from django.db import models + + +class Pet(models.Model): + # Fields are: name, health_max, health_remaining, modifier_strength, + # modifier_dexterity, modifier_constitution, modifier_intelligence, + # modifier_wisdom, modifier_charisma, damage, initiative, defense, attack, + # recovery and notes. + name = models.CharField(max_length=100, verbose_name="nom") + owner = models.ForeignKey( + "character.Character", + on_delete=models.CASCADE, + related_name="pets", + ) + health_max = models.PositiveIntegerField(verbose_name="points de vie maximum") + health_remaining = models.PositiveIntegerField( + verbose_name="points de vie restants", + ) + modifier_strength = models.IntegerField(verbose_name="modificateur force") + modifier_dexterity = models.IntegerField(verbose_name="modificateur dextérité") + modifier_constitution = models.IntegerField( + verbose_name="modificateur constitution", + ) + modifier_intelligence = models.IntegerField( + verbose_name="modificateur intelligence", + ) + modifier_wisdom = models.IntegerField(verbose_name="modificateur sagesse") + modifier_charisma = models.IntegerField(verbose_name="modificateur charisme") + damage = models.PositiveIntegerField(verbose_name="dégâts") + initiative = models.PositiveIntegerField(verbose_name="initiative") + defense = models.PositiveIntegerField(verbose_name="défense") + attack = models.PositiveIntegerField(verbose_name="attaque") + recovery = models.CharField(max_length=100, verbose_name="récupération", blank=True) + notes = models.TextField(verbose_name="notes", blank=True) + + def __str__(self): + return self.name + + @property + def health_remaining_percent(self) -> float: + if self.health_max == 0: + return 0 + return self.health_remaining / self.health_max * 100 diff --git a/src/character/templates/character/character_details.html b/src/character/templates/character/character_details.html index 039c8b5..9bd5990 100644 --- a/src/character/templates/character/character_details.html +++ b/src/character/templates/character/character_details.html @@ -494,6 +494,15 @@ + Nouveau familier +
+ {% for pet in character.pets.all %} + {% include "character/snippets/characters_list/pet_card.html" %} + {% endfor %} +
diff --git a/src/character/templates/character/pet_delete.html b/src/character/templates/character/pet_delete.html new file mode 100644 index 0000000..5da00ee --- /dev/null +++ b/src/character/templates/character/pet_delete.html @@ -0,0 +1,18 @@ +{% extends "common/base.html" %} +{% load character_extras %} + +{% block title %}Suppression familier {{ pet.name }}{% endblock %} + +{% block content %} +

Suppression familier {{ pet.name }}

+
+ {% csrf_token %} +

+ Êtes-vous certain de vouloir supprimer le familier {{ pet.name }} ?
+ Cette action est irréversible. +

+ +
+{% endblock %} diff --git a/src/character/templates/character/pet_form.html b/src/character/templates/character/pet_form.html new file mode 100644 index 0000000..7babe84 --- /dev/null +++ b/src/character/templates/character/pet_form.html @@ -0,0 +1,14 @@ +{% extends "common/base.html" %} +{% load django_bootstrap5 %} + +{% block title %}Création de familier{% endblock %} + +{% block content %} +

Création de familier

+
+ {% bootstrap_form_errors form %} + {% csrf_token %} + {% bootstrap_form form %} + +
+{% endblock %} diff --git a/src/character/templates/character/snippets/character_details/pet_health_bar.html b/src/character/templates/character/snippets/character_details/pet_health_bar.html new file mode 100644 index 0000000..f7a87ca --- /dev/null +++ b/src/character/templates/character/snippets/character_details/pet_health_bar.html @@ -0,0 +1,7 @@ +{% load character_extras %} + +
+
+ PV : {{ pet.health_remaining }}/{{ pet.health_max }} +
+
diff --git a/src/character/templates/character/snippets/characters_list/pet_card.html b/src/character/templates/character/snippets/characters_list/pet_card.html new file mode 100644 index 0000000..d60460d --- /dev/null +++ b/src/character/templates/character/snippets/characters_list/pet_card.html @@ -0,0 +1,60 @@ +{% load character_extras %} +
+
+
+
+ {{ pet.name }} +
+

+ + ⚔️ {{ pet.attack }} + / + + 🛡️ {{ pet.defense }} + / + + 🎲 {{ pet.initiative }} + +

+
+ {% include "character/snippets/character_details/pet_health_bar.html" %} + {% if pet.owner|managed_by:user %} +
+ + + + +
+ {% endif %} +
+ {% if pet.owner|managed_by:user %} + Modifier + {% endif %} + {% if pet.owner|owned_by:user %} + Supprimer + {% endif %} +
+
+
diff --git a/src/character/templatetags/character_extras.py b/src/character/templatetags/character_extras.py index 76e838d..8f1fd8e 100644 --- a/src/character/templatetags/character_extras.py +++ b/src/character/templatetags/character_extras.py @@ -46,3 +46,8 @@ def managed_by(character: Character, user: User) -> bool: @register.filter def mastered_by(character: Character, user: User) -> bool: return character.mastered_by(user) + + +@register.filter +def owned_by(character: Character, user: User) -> bool: + return character.owned_by(user) diff --git a/src/character/tests/test_pet.py b/src/character/tests/test_pet.py index de82580..5a0527c 100644 --- a/src/character/tests/test_pet.py +++ b/src/character/tests/test_pet.py @@ -5,6 +5,7 @@ from selenium.webdriver.common.by import By from selenium.webdriver.firefox.webdriver import WebDriver from character.models import Character +from character.tests.test_interactions import login from common.models import User @@ -16,6 +17,7 @@ def test_pet_happy_path(selenium: WebDriver, live_server: LiveServer): username, password = "user", "some_password" player = User.objects.create_user(username, password=password) character = baker.make(Character, player=player) + login(selenium, live_server, username, password) # Starting on the character's sheet. selenium.get(live_server.url + character.get_absolute_url()) @@ -48,7 +50,7 @@ def test_pet_happy_path(selenium: WebDriver, live_server: LiveServer): selenium.find_element(By.ID, "id_notes").send_keys("My pet's notes") # Save & check redirected to character's sheet. - selenium.find_element(By.ID, "save-pet").click() + selenium.find_element(By.CSS_SELECTOR, "[type=submit]").click() assert selenium.current_url == live_server.url + character.get_absolute_url() # Fetch pet @@ -57,7 +59,10 @@ def test_pet_happy_path(selenium: WebDriver, live_server: LiveServer): # It now displays the pet's information. # There can be multiple pets. assert ( - selenium.find_element(By.CSS_SELECTOR, f".pet[data-id='{pet.pk}'] .name").text + selenium.find_element( + By.CSS_SELECTOR, + f".pet[data-id='{pet.pk}'] .pet-name", + ).text == "My pet" ) @@ -73,18 +78,24 @@ def test_pet_happy_path(selenium: WebDriver, live_server: LiveServer): # I can edit my pets. When I click on the edit button of a pet, # I have the same form as previously, pre-filled with the current values of my pet. selenium.find_element(By.CSS_SELECTOR, f".pet[data-id='{pet.pk}'] .edit").click() - assert selenium.find_element(By.ID, "id_name").get_attribute("value") == "My pet" + pet_name = selenium.find_element(By.ID, "id_name") + assert pet_name.get_attribute("value") == "My pet" assert selenium.find_element(By.ID, "id_health_max").get_attribute("value") == "10" assert ( selenium.find_element(By.ID, "id_health_remaining").get_attribute("value") - == "10" + == "9" ) + pet_name.clear() + pet_name.send_keys("new name") + selenium.find_element(By.CSS_SELECTOR, "[type=submit]").click() + pet.refresh_from_db() + assert pet.name == "new name" # I can delete my pets. When I click on the pet's delete button, # I'm redirected to a page asking confirmation of my action, # in order to avoid mistakes. selenium.find_element(By.CSS_SELECTOR, f".pet[data-id='{pet.pk}'] .delete").click() assert character.pets.count() == 1 - selenium.find_element(By.ID, "delete-pet").click() + selenium.find_element(By.CSS_SELECTOR, "[type=submit]").click() assert selenium.current_url == live_server.url + character.get_absolute_url() assert character.pets.count() == 0 diff --git a/src/character/urls.py b/src/character/urls.py index d21e8c1..506b299 100644 --- a/src/character/urls.py +++ b/src/character/urls.py @@ -88,6 +88,7 @@ urlpatterns = [ name="remove_last_in_path", ), path("/add_path/", views.add_path, name="add_path"), + path("/create_pet/", views.create_pet, name="create_pet"), path( "/remove_state//", views.remove_state, @@ -95,4 +96,11 @@ urlpatterns = [ ), path("/add_state//", views.add_state, name="add_state"), path("/reset_stats/", views.reset_stats, name="reset_stats"), + path( + "pet//health_change/", + views.pet_health_change, + name="pet_health_change", + ), + path("pet//change/", views.pet_change, name="pet_change"), + path("pet//delete/", views.pet_delete, name="pet_delete"), ] diff --git a/src/character/views.py b/src/character/views.py index f5000cc..1bb02d7 100644 --- a/src/character/views.py +++ b/src/character/views.py @@ -4,8 +4,9 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django_htmx.http import trigger_client_event -from character.forms import AddPathForm, CharacterCreateForm, EquipmentForm +from character.forms import AddPathForm, CharacterForm, EquipmentForm, PetForm from character.models import Capability, Character, HarmfulState, Path +from character.models.pet import Pet from character.templatetags.character_extras import modifier from party.models import Party @@ -25,7 +26,7 @@ def characters_list(request): @login_required def character_create(request): if request.method == "POST": - form = CharacterCreateForm(request.POST, request.FILES) + form = CharacterForm(request.POST, request.FILES) if form.is_valid(): character = form.save(commit=False) character.player = request.user @@ -38,7 +39,7 @@ def character_create(request): messages.success(request, f"{character.name} a été créé.") return redirect("character:list") else: - form = CharacterCreateForm() + form = CharacterForm() context = {"form": form} return render(request, "character/character_form.html", context) @@ -47,13 +48,13 @@ def character_create(request): def character_change(request, pk: int): character = get_object_or_404(Character.objects.managed_by(request.user), pk=pk) if request.method == "POST": - form = CharacterCreateForm(request.POST, request.FILES, instance=character) + form = CharacterForm(request.POST, request.FILES, instance=character) if form.is_valid(): character = form.save() messages.success(request, f"{character.name} a été enregistré.") return redirect(character.get_absolute_url()) else: - form = CharacterCreateForm(instance=character) + form = CharacterForm(instance=character) context = {"form": form} return render(request, "character/character_form.html", context) @@ -461,3 +462,71 @@ def reset_stats(request, pk: int): messages.success(request, f"Les stats de {character} ont été réinitialisées.") return redirect(character) return render(request, "character/character_reset_stats.html", context) + + +@login_required +def create_pet(request, pk: int): + character = get_object_or_404(Character.objects.managed_by(request.user), pk=pk) + if request.method == "POST": + form = PetForm(request.POST, request.FILES) + if form.is_valid(): + pet = form.save(commit=False) + pet.owner = character + pet.save() + form.save_m2m() + messages.success(request, f"{pet.name} a été créé.") + return redirect("character:view", pk=pk) + else: + form = PetForm() + context = {"form": form} + return render(request, "character/pet_form.html", context) + + +@login_required +def pet_change(request, pk: int): + potential_owners = Character.objects.managed_by(request.user) + pet = get_object_or_404(Pet.objects.filter(owner__in=potential_owners), pk=pk) + if request.method == "POST": + form = PetForm(request.POST, request.FILES, instance=pet) + if form.is_valid(): + pet = form.save() + messages.success(request, f"{pet.name} a été enregistré.") + return redirect(pet.owner.get_absolute_url()) + else: + form = PetForm(instance=pet) + context = {"form": form} + return render(request, "character/pet_form.html", context) + + +@login_required +def pet_health_change(request, pk: int): + potential_owners = Character.objects.managed_by(request.user) + pet = get_object_or_404( + Pet.objects.filter(owner__in=potential_owners).only( + "health_max", + "health_remaining", + ), + pk=pk, + ) + value = get_updated_value(request, pet.health_remaining, pet.health_max) + pet.health_remaining = value + pet.save(update_fields=["health_remaining"]) + return render( + request, + "character/snippets/character_details/pet_health_bar.html", + {"pet": pet}, + ) + + +@login_required +def pet_delete(request, pk: int): + potential_owners = Character.objects.owned_by(request.user) + pet = get_object_or_404(Pet.objects.filter(owner__in=potential_owners), pk=pk) + context = {"pet": pet} + if request.method == "POST": + name = pet.name + owner = pet.owner + pet.delete() + messages.success(request, f"Le familier {name} a été supprimé.") + return redirect("character:view", pk=owner.pk) + return render(request, "character/pet_delete.html", context)