Implement pets (#48)

This commit is contained in:
Gabriel Augendre 2023-03-01 16:05:14 +01:00
parent 738ddb7e7b
commit f02da17f63
14 changed files with 363 additions and 12 deletions

View file

@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from character.models import Character, Path, RacialCapability from character.models import Character, Path, RacialCapability
from character.models.pet import Pet
class EquipmentForm(forms.ModelForm): class EquipmentForm(forms.ModelForm):
@ -50,7 +51,7 @@ class AddPathForm(forms.Form):
return cleaned_data return cleaned_data
class CharacterCreateForm(forms.ModelForm): class CharacterForm(forms.ModelForm):
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields[ self.fields[
@ -92,3 +93,25 @@ class CharacterCreateForm(forms.ModelForm):
"damage_reduction", "damage_reduction",
"notes", "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",
]

View file

@ -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",
),
),
],
),
]

View file

@ -1 +1 @@
0040_character_gm_notes 0041_pet

View file

@ -468,6 +468,9 @@ class Character(models.Model):
def mastered_by(self, user): def mastered_by(self, user):
return self in Character.objects.mastered_by(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): def reset_stats(self):
self.health_remaining = self.health_max self.health_remaining = self.health_max
self.mana_remaining = self.mana_max self.mana_remaining = self.mana_max

View file

@ -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

View file

@ -494,6 +494,15 @@
</table> </table>
</div> </div>
</div> </div>
<a href="{% url "character:create_pet" pk=character.pk %}"
id="add-pet"
class="btn btn-success mb-2"
>Nouveau familier</a>
<div class="row mb-3 row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
{% for pet in character.pets.all %}
{% include "character/snippets/characters_list/pet_card.html" %}
{% endfor %}
</div>
<div class="row"> <div class="row">
<div class="col-md-6 col-lg-4 mb-3"> <div class="col-md-6 col-lg-4 mb-3">
<div class="card"> <div class="card">

View file

@ -0,0 +1,18 @@
{% extends "common/base.html" %}
{% load character_extras %}
{% block title %}Suppression familier {{ pet.name }}{% endblock %}
{% block content %}
<h1>Suppression familier {{ pet.name }}</h1>
<form action="{% url "character:pet_delete" pk=pet.pk %}" method=post>
{% csrf_token %}
<p>
Êtes-vous certain de vouloir supprimer le familier {{ pet.name }} ?<br>
Cette action est irréversible.
</p>
<button class="btn btn-danger" type="submit">
<i class="fa-solid fa-user-minus"></i> Supprimer le familier
</button>
</form>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "common/base.html" %}
{% load django_bootstrap5 %}
{% block title %}Création de familier{% endblock %}
{% block content %}
<h1>Création de familier</h1>
<form action="" method="post" enctype="multipart/form-data">
{% bootstrap_form_errors form %}
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-primary">Enregistrer</button>
</form>
{% endblock %}

View file

@ -0,0 +1,7 @@
{% load character_extras %}
<div class="progress">
<div class="progress-bar {% if pet.health_remaining_percent > 60 %}bg-success{% elif pet.health_remaining_percent > 30 %}bg-warning{% else %}bg-danger{% endif %}" style="width: {{ pet.health_remaining_percent|floatformat:"0" }}%">
PV : {{ pet.health_remaining }}/{{ pet.health_max }}
</div>
</div>

View file

@ -0,0 +1,60 @@
{% load character_extras %}
<div class="col">
<div class="card pet" data-id="{{ pet.pk }}">
<div class="card-body">
<h5 class="card-title pet-name">
{{ pet.name }}
</h5>
<p class="card-text">
<span data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="Attaque">
⚔️&nbsp;{{ pet.attack }}
</span> /
<span data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="DEF">
🛡️&nbsp;{{ pet.defense }}
</span> /
<span data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="Initiative">
🎲&nbsp;{{ pet.initiative }}
</span>
</p>
<div class="health">
{% include "character/snippets/character_details/pet_health_bar.html" %}
{% if pet.owner|managed_by:user %}
<div class="btn-group btn-group-sm mt-1" role="group">
<button
hx-get="{% url "character:pet_health_change" pk=pet.pk %}?value=ko"
hx-target='[data-id="{{ pet.pk }}"] .health .progress'
hx-swap="outerHTML"
type="button"
class="btn btn-outline-danger min"><i class="fa-solid fa-battery-empty"></i></button>
<button
hx-get="{% url "character:pet_health_change" pk=pet.pk %}?value=-1"
hx-target='[data-id="{{ pet.pk }}"] .health .progress'
hx-swap="outerHTML"
type="button"
class="btn btn-danger decrease"><i class="fa-solid fa-minus"></i></button>
<button
hx-get="{% url "character:pet_health_change" pk=pet.pk %}?value=1"
hx-target='[data-id="{{ pet.pk }}"] .health .progress'
hx-swap="outerHTML"
type="button"
class="btn btn-success increase"><i class="fa-solid fa-plus"></i></button>
<button
hx-get="{% url "character:pet_health_change" pk=pet.pk %}?value=max"
hx-target='[data-id="{{ pet.pk }}"] .health .progress'
hx-swap="outerHTML"
type="button"
class="btn btn-outline-success max"><i class="fa-solid fa-battery-full"></i></button>
</div>
{% endif %}
</div>
{% if pet.owner|managed_by:user %}
<a href="{% url "character:pet_change" pk=pet.pk %}"
class="edit">Modifier</a>
{% endif %}
{% if pet.owner|owned_by:user %}
<a href="{% url "character:pet_delete" pk=pet.pk %}"
class="delete">Supprimer</a>
{% endif %}
</div>
</div>
</div>

View file

@ -46,3 +46,8 @@ def managed_by(character: Character, user: User) -> bool:
@register.filter @register.filter
def mastered_by(character: Character, user: User) -> bool: def mastered_by(character: Character, user: User) -> bool:
return character.mastered_by(user) return character.mastered_by(user)
@register.filter
def owned_by(character: Character, user: User) -> bool:
return character.owned_by(user)

View file

@ -5,6 +5,7 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.webdriver import WebDriver from selenium.webdriver.firefox.webdriver import WebDriver
from character.models import Character from character.models import Character
from character.tests.test_interactions import login
from common.models import User from common.models import User
@ -16,6 +17,7 @@ def test_pet_happy_path(selenium: WebDriver, live_server: LiveServer):
username, password = "user", "some_password" username, password = "user", "some_password"
player = User.objects.create_user(username, password=password) player = User.objects.create_user(username, password=password)
character = baker.make(Character, player=player) character = baker.make(Character, player=player)
login(selenium, live_server, username, password)
# Starting on the character's sheet. # Starting on the character's sheet.
selenium.get(live_server.url + character.get_absolute_url()) 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") selenium.find_element(By.ID, "id_notes").send_keys("My pet's notes")
# Save & check redirected to character's sheet. # 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() assert selenium.current_url == live_server.url + character.get_absolute_url()
# Fetch pet # Fetch pet
@ -57,7 +59,10 @@ def test_pet_happy_path(selenium: WebDriver, live_server: LiveServer):
# It now displays the pet's information. # It now displays the pet's information.
# There can be multiple pets. # There can be multiple pets.
assert ( 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" == "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 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. # 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() 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_max").get_attribute("value") == "10"
assert ( assert (
selenium.find_element(By.ID, "id_health_remaining").get_attribute("value") 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 can delete my pets. When I click on the pet's delete button,
# I'm redirected to a page asking confirmation of my action, # I'm redirected to a page asking confirmation of my action,
# in order to avoid mistakes. # in order to avoid mistakes.
selenium.find_element(By.CSS_SELECTOR, f".pet[data-id='{pet.pk}'] .delete").click() selenium.find_element(By.CSS_SELECTOR, f".pet[data-id='{pet.pk}'] .delete").click()
assert character.pets.count() == 1 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 selenium.current_url == live_server.url + character.get_absolute_url()
assert character.pets.count() == 0 assert character.pets.count() == 0

View file

@ -88,6 +88,7 @@ urlpatterns = [
name="remove_last_in_path", name="remove_last_in_path",
), ),
path("<int:pk>/add_path/", views.add_path, name="add_path"), path("<int:pk>/add_path/", views.add_path, name="add_path"),
path("<int:pk>/create_pet/", views.create_pet, name="create_pet"),
path( path(
"<int:pk>/remove_state/<int:state_pk>/", "<int:pk>/remove_state/<int:state_pk>/",
views.remove_state, views.remove_state,
@ -95,4 +96,11 @@ urlpatterns = [
), ),
path("<int:pk>/add_state/<int:state_pk>/", views.add_state, name="add_state"), path("<int:pk>/add_state/<int:state_pk>/", views.add_state, name="add_state"),
path("<int:pk>/reset_stats/", views.reset_stats, name="reset_stats"), path("<int:pk>/reset_stats/", views.reset_stats, name="reset_stats"),
path(
"pet/<int:pk>/health_change/",
views.pet_health_change,
name="pet_health_change",
),
path("pet/<int:pk>/change/", views.pet_change, name="pet_change"),
path("pet/<int:pk>/delete/", views.pet_delete, name="pet_delete"),
] ]

View file

@ -4,8 +4,9 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django_htmx.http import trigger_client_event 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 import Capability, Character, HarmfulState, Path
from character.models.pet import Pet
from character.templatetags.character_extras import modifier from character.templatetags.character_extras import modifier
from party.models import Party from party.models import Party
@ -25,7 +26,7 @@ def characters_list(request):
@login_required @login_required
def character_create(request): def character_create(request):
if request.method == "POST": if request.method == "POST":
form = CharacterCreateForm(request.POST, request.FILES) form = CharacterForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
character = form.save(commit=False) character = form.save(commit=False)
character.player = request.user character.player = request.user
@ -38,7 +39,7 @@ def character_create(request):
messages.success(request, f"{character.name} a été créé.") messages.success(request, f"{character.name} a été créé.")
return redirect("character:list") return redirect("character:list")
else: else:
form = CharacterCreateForm() form = CharacterForm()
context = {"form": form} context = {"form": form}
return render(request, "character/character_form.html", context) return render(request, "character/character_form.html", context)
@ -47,13 +48,13 @@ def character_create(request):
def character_change(request, pk: int): def character_change(request, pk: int):
character = get_object_or_404(Character.objects.managed_by(request.user), pk=pk) character = get_object_or_404(Character.objects.managed_by(request.user), pk=pk)
if request.method == "POST": if request.method == "POST":
form = CharacterCreateForm(request.POST, request.FILES, instance=character) form = CharacterForm(request.POST, request.FILES, instance=character)
if form.is_valid(): if form.is_valid():
character = form.save() character = form.save()
messages.success(request, f"{character.name} a été enregistré.") messages.success(request, f"{character.name} a été enregistré.")
return redirect(character.get_absolute_url()) return redirect(character.get_absolute_url())
else: else:
form = CharacterCreateForm(instance=character) form = CharacterForm(instance=character)
context = {"form": form} context = {"form": form}
return render(request, "character/character_form.html", context) 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.") messages.success(request, f"Les stats de {character} ont été réinitialisées.")
return redirect(character) return redirect(character)
return render(request, "character/character_reset_stats.html", context) 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)