mirror of
https://github.com/Crocmagnon/charasheet.git
synced 2024-12-22 22:01:48 +01:00
Implement pets (#48)
This commit is contained in:
parent
738ddb7e7b
commit
f02da17f63
14 changed files with 363 additions and 12 deletions
|
@ -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",
|
||||
]
|
||||
|
|
81
src/character/migrations/0041_pet.py
Normal file
81
src/character/migrations/0041_pet.py
Normal 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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -1 +1 @@
|
|||
0040_character_gm_notes
|
||||
0041_pet
|
||||
|
|
|
@ -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
|
||||
|
|
43
src/character/models/pet.py
Normal file
43
src/character/models/pet.py
Normal 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
|
|
@ -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">
|
||||
|
|
18
src/character/templates/character/pet_delete.html
Normal file
18
src/character/templates/character/pet_delete.html
Normal 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 %}
|
14
src/character/templates/character/pet_form.html
Normal file
14
src/character/templates/character/pet_form.html
Normal 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 %}
|
|
@ -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>
|
|
@ -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">
|
||||
⚔️ {{ pet.attack }}
|
||||
</span> /
|
||||
<span data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="DEF">
|
||||
🛡️ {{ pet.defense }}
|
||||
</span> /
|
||||
<span data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="Initiative">
|
||||
🎲 {{ 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>
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue