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

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):
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

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>
</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="col-md-6 col-lg-4 mb-3">
<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
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)

View file

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

View file

@ -88,6 +88,7 @@ urlpatterns = [
name="remove_last_in_path",
),
path("<int:pk>/add_path/", views.add_path, name="add_path"),
path("<int:pk>/create_pet/", views.create_pet, name="create_pet"),
path(
"<int:pk>/remove_state/<int:state_pk>/",
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>/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_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)