mirror of
https://github.com/Crocmagnon/charasheet.git
synced 2024-11-21 22:18:04 +01:00
Enable more ruff rules
This commit is contained in:
parent
bcabc15054
commit
16d7ff5d20
38 changed files with 394 additions and 215 deletions
|
@ -36,50 +36,12 @@ python_files = [
|
|||
[tool.ruff]
|
||||
src = ["src"]
|
||||
target-version = "py311"
|
||||
select = [
|
||||
"F", # pyflakes
|
||||
"E", "W", # pycodestyle
|
||||
"C90", # mccabe
|
||||
"I", # isort
|
||||
"N", # pep8-naming
|
||||
"D", # pydocstyle
|
||||
"S", # flake8-bandit
|
||||
"FBT", # flake8-boolean-trap
|
||||
"B", # flake8-bugbear
|
||||
"A", # flake8-builtins
|
||||
"C4", # flake8-comprehensions
|
||||
"DTZ", # flake8-datetimez
|
||||
"T10", # flake8-debugger
|
||||
"EXE", # flake8-executable
|
||||
"ISC", # flake8-implicit-str-concat
|
||||
"ICN", # flake8-import-conventions
|
||||
"G", # flake8-logging-format
|
||||
"INP", # flake8-no-pep420
|
||||
"PIE", # flake8-pie
|
||||
"T20", # flake8-print
|
||||
"PT", # flake8-pytest-style
|
||||
"RET", # flake8-return
|
||||
"SIM", # flake8-simplify
|
||||
"TID", # flake8-tidy-imports
|
||||
"ARG", # flake8-unused-arguments
|
||||
"PTH", # flake8-use-pathlib
|
||||
"ERA", # eradicate
|
||||
"PD", # pandas-vet
|
||||
"PGH", # pygrep-hooks
|
||||
"PL", # pylint
|
||||
"TRY", # tryceratops
|
||||
"RUF", # ruff-specific rules
|
||||
]
|
||||
select = ["ALL"]
|
||||
unfixable = ["T20", "RUF001", "RUF002", "RUF003"]
|
||||
|
||||
ignore = [
|
||||
"UP", # pyupgrade
|
||||
"YTT", # flake8-2020
|
||||
"ANN", # flake8-annotations
|
||||
"BLE", # flake8-blind-except
|
||||
"COM", # flake8-commas
|
||||
"EM", # flake8-errmsg
|
||||
"Q", # flake8-quotes
|
||||
"TCH", # flake8-type-checking / TODO: revisit later ?
|
||||
|
||||
"E501", # long lines
|
||||
|
|
|
@ -92,12 +92,14 @@ class RaceAdmin(admin.ModelAdmin):
|
|||
class CharacterAdminForm(ModelForm):
|
||||
class Meta:
|
||||
model = models.Character
|
||||
exclude = ()
|
||||
exclude = () # noqa: DJ006
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["capabilities"].queryset = models.Capability.objects.select_related(
|
||||
"path", "path__race", "path__profile"
|
||||
"path",
|
||||
"path__race",
|
||||
"path__profile",
|
||||
)
|
||||
self.fields[
|
||||
"racial_capability"
|
||||
|
@ -127,7 +129,7 @@ class CharacterAdmin(admin.ModelAdmin):
|
|||
"level",
|
||||
"race",
|
||||
"private",
|
||||
]
|
||||
],
|
||||
},
|
||||
),
|
||||
("Apparence", {"fields": ["gender", "age", "height", "weight"]}),
|
||||
|
@ -141,7 +143,7 @@ class CharacterAdmin(admin.ModelAdmin):
|
|||
("value_intelligence", "modifier_intelligence"),
|
||||
("value_wisdom", "modifier_wisdom"),
|
||||
("value_charisma", "modifier_charisma"),
|
||||
]
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
|
@ -153,7 +155,7 @@ class CharacterAdmin(admin.ModelAdmin):
|
|||
"attack_range",
|
||||
"attack_magic",
|
||||
"states",
|
||||
]
|
||||
],
|
||||
},
|
||||
),
|
||||
("Vitalité", {"fields": [("health_max", "health_remaining")]}),
|
||||
|
@ -165,7 +167,7 @@ class CharacterAdmin(admin.ModelAdmin):
|
|||
"weapons",
|
||||
"equipment",
|
||||
("money_pp", "money_po", "money_pa", "money_pc"),
|
||||
]
|
||||
],
|
||||
},
|
||||
),
|
||||
("Race", {"fields": ["racial_capability"]}),
|
||||
|
|
|
@ -18,10 +18,12 @@ class AddPathForm(forms.Form):
|
|||
empty_label="----- Voies liées au personnage -----",
|
||||
)
|
||||
other_path = forms.ModelChoiceField(
|
||||
Path.objects.none(), required=False, empty_label="----- Autres voies -----"
|
||||
Path.objects.none(),
|
||||
required=False,
|
||||
empty_label="----- Autres voies -----",
|
||||
)
|
||||
|
||||
def __init__(self, character: Character, *args, **kwargs):
|
||||
def __init__(self, character: Character, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
paths = {cap.path_id for cap in character.capabilities.all()}
|
||||
paths = (
|
||||
|
@ -30,12 +32,12 @@ class AddPathForm(forms.Form):
|
|||
.select_related("profile", "race")
|
||||
)
|
||||
character_paths = paths.filter(
|
||||
Q(profile=character.profile) | Q(race=character.race)
|
||||
Q(profile=character.profile) | Q(race=character.race),
|
||||
)
|
||||
self.fields["character_path"].queryset = character_paths
|
||||
self.fields["character_path"].widget.attrs["class"] = "form-select"
|
||||
self.fields["other_path"].queryset = paths.exclude(
|
||||
pk__in={path.pk for path in character_paths}
|
||||
pk__in={path.pk for path in character_paths},
|
||||
)
|
||||
self.fields["other_path"].widget.attrs["class"] = "form-select"
|
||||
|
||||
|
@ -43,12 +45,13 @@ class AddPathForm(forms.Form):
|
|||
cleaned_data = super().clean()
|
||||
values = [cleaned_data.get("character_path"), cleaned_data.get("other_path")]
|
||||
if len(list(filter(None, values))) != 1:
|
||||
raise ValidationError("Vous devez sélectionner une seule valeur.")
|
||||
msg = "Vous devez sélectionner une seule valeur."
|
||||
raise ValidationError(msg)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class CharacterCreateForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields[
|
||||
"racial_capability"
|
||||
|
|
|
@ -17,7 +17,8 @@ class Command(BaseCommand):
|
|||
while len(cards) < expected_capability_count:
|
||||
self.selenium.find_element(By.TAG_NAME, "body").send_keys(Keys.END)
|
||||
cards = self.selenium.find_elements(
|
||||
By.CSS_SELECTOR, ".col-md-4.col-sm-6.col-12.mb-4.views-row"
|
||||
By.CSS_SELECTOR,
|
||||
".col-md-4.col-sm-6.col-12.mb-4.views-row",
|
||||
)
|
||||
for card in cards:
|
||||
try:
|
||||
|
@ -63,11 +64,11 @@ class Command(BaseCommand):
|
|||
},
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Created/updated cap {capability}")
|
||||
self.style.SUCCESS(f"Created/updated cap {capability}"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Couldn't create/update cap {name}: {e}")
|
||||
self.style.ERROR(f"Couldn't create/update cap {name}: {e}"),
|
||||
)
|
||||
|
||||
def get_paths(self, card: WebElement, name: str) -> list[Path]:
|
||||
|
@ -78,7 +79,7 @@ class Command(BaseCommand):
|
|||
paths.append(Path.objects.get(name__iexact=path_name))
|
||||
except Exception:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Couldn't find path in card for cap '{name}'.")
|
||||
self.style.WARNING(f"Couldn't find path in card for cap '{name}'."),
|
||||
)
|
||||
return []
|
||||
return paths
|
||||
|
|
|
@ -22,10 +22,12 @@ class Command(BaseCommand):
|
|||
def import_row(self, url: str, state_row: WebElement) -> None:
|
||||
name = state_row.find_element(By.CLASS_NAME, "views-field-name").text.strip()
|
||||
description = state_row.find_element(
|
||||
By.CLASS_NAME, "views-field-description__value"
|
||||
By.CLASS_NAME,
|
||||
"views-field-description__value",
|
||||
).text.strip()
|
||||
icon_url = state_row.find_element(
|
||||
By.CSS_SELECTOR, ".views-field-field-svg-icon img"
|
||||
By.CSS_SELECTOR,
|
||||
".views-field-field-svg-icon img",
|
||||
).get_attribute("src")
|
||||
state, _ = HarmfulState.objects.update_or_create(
|
||||
name=name,
|
||||
|
|
|
@ -16,7 +16,8 @@ class Command(BaseCommand):
|
|||
while len(anchors) < expected_path_count:
|
||||
self.selenium.find_element(By.TAG_NAME, "body").send_keys(Keys.END)
|
||||
anchors = self.selenium.find_elements(
|
||||
By.CSS_SELECTOR, ".card-body .card-title a"
|
||||
By.CSS_SELECTOR,
|
||||
".card-body .card-title a",
|
||||
)
|
||||
urls = [anchor.get_attribute("href") for anchor in anchors]
|
||||
for url in urls:
|
||||
|
@ -57,7 +58,8 @@ class Command(BaseCommand):
|
|||
try:
|
||||
category = (
|
||||
self.selenium.find_element(
|
||||
By.CSS_SELECTOR, ".field--name-type .field__item"
|
||||
By.CSS_SELECTOR,
|
||||
".field--name-type .field__item",
|
||||
)
|
||||
.text.lower()
|
||||
.strip()
|
||||
|
@ -65,8 +67,8 @@ class Command(BaseCommand):
|
|||
except Exception:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Couldn't find category for {name}. Defaulting to profile."
|
||||
)
|
||||
f"Couldn't find category for {name}. Defaulting to profile.",
|
||||
),
|
||||
)
|
||||
return Path.Category.PROFILE
|
||||
|
||||
|
@ -79,7 +81,8 @@ class Command(BaseCommand):
|
|||
def get_profile(self, name: str) -> Profile | None:
|
||||
try:
|
||||
profile_name = self.selenium.find_element(
|
||||
By.CSS_SELECTOR, ".field--name-type + strong + a"
|
||||
By.CSS_SELECTOR,
|
||||
".field--name-type + strong + a",
|
||||
).text
|
||||
except Exception:
|
||||
self.stdout.write(self.style.WARNING(f"Couldn't find profile for {name}"))
|
||||
|
@ -99,7 +102,8 @@ class Command(BaseCommand):
|
|||
def get_notes(self) -> str:
|
||||
try:
|
||||
return self.selenium.find_element(
|
||||
By.CSS_SELECTOR, ".mt-3 > .field--name-description"
|
||||
By.CSS_SELECTOR,
|
||||
".mt-3 > .field--name-description",
|
||||
).text.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
|
|
@ -41,19 +41,20 @@ class Command(BaseCommand):
|
|||
|
||||
def get_dice(self, name: str) -> Dice:
|
||||
dice = self.selenium.find_element(By.CSS_SELECTOR, ".dice + div").text.split(
|
||||
"D"
|
||||
"D",
|
||||
)
|
||||
number_of_dice, dice_value = int(dice[0]), int(dice[1])
|
||||
if number_of_dice != 1:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Multiple dice for {name}: {number_of_dice}")
|
||||
self.style.WARNING(f"Multiple dice for {name}: {number_of_dice}"),
|
||||
)
|
||||
return Dice(dice_value)
|
||||
|
||||
def get_magical_strength(self) -> Profile.MagicalStrength:
|
||||
try:
|
||||
magical_strength = self.selenium.find_element(
|
||||
By.CSS_SELECTOR, ".field--name-magic-attack-modifier .field__item"
|
||||
By.CSS_SELECTOR,
|
||||
".field--name-magic-attack-modifier .field__item",
|
||||
).text
|
||||
magical_strength = Profile.MagicalStrength(magical_strength)
|
||||
except Exception:
|
||||
|
|
|
@ -27,7 +27,8 @@ class Command(BaseCommand):
|
|||
self.stdout.write(self.style.SUCCESS(f"Created/updated race {race}"))
|
||||
|
||||
racial_cap = self.selenium.find_element(
|
||||
By.CSS_SELECTOR, ".field--name-abilities"
|
||||
By.CSS_SELECTOR,
|
||||
".field--name-abilities",
|
||||
)
|
||||
racial_name = (
|
||||
racial_cap.find_element(By.TAG_NAME, "strong")
|
||||
|
|
|
@ -32,13 +32,15 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
@ -47,7 +49,7 @@ class Migration(migrations.Migration):
|
|||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(5),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
("limited", models.BooleanField(blank=True)),
|
||||
|
@ -74,13 +76,15 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
@ -106,7 +110,7 @@ class Migration(migrations.Migration):
|
|||
(10, "D10"),
|
||||
(12, "D12"),
|
||||
(20, "D20"),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -130,13 +134,15 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -160,13 +166,15 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
("damage", models.CharField(blank=True, max_length=50)),
|
||||
|
@ -192,20 +200,23 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
("description", models.TextField()),
|
||||
(
|
||||
"race",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="character.race"
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="character.race",
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -229,13 +240,15 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
@ -355,7 +368,8 @@ class Migration(migrations.Migration):
|
|||
model_name="capability",
|
||||
name="path",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="character.path"
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="character.path",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
|
@ -369,7 +383,9 @@ class Migration(migrations.Migration):
|
|||
migrations.AddConstraint(
|
||||
model_name="capability",
|
||||
constraint=models.UniqueConstraint(
|
||||
models.F("path"), models.F("rank"), name="unique_path_rank"
|
||||
models.F("path"),
|
||||
models.F("rank"),
|
||||
name="unique_path_rank",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -17,7 +17,9 @@ class Migration(migrations.Migration):
|
|||
migrations.AddConstraint(
|
||||
model_name="racialcapability",
|
||||
constraint=models.UniqueConstraint(
|
||||
models.F("name"), models.F("race"), name="unique_name_race"
|
||||
models.F("name"),
|
||||
models.F("race"),
|
||||
name="unique_name_race",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -56,7 +56,9 @@ class Migration(migrations.Migration):
|
|||
model_name="capability",
|
||||
name="limited",
|
||||
field=models.BooleanField(
|
||||
blank=True, default=False, verbose_name="limitée"
|
||||
blank=True,
|
||||
default=False,
|
||||
verbose_name="limitée",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
@ -108,7 +110,9 @@ class Migration(migrations.Migration):
|
|||
model_name="character",
|
||||
name="capabilities",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, to="character.capability", verbose_name="capacités"
|
||||
blank=True,
|
||||
to="character.capability",
|
||||
verbose_name="capacités",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
@ -140,7 +144,7 @@ class Migration(migrations.Migration):
|
|||
model_name="character",
|
||||
name="health_remaining",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
verbose_name="points de vie restants"
|
||||
verbose_name="points de vie restants",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
@ -162,14 +166,15 @@ class Migration(migrations.Migration):
|
|||
model_name="character",
|
||||
name="luck_points_remaining",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
verbose_name="points de chance restants"
|
||||
verbose_name="points de chance restants",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="character",
|
||||
name="mana_consumed",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=0, verbose_name="mana utilisé"
|
||||
default=0,
|
||||
verbose_name="mana utilisé",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
@ -261,7 +266,9 @@ class Migration(migrations.Migration):
|
|||
model_name="character",
|
||||
name="weapons",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, to="character.weapon", verbose_name="armes"
|
||||
blank=True,
|
||||
to="character.weapon",
|
||||
verbose_name="armes",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
|
|
@ -17,7 +17,8 @@ class Migration(migrations.Migration):
|
|||
model_name="character",
|
||||
name="mana_remaining",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=0, verbose_name="mana restant"
|
||||
default=0,
|
||||
verbose_name="mana restant",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -13,7 +13,8 @@ class Migration(migrations.Migration):
|
|||
model_name="character",
|
||||
name="recovery_points_remaining",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=5, verbose_name="points de récupération restants"
|
||||
default=5,
|
||||
verbose_name="points de récupération restants",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -30,13 +30,15 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
("description", models.TextField()),
|
||||
|
|
|
@ -13,7 +13,8 @@ class Migration(migrations.Migration):
|
|||
model_name="character",
|
||||
name="states",
|
||||
field=models.ManyToManyField(
|
||||
related_name="characters", to="character.harmfulstate"
|
||||
related_name="characters",
|
||||
to="character.harmfulstate",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -13,7 +13,9 @@ class Migration(migrations.Migration):
|
|||
model_name="character",
|
||||
name="states",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, related_name="characters", to="character.harmfulstate"
|
||||
blank=True,
|
||||
related_name="characters",
|
||||
to="character.harmfulstate",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -32,13 +32,15 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
|
|
@ -25,7 +25,7 @@ class Migration(migrations.Migration):
|
|||
character.models.character.validate_image,
|
||||
*(),
|
||||
**{"megabytes_limit": 2},
|
||||
)
|
||||
),
|
||||
],
|
||||
verbose_name="image de profil",
|
||||
),
|
||||
|
|
|
@ -32,7 +32,9 @@ class Path(DocumentedModel, UniquelyNamedModel, TimeStampedModel, models.Model):
|
|||
CREATURE = "creature", "Créature"
|
||||
|
||||
category = models.CharField(
|
||||
max_length=20, choices=Category.choices, verbose_name="catégorie"
|
||||
max_length=20,
|
||||
choices=Category.choices,
|
||||
verbose_name="catégorie",
|
||||
)
|
||||
notes = models.TextField(blank=True, verbose_name="notes")
|
||||
|
||||
|
@ -93,13 +95,20 @@ class Capability(DocumentedModel, TimeStampedModel, models.Model):
|
|||
related_name="capabilities",
|
||||
)
|
||||
rank = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(5)], verbose_name="rang"
|
||||
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
||||
verbose_name="rang",
|
||||
)
|
||||
limited = models.BooleanField(
|
||||
blank=True, null=False, default=False, verbose_name="limitée"
|
||||
blank=True,
|
||||
null=False,
|
||||
default=False,
|
||||
verbose_name="limitée",
|
||||
)
|
||||
spell = models.BooleanField(
|
||||
blank=True, null=False, default=False, verbose_name="sort"
|
||||
blank=True,
|
||||
null=False,
|
||||
default=False,
|
||||
verbose_name="sort",
|
||||
)
|
||||
description = models.TextField(verbose_name="description")
|
||||
|
||||
|
@ -134,7 +143,9 @@ class RacialCapabilityManager(models.Manager):
|
|||
class RacialCapability(DocumentedModel, TimeStampedModel, models.Model):
|
||||
name = models.CharField(max_length=100, blank=False, null=False, verbose_name="nom")
|
||||
race = models.ForeignKey(
|
||||
"character.Race", on_delete=models.CASCADE, verbose_name="race"
|
||||
"character.Race",
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="race",
|
||||
)
|
||||
description = models.TextField(verbose_name="description")
|
||||
|
||||
|
|
|
@ -17,7 +17,12 @@ from character.models.equipment import Weapon
|
|||
from common.models import DocumentedModel, UniquelyNamedModel
|
||||
|
||||
|
||||
class Profile(DocumentedModel, UniquelyNamedModel, TimeStampedModel, models.Model):
|
||||
class Profile( # noqa: DJ008
|
||||
DocumentedModel,
|
||||
UniquelyNamedModel,
|
||||
TimeStampedModel,
|
||||
models.Model,
|
||||
):
|
||||
class MagicalStrength(models.TextChoices):
|
||||
NONE = "NON", "Aucun"
|
||||
INTELLIGENCE = "INT", "Intelligence"
|
||||
|
@ -36,10 +41,13 @@ class Profile(DocumentedModel, UniquelyNamedModel, TimeStampedModel, models.Mode
|
|||
verbose_name="force magique",
|
||||
)
|
||||
life_dice = models.PositiveSmallIntegerField(
|
||||
choices=Dice.choices, verbose_name="dé de vie"
|
||||
choices=Dice.choices,
|
||||
verbose_name="dé de vie",
|
||||
)
|
||||
mana_max_compute = models.PositiveSmallIntegerField(
|
||||
choices=ManaMax.choices, verbose_name="calcul mana max", default=ManaMax.NO_MANA
|
||||
choices=ManaMax.choices,
|
||||
verbose_name="calcul mana max",
|
||||
default=ManaMax.NO_MANA,
|
||||
)
|
||||
notes = models.TextField(blank=True, verbose_name="notes")
|
||||
|
||||
|
@ -48,13 +56,23 @@ class Profile(DocumentedModel, UniquelyNamedModel, TimeStampedModel, models.Mode
|
|||
verbose_name_plural = "Profils"
|
||||
|
||||
|
||||
class Race(DocumentedModel, UniquelyNamedModel, TimeStampedModel, models.Model):
|
||||
class Race( # noqa: DJ008
|
||||
DocumentedModel,
|
||||
UniquelyNamedModel,
|
||||
TimeStampedModel,
|
||||
models.Model,
|
||||
):
|
||||
class Meta(UniquelyNamedModel.Meta, TimeStampedModel.Meta):
|
||||
verbose_name = "Race"
|
||||
verbose_name_plural = "Races"
|
||||
|
||||
|
||||
class HarmfulState(DocumentedModel, UniquelyNamedModel, TimeStampedModel, models.Model):
|
||||
class HarmfulState( # noqa: DJ008
|
||||
DocumentedModel,
|
||||
UniquelyNamedModel,
|
||||
TimeStampedModel,
|
||||
models.Model,
|
||||
):
|
||||
description = models.TextField()
|
||||
icon_url = models.URLField()
|
||||
|
||||
|
@ -88,7 +106,7 @@ class CharacterQuerySet(models.QuerySet):
|
|||
from party.models import Party
|
||||
|
||||
return self.filter(
|
||||
Q(player=user) | Q(parties__in=Party.objects.managed_by(user))
|
||||
Q(player=user) | Q(parties__in=Party.objects.managed_by(user)),
|
||||
)
|
||||
|
||||
def mastered_by(self, user):
|
||||
|
@ -113,7 +131,7 @@ class CharacterQuerySet(models.QuerySet):
|
|||
return self.filter(
|
||||
Q(player=user)
|
||||
| Q(parties__in=Party.objects.related_to(user))
|
||||
| Q(invites__in=Party.objects.related_to(user))
|
||||
| Q(invites__in=Party.objects.related_to(user)),
|
||||
).distinct()
|
||||
|
||||
|
||||
|
@ -184,7 +202,10 @@ class Character(models.Model):
|
|||
level = models.PositiveSmallIntegerField(verbose_name="niveau", default=1)
|
||||
|
||||
gender = models.CharField(
|
||||
max_length=1, choices=Gender.choices, default=Gender.OTHER, verbose_name="genre"
|
||||
max_length=1,
|
||||
choices=Gender.choices,
|
||||
default=Gender.OTHER,
|
||||
verbose_name="genre",
|
||||
)
|
||||
age = models.PositiveSmallIntegerField(verbose_name="âge")
|
||||
height = models.PositiveSmallIntegerField(verbose_name="taille")
|
||||
|
@ -193,17 +214,17 @@ class Character(models.Model):
|
|||
value_strength = models.PositiveSmallIntegerField(verbose_name="valeur force")
|
||||
value_dexterity = models.PositiveSmallIntegerField(verbose_name="valeur dextérité")
|
||||
value_constitution = models.PositiveSmallIntegerField(
|
||||
verbose_name="valeur constitution"
|
||||
verbose_name="valeur constitution",
|
||||
)
|
||||
value_intelligence = models.PositiveSmallIntegerField(
|
||||
verbose_name="valeur intelligence"
|
||||
verbose_name="valeur intelligence",
|
||||
)
|
||||
value_wisdom = models.PositiveSmallIntegerField(verbose_name="valeur sagesse")
|
||||
value_charisma = models.PositiveSmallIntegerField(verbose_name="valeur charisme")
|
||||
|
||||
health_max = models.PositiveSmallIntegerField(verbose_name="points de vie max")
|
||||
health_remaining = models.PositiveSmallIntegerField(
|
||||
verbose_name="points de vie restants"
|
||||
verbose_name="points de vie restants",
|
||||
)
|
||||
|
||||
racial_capability = models.ForeignKey(
|
||||
|
@ -214,7 +235,9 @@ class Character(models.Model):
|
|||
)
|
||||
|
||||
weapons = models.ManyToManyField(
|
||||
"character.Weapon", blank=True, verbose_name="armes"
|
||||
"character.Weapon",
|
||||
blank=True,
|
||||
verbose_name="armes",
|
||||
)
|
||||
|
||||
armor = models.PositiveSmallIntegerField(verbose_name="armure", default=0)
|
||||
|
@ -222,20 +245,24 @@ class Character(models.Model):
|
|||
defense_misc = models.SmallIntegerField(verbose_name="divers défense", default=0)
|
||||
|
||||
initiative_misc = models.SmallIntegerField(
|
||||
verbose_name="divers initiative", default=0
|
||||
verbose_name="divers initiative",
|
||||
default=0,
|
||||
)
|
||||
|
||||
capabilities = models.ManyToManyField(
|
||||
"character.Capability", blank=True, verbose_name="capacités"
|
||||
"character.Capability",
|
||||
blank=True,
|
||||
verbose_name="capacités",
|
||||
)
|
||||
|
||||
equipment = models.TextField(blank=True, verbose_name="équipement")
|
||||
luck_points_remaining = models.PositiveSmallIntegerField(
|
||||
verbose_name="points de chance restants"
|
||||
verbose_name="points de chance restants",
|
||||
)
|
||||
|
||||
mana_remaining = models.PositiveSmallIntegerField(
|
||||
default=0, verbose_name="mana restant"
|
||||
default=0,
|
||||
verbose_name="mana restant",
|
||||
)
|
||||
|
||||
money_pp = models.PositiveSmallIntegerField(default=0, verbose_name="PP")
|
||||
|
@ -244,7 +271,8 @@ class Character(models.Model):
|
|||
money_pc = models.PositiveSmallIntegerField(default=0, verbose_name="PC")
|
||||
|
||||
recovery_points_remaining = models.PositiveSmallIntegerField(
|
||||
default=5, verbose_name="points de récupération restants"
|
||||
default=5,
|
||||
verbose_name="points de récupération restants",
|
||||
)
|
||||
|
||||
notes = models.TextField(blank=True, verbose_name="notes", default=DEFAULT_NOTES)
|
||||
|
@ -267,8 +295,10 @@ class Character(models.Model):
|
|||
verbose_name_plural = "Personnages"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
Lower("name"), "player", name="unique_character_player"
|
||||
)
|
||||
Lower("name"),
|
||||
"player",
|
||||
name="unique_character_player",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
@ -329,7 +359,8 @@ class Character(models.Model):
|
|||
Profile.MagicalStrength.NONE: 0,
|
||||
}
|
||||
return modifier_map.get(
|
||||
Profile.MagicalStrength(self.profile.magical_strength), 0
|
||||
Profile.MagicalStrength(self.profile.magical_strength),
|
||||
0,
|
||||
)
|
||||
|
||||
@property
|
||||
|
@ -403,8 +434,9 @@ class Character(models.Model):
|
|||
for capability in path.capabilities.all():
|
||||
capabilities_by_path[capability.path].append(
|
||||
CharacterCapability(
|
||||
capability, known=capability in character_capabilities
|
||||
)
|
||||
capability,
|
||||
known=capability in character_capabilities,
|
||||
),
|
||||
)
|
||||
|
||||
return dict(
|
||||
|
@ -414,7 +446,7 @@ class Character(models.Model):
|
|||
for path, capabilities in capabilities_by_path.items()
|
||||
),
|
||||
key=lambda x: x[0].name,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def get_formatted_notes(self) -> str:
|
||||
|
@ -427,7 +459,7 @@ class Character(models.Model):
|
|||
|
||||
def get_missing_states(self) -> Iterable[HarmfulState]:
|
||||
return HarmfulState.objects.exclude(
|
||||
pk__in=self.states.all().values_list("pk", flat=True)
|
||||
pk__in=self.states.all().values_list("pk", flat=True),
|
||||
)
|
||||
|
||||
def managed_by(self, user):
|
||||
|
|
|
@ -4,7 +4,12 @@ from django_extensions.db.models import TimeStampedModel
|
|||
from common.models import DocumentedModel, UniquelyNamedModel
|
||||
|
||||
|
||||
class Weapon(UniquelyNamedModel, DocumentedModel, TimeStampedModel, models.Model):
|
||||
class Weapon( # noqa: DJ008
|
||||
UniquelyNamedModel,
|
||||
DocumentedModel,
|
||||
TimeStampedModel,
|
||||
models.Model,
|
||||
):
|
||||
class Category(models.TextChoices):
|
||||
MELEE = "MEL", "corps à corps"
|
||||
RANGE = "RAN", "à distance"
|
||||
|
@ -13,7 +18,9 @@ class Weapon(UniquelyNamedModel, DocumentedModel, TimeStampedModel, models.Model
|
|||
damage = models.CharField(max_length=50, blank=True, verbose_name="dégâts")
|
||||
special = models.TextField(blank=True, verbose_name="spécial")
|
||||
category = models.CharField(
|
||||
max_length=3, choices=Category.choices, default=Category.NONE
|
||||
max_length=3,
|
||||
choices=Category.choices,
|
||||
default=Category.NONE,
|
||||
)
|
||||
|
||||
class Meta(UniquelyNamedModel.Meta, TimeStampedModel.Meta):
|
||||
|
|
|
@ -44,7 +44,10 @@ def test_can_access_character_in_party(client):
|
|||
notes = "Some notes"
|
||||
gm_notes = "Some GM notes"
|
||||
friend_character = baker.make(
|
||||
Character, player=friend, notes=notes, gm_notes=gm_notes
|
||||
Character,
|
||||
player=friend,
|
||||
notes=notes,
|
||||
gm_notes=gm_notes,
|
||||
)
|
||||
party = baker.make(Party)
|
||||
party.characters.add(character)
|
||||
|
|
|
@ -68,7 +68,10 @@ def test_attack_range(level, dexterity):
|
|||
@given(armor=integers(), shield=integers(), dexterity=ability_values(), misc=integers())
|
||||
def test_defense(armor, shield, dexterity, misc):
|
||||
char = Character(
|
||||
armor=armor, shield=shield, value_dexterity=dexterity, defense_misc=misc
|
||||
armor=armor,
|
||||
shield=shield,
|
||||
value_dexterity=dexterity,
|
||||
defense_misc=misc,
|
||||
)
|
||||
assert char.defense == 10 + armor + shield + modifier_test(dexterity) + misc
|
||||
|
||||
|
|
|
@ -118,7 +118,8 @@ def test_list_characters(selenium: WebDriver, live_server: LiveServer):
|
|||
names = {
|
||||
name.text
|
||||
for name in selenium.find_elements(
|
||||
By.CSS_SELECTOR, ".character.card .card-title"
|
||||
By.CSS_SELECTOR,
|
||||
".character.card .card-title",
|
||||
)
|
||||
}
|
||||
expected_names = {character.name for character in characters}
|
||||
|
@ -137,7 +138,8 @@ def test_delete_character(selenium: WebDriver, live_server: LiveServer):
|
|||
|
||||
assert Character.objects.count() == 2
|
||||
selenium.find_element(
|
||||
By.CSS_SELECTOR, f".character.card[data-id='{characters[0].pk}'] .delete"
|
||||
By.CSS_SELECTOR,
|
||||
f".character.card[data-id='{characters[0].pk}'] .delete",
|
||||
).click()
|
||||
selenium.find_element(By.CSS_SELECTOR, "[type=submit]").click()
|
||||
|
||||
|
@ -148,7 +150,9 @@ def test_delete_character(selenium: WebDriver, live_server: LiveServer):
|
|||
|
||||
@pytest.mark.django_db()
|
||||
def test_reset_stats_view(
|
||||
selenium: WebDriver, live_server: LiveServer, initial_data: None
|
||||
selenium: WebDriver,
|
||||
live_server: LiveServer,
|
||||
initial_data: None,
|
||||
):
|
||||
username, password = "user", "some_password"
|
||||
player = User.objects.create_user(username, password=password)
|
||||
|
@ -186,7 +190,10 @@ def create_hurt_character(player, profile):
|
|||
|
||||
|
||||
def login(
|
||||
selenium: WebDriver, live_server: LiveServer, username: str, password: str
|
||||
selenium: WebDriver,
|
||||
live_server: LiveServer,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
selenium.get(live_server.url)
|
||||
selenium.find_element(By.ID, "login").click()
|
||||
|
|
|
@ -10,7 +10,9 @@ urlpatterns = [
|
|||
path("<int:pk>/change/", views.character_change, name="change"),
|
||||
path("<int:pk>/delete/", views.character_delete, name="delete"),
|
||||
path(
|
||||
"<int:pk>/health_change/", views.character_health_change, name="health_change"
|
||||
"<int:pk>/health_change/",
|
||||
views.character_health_change,
|
||||
name="health_change",
|
||||
),
|
||||
path("<int:pk>/mana_change/", views.character_mana_change, name="mana_change"),
|
||||
path(
|
||||
|
@ -87,7 +89,9 @@ urlpatterns = [
|
|||
),
|
||||
path("<int:pk>/add_path/", views.add_path, name="add_path"),
|
||||
path(
|
||||
"<int:pk>/remove_state/<int:state_pk>/", views.remove_state, name="remove_state"
|
||||
"<int:pk>/remove_state/<int:state_pk>/",
|
||||
views.remove_state,
|
||||
name="remove_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"),
|
||||
|
|
|
@ -14,7 +14,8 @@ from party.models import Party
|
|||
def characters_list(request):
|
||||
context = {
|
||||
"characters": Character.objects.owned_by(request.user).select_related(
|
||||
"race", "profile"
|
||||
"race",
|
||||
"profile",
|
||||
),
|
||||
"all_states": HarmfulState.objects.all(),
|
||||
}
|
||||
|
@ -99,7 +100,7 @@ def add_path(request, pk: int):
|
|||
context = {"character": character}
|
||||
if form.is_valid():
|
||||
path: Path = form.cleaned_data.get("character_path") or form.cleaned_data.get(
|
||||
"other_path"
|
||||
"other_path",
|
||||
)
|
||||
cap = path.get_next_capability(character)
|
||||
character.capabilities.add(cap)
|
||||
|
@ -117,7 +118,8 @@ def add_path(request, pk: int):
|
|||
def character_health_change(request, pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only(
|
||||
"health_max", "health_remaining"
|
||||
"health_max",
|
||||
"health_remaining",
|
||||
),
|
||||
pk=pk,
|
||||
)
|
||||
|
@ -150,7 +152,9 @@ def character_recovery_points_change(request, pk: int):
|
|||
pk=pk,
|
||||
)
|
||||
value = get_updated_value(
|
||||
request, character.recovery_points_remaining, character.recovery_points_max
|
||||
request,
|
||||
character.recovery_points_remaining,
|
||||
character.recovery_points_max,
|
||||
)
|
||||
character.recovery_points_remaining = value
|
||||
character.save(update_fields=["recovery_points_remaining"])
|
||||
|
@ -160,7 +164,8 @@ def character_recovery_points_change(request, pk: int):
|
|||
@login_required
|
||||
def character_defense_misc_change(request, pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only("defense_misc"), pk=pk
|
||||
Character.objects.managed_by(request.user).only("defense_misc"),
|
||||
pk=pk,
|
||||
)
|
||||
value = get_updated_value(request, character.defense_misc, float("inf"))
|
||||
character.defense_misc = value
|
||||
|
@ -172,7 +177,8 @@ def character_defense_misc_change(request, pk: int):
|
|||
@login_required
|
||||
def character_shield_change(request, pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only("shield"), pk=pk
|
||||
Character.objects.managed_by(request.user).only("shield"),
|
||||
pk=pk,
|
||||
)
|
||||
value = get_updated_value(request, character.shield, float("inf"))
|
||||
character.shield = value
|
||||
|
@ -184,7 +190,8 @@ def character_shield_change(request, pk: int):
|
|||
@login_required
|
||||
def character_armor_change(request, pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only("armor"), pk=pk
|
||||
Character.objects.managed_by(request.user).only("armor"),
|
||||
pk=pk,
|
||||
)
|
||||
value = get_updated_value(request, character.armor, float("inf"))
|
||||
character.armor = value
|
||||
|
@ -196,7 +203,8 @@ def character_armor_change(request, pk: int):
|
|||
@login_required
|
||||
def character_initiative_misc_change(request, pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only("initiative_misc"), pk=pk
|
||||
Character.objects.managed_by(request.user).only("initiative_misc"),
|
||||
pk=pk,
|
||||
)
|
||||
value = get_updated_value(request, character.initiative_misc, float("inf"))
|
||||
character.initiative_misc = value
|
||||
|
@ -209,12 +217,15 @@ def character_initiative_misc_change(request, pk: int):
|
|||
def character_luck_points_change(request, pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only(
|
||||
"luck_points_remaining", "value_charisma"
|
||||
"luck_points_remaining",
|
||||
"value_charisma",
|
||||
),
|
||||
pk=pk,
|
||||
)
|
||||
value = get_updated_value(
|
||||
request, character.luck_points_remaining, character.luck_points_max
|
||||
request,
|
||||
character.luck_points_remaining,
|
||||
character.luck_points_max,
|
||||
)
|
||||
character.luck_points_remaining = value
|
||||
character.save(update_fields=["luck_points_remaining"])
|
||||
|
@ -222,7 +233,9 @@ def character_luck_points_change(request, pk: int):
|
|||
|
||||
|
||||
def get_updated_value(
|
||||
request, remaining_value: int | float, max_value: int | float
|
||||
request,
|
||||
remaining_value: int | float,
|
||||
max_value: int | float,
|
||||
) -> int:
|
||||
form_value = request.GET.get("value")
|
||||
if form_value == "ko":
|
||||
|
@ -241,7 +254,10 @@ def get_updated_value(
|
|||
def character_get_defense(request, pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only(
|
||||
"defense_misc", "armor", "shield", "value_dexterity"
|
||||
"defense_misc",
|
||||
"armor",
|
||||
"shield",
|
||||
"value_dexterity",
|
||||
),
|
||||
pk=pk,
|
||||
)
|
||||
|
@ -252,13 +268,16 @@ def character_get_defense(request, pk: int):
|
|||
def character_get_health_bar(request, pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only(
|
||||
"health_max", "health_remaining"
|
||||
"health_max",
|
||||
"health_remaining",
|
||||
),
|
||||
pk=pk,
|
||||
)
|
||||
context = {"character": character}
|
||||
return render(
|
||||
request, "character/snippets/character_details/health_bar.html", context
|
||||
request,
|
||||
"character/snippets/character_details/health_bar.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
|
@ -270,7 +289,9 @@ def character_get_mana_bar(request, pk: int):
|
|||
)
|
||||
context = {"character": character}
|
||||
return render(
|
||||
request, "character/snippets/character_details/mana_bar.html", context
|
||||
request,
|
||||
"character/snippets/character_details/mana_bar.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
|
@ -278,7 +299,8 @@ def character_get_mana_bar(request, pk: int):
|
|||
def character_get_initiative(request, pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only(
|
||||
"initiative_misc", "value_dexterity"
|
||||
"initiative_misc",
|
||||
"value_dexterity",
|
||||
),
|
||||
pk=pk,
|
||||
)
|
||||
|
@ -299,7 +321,8 @@ def character_gm_notes_change(request, pk: int):
|
|||
def character_equipment_change(request, pk: int):
|
||||
field = "equipment"
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only(field), pk=pk
|
||||
Character.objects.managed_by(request.user).only(field),
|
||||
pk=pk,
|
||||
)
|
||||
context = {"character": character}
|
||||
if request.method == "GET":
|
||||
|
@ -331,7 +354,8 @@ def character_damage_reduction_change(request, pk: int):
|
|||
|
||||
def update_text_field(request, pk, field):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user).only(field), pk=pk
|
||||
Character.objects.managed_by(request.user).only(field),
|
||||
pk=pk,
|
||||
)
|
||||
context = {"character": character}
|
||||
if request.method == "GET":
|
||||
|
@ -343,14 +367,17 @@ def update_text_field(request, pk, field):
|
|||
setattr(character, field, request.POST.get(field))
|
||||
character.save(update_fields=[field])
|
||||
return render(
|
||||
request, f"character/snippets/character_details/{field}_display.html", context
|
||||
request,
|
||||
f"character/snippets/character_details/{field}_display.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def add_next_in_path(request, character_pk: int, path_pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user), pk=character_pk
|
||||
Character.objects.managed_by(request.user),
|
||||
pk=character_pk,
|
||||
)
|
||||
path = get_object_or_404(Path, pk=path_pk)
|
||||
capability = path.get_next_capability(character)
|
||||
|
@ -369,10 +396,11 @@ def add_next_in_path(request, character_pk: int, path_pk: int):
|
|||
@login_required
|
||||
def remove_last_in_path(request, character_pk: int, path_pk: int):
|
||||
character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user), pk=character_pk
|
||||
Character.objects.managed_by(request.user),
|
||||
pk=character_pk,
|
||||
)
|
||||
last_rank = max(
|
||||
character.capabilities.filter(path_id=path_pk).values_list("rank", flat=True)
|
||||
character.capabilities.filter(path_id=path_pk).values_list("rank", flat=True),
|
||||
)
|
||||
cap = Capability.objects.get(path_id=path_pk, rank=last_rank)
|
||||
character.capabilities.remove(cap)
|
||||
|
@ -390,13 +418,16 @@ def remove_last_in_path(request, character_pk: int, path_pk: int):
|
|||
@login_required
|
||||
def remove_state(request, pk: int, state_pk: int):
|
||||
character: Character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user), pk=pk
|
||||
Character.objects.managed_by(request.user),
|
||||
pk=pk,
|
||||
)
|
||||
state = get_object_or_404(HarmfulState, pk=state_pk)
|
||||
character.states.remove(state)
|
||||
context = {"character": character, "all_states": HarmfulState.objects.all()}
|
||||
response = render(
|
||||
request, "character/snippets/character_details/states.html", context
|
||||
request,
|
||||
"character/snippets/character_details/states.html",
|
||||
context,
|
||||
)
|
||||
return trigger_client_event(response, "refresh_tooltips", after="swap")
|
||||
|
||||
|
@ -404,13 +435,16 @@ def remove_state(request, pk: int, state_pk: int):
|
|||
@login_required
|
||||
def add_state(request, pk: int, state_pk: int):
|
||||
character: Character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user), pk=pk
|
||||
Character.objects.managed_by(request.user),
|
||||
pk=pk,
|
||||
)
|
||||
state = get_object_or_404(HarmfulState, pk=state_pk)
|
||||
character.states.add(state)
|
||||
context = {"character": character, "all_states": HarmfulState.objects.all()}
|
||||
response = render(
|
||||
request, "character/snippets/character_details/states.html", context
|
||||
request,
|
||||
"character/snippets/character_details/states.html",
|
||||
context,
|
||||
)
|
||||
return trigger_client_event(response, "refresh_tooltips", after="swap")
|
||||
|
||||
|
@ -418,7 +452,8 @@ def add_state(request, pk: int, state_pk: int):
|
|||
@login_required
|
||||
def reset_stats(request, pk: int):
|
||||
character: Character = get_object_or_404(
|
||||
Character.objects.managed_by(request.user), pk=pk
|
||||
Character.objects.managed_by(request.user),
|
||||
pk=pk,
|
||||
)
|
||||
context = {"character": character}
|
||||
if request.method == "POST":
|
||||
|
|
|
@ -128,7 +128,7 @@ DATABASES = {"default": env.db()}
|
|||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
SOLO_CACHE = "default"
|
||||
|
@ -244,7 +244,7 @@ APP = {
|
|||
"date": "latest-date",
|
||||
"commit": "latest-commit",
|
||||
"describe": "latest-describe",
|
||||
}
|
||||
},
|
||||
}
|
||||
try:
|
||||
with Path("/app/git/build-date").open() as f:
|
||||
|
|
|
@ -28,7 +28,9 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="last login",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
@ -43,13 +45,13 @@ class Migration(migrations.Migration):
|
|||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
"unique": "A user with that username already exists.",
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator(),
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
|
@ -57,19 +59,25 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
blank=True,
|
||||
max_length=150,
|
||||
verbose_name="first name",
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
blank=True,
|
||||
max_length=150,
|
||||
verbose_name="last name",
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
blank=True,
|
||||
max_length=254,
|
||||
verbose_name="email address",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
@ -91,7 +99,8 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="date joined",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
|
|
@ -13,7 +13,11 @@ class UniquelyNamedModelManager(models.Manager):
|
|||
|
||||
class UniquelyNamedModel(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=100, blank=False, null=False, unique=True, verbose_name="nom"
|
||||
max_length=100,
|
||||
blank=False,
|
||||
null=False,
|
||||
unique=True,
|
||||
verbose_name="nom",
|
||||
)
|
||||
objects = UniquelyNamedModelManager()
|
||||
|
||||
|
|
|
@ -9,11 +9,12 @@ def main():
|
|||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
msg = (
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
)
|
||||
raise ImportError(msg) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ from party.models import BattleEffect, Party
|
|||
|
||||
|
||||
class PartyForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.original_instance = kwargs.get("instance")
|
||||
super().__init__(*args, **kwargs)
|
||||
qs = Character.objects.all()
|
||||
|
@ -16,9 +16,10 @@ class PartyForm(forms.ModelForm):
|
|||
Q(private=False)
|
||||
| Q(
|
||||
pk__in=self.original_instance.invited_characters.all().values_list(
|
||||
"pk", flat=True
|
||||
)
|
||||
)
|
||||
"pk",
|
||||
flat=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
self.fields["invited_characters"].queryset = qs
|
||||
|
||||
|
|
|
@ -34,13 +34,15 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
|
|
@ -28,13 +28,15 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
auto_now_add=True,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
auto_now=True,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="nom")),
|
||||
|
|
|
@ -17,14 +17,14 @@ class PartyQuerySet(models.QuerySet):
|
|||
def played_or_mastered_by(self, user):
|
||||
return self.filter(
|
||||
Q(game_master=user)
|
||||
| Q(characters__in=Character.objects.filter(player=user))
|
||||
| Q(characters__in=Character.objects.filter(player=user)),
|
||||
).distinct()
|
||||
|
||||
def related_to(self, user):
|
||||
return self.filter(
|
||||
Q(game_master=user)
|
||||
| Q(characters__in=Character.objects.filter(player=user))
|
||||
| Q(invited_characters__in=Character.objects.filter(player=user))
|
||||
| Q(invited_characters__in=Character.objects.filter(player=user)),
|
||||
).distinct()
|
||||
|
||||
def invited_to(self, user):
|
||||
|
@ -35,7 +35,7 @@ class PartyManager(UniquelyNamedModelManager):
|
|||
pass
|
||||
|
||||
|
||||
class Party(UniquelyNamedModel, TimeStampedModel, models.Model):
|
||||
class Party(UniquelyNamedModel, TimeStampedModel, models.Model): # noqa: DJ008
|
||||
game_master = models.ForeignKey(
|
||||
"common.User",
|
||||
on_delete=models.PROTECT,
|
||||
|
@ -96,7 +96,10 @@ class BattleEffectManager(models.Manager):
|
|||
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"
|
||||
max_length=100,
|
||||
blank=False,
|
||||
null=False,
|
||||
verbose_name="cible",
|
||||
)
|
||||
description = models.TextField(blank=True, null=False, verbose_name="description")
|
||||
remaining_rounds = models.SmallIntegerField(
|
||||
|
@ -126,3 +129,6 @@ class BattleEffect(TimeStampedModel, models.Model):
|
|||
if self.remaining_rounds >= max_display_percent or self.remaining_rounds < 0:
|
||||
return 100
|
||||
return self.remaining_rounds / max_display_percent * 100
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
|
@ -28,7 +28,8 @@ def test_add_character_to_existing_group(selenium: WebDriver, live_server: LiveS
|
|||
|
||||
selenium.get(live_server.url + reverse("party:list"))
|
||||
selenium.find_element(
|
||||
By.CSS_SELECTOR, f".party[data-id='{party.pk}'] .edit"
|
||||
By.CSS_SELECTOR,
|
||||
f".party[data-id='{party.pk}'] .edit",
|
||||
).click()
|
||||
invited = Select(selenium.find_element(By.ID, "id_invited_characters"))
|
||||
invited.select_by_index(0)
|
||||
|
@ -41,7 +42,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,
|
||||
):
|
||||
username, password = "gm", "password"
|
||||
gm = User.objects.create_user(username, password=password)
|
||||
|
@ -54,10 +56,12 @@ def test_gm_observe_invited_character_in_group(
|
|||
|
||||
selenium.get(live_server.url + reverse("party:list"))
|
||||
selenium.find_element(
|
||||
By.CSS_SELECTOR, f".party[data-id='{party.pk}'] .access"
|
||||
By.CSS_SELECTOR,
|
||||
f".party[data-id='{party.pk}'] .access",
|
||||
).click()
|
||||
selenium.find_element(
|
||||
By.CSS_SELECTOR, f".character[data-id='{character.pk}'] .observe"
|
||||
By.CSS_SELECTOR,
|
||||
f".character[data-id='{character.pk}'] .observe",
|
||||
).click()
|
||||
title = selenium.find_element(By.TAG_NAME, "h1").text.strip()
|
||||
assert title == character.name
|
||||
|
@ -65,7 +69,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,
|
||||
):
|
||||
username, password = "gm", "password"
|
||||
gm = User.objects.create_user(username, password=password)
|
||||
|
@ -80,10 +85,12 @@ def test_gm_observe_invited_character_in_two_groups(
|
|||
|
||||
selenium.get(live_server.url + reverse("party:list"))
|
||||
selenium.find_element(
|
||||
By.CSS_SELECTOR, f".party[data-id='{party.pk}'] .access"
|
||||
By.CSS_SELECTOR,
|
||||
f".party[data-id='{party.pk}'] .access",
|
||||
).click()
|
||||
selenium.find_element(
|
||||
By.CSS_SELECTOR, f".character[data-id='{character.pk}'] .observe"
|
||||
By.CSS_SELECTOR,
|
||||
f".character[data-id='{character.pk}'] .observe",
|
||||
).click()
|
||||
title = selenium.find_element(By.TAG_NAME, "h1").text.strip()
|
||||
assert title == character.name
|
||||
|
@ -91,7 +98,9 @@ def test_gm_observe_invited_character_in_two_groups(
|
|||
|
||||
@pytest.mark.django_db()
|
||||
def test_reset_stats_view(
|
||||
selenium: WebDriver, live_server: LiveServer, initial_data: None
|
||||
selenium: WebDriver,
|
||||
live_server: LiveServer,
|
||||
initial_data: None,
|
||||
):
|
||||
user, password = "gm", "password"
|
||||
gm = User.objects.create_user(user, password=password)
|
||||
|
@ -189,13 +198,22 @@ def test_gm_can_change_remaining_rounds(selenium: WebDriver, live_server: LiveSe
|
|||
party=party,
|
||||
)
|
||||
active_nearly_terminated = baker.make(
|
||||
BattleEffect, _quantity=3, remaining_rounds=1, party=party
|
||||
BattleEffect,
|
||||
_quantity=3,
|
||||
remaining_rounds=1,
|
||||
party=party,
|
||||
)
|
||||
terminated = baker.make( # noqa: F841
|
||||
BattleEffect, _quantity=5, remaining_rounds=0, party=party
|
||||
BattleEffect,
|
||||
_quantity=5,
|
||||
remaining_rounds=0,
|
||||
party=party,
|
||||
)
|
||||
permanent = baker.make( # noqa: F841
|
||||
BattleEffect, _quantity=2, remaining_rounds=-1, party=party
|
||||
BattleEffect,
|
||||
_quantity=2,
|
||||
remaining_rounds=-1,
|
||||
party=party,
|
||||
)
|
||||
not_party = baker.make(BattleEffect, _quantity=4, remaining_rounds=55) # noqa: F841
|
||||
beacon = active_nearly_terminated[0]
|
||||
|
@ -246,7 +264,8 @@ def test_gm_can_change_remaining_rounds(selenium: WebDriver, live_server: LiveSe
|
|||
|
||||
@pytest.mark.django_db()
|
||||
def test_gm_can_delete_any_existing_effect(
|
||||
selenium: WebDriver, live_server: LiveServer
|
||||
selenium: WebDriver,
|
||||
live_server: LiveServer,
|
||||
):
|
||||
"""The GM of a group can delete any existing effect, running or terminated."""
|
||||
user, password = "gm", "password"
|
||||
|
@ -258,7 +277,8 @@ def test_gm_can_delete_any_existing_effect(
|
|||
|
||||
go_to_party(selenium, live_server, party, user, password)
|
||||
selenium.find_element(
|
||||
By.CSS_SELECTOR, f'.effect[data-id="{effects[0].pk}"] .delete'
|
||||
By.CSS_SELECTOR,
|
||||
f'.effect[data-id="{effects[0].pk}"] .delete',
|
||||
).click()
|
||||
|
||||
assert BattleEffect.objects.count() == 1
|
||||
|
@ -267,7 +287,8 @@ def test_gm_can_delete_any_existing_effect(
|
|||
|
||||
@pytest.mark.django_db()
|
||||
def test_player_cant_change_existing_running_effect(
|
||||
selenium: WebDriver, live_server: LiveServer
|
||||
selenium: WebDriver,
|
||||
live_server: LiveServer,
|
||||
):
|
||||
"""Members of the group can only view existing running effects, no update."""
|
||||
user, password = "player", "password"
|
||||
|
@ -280,7 +301,8 @@ def test_player_cant_change_existing_running_effect(
|
|||
go_to_party(selenium, live_server, party, user, password)
|
||||
effect = effects[0]
|
||||
effect_element = selenium.find_element(
|
||||
By.CSS_SELECTOR, f'.effect[data-id="{effect.pk}"]'
|
||||
By.CSS_SELECTOR,
|
||||
f'.effect[data-id="{effect.pk}"]',
|
||||
)
|
||||
assert effect.name in effect_element.text
|
||||
assert effect.target in effect_element.text
|
||||
|
@ -295,7 +317,11 @@ def test_player_cant_change_existing_running_effect(
|
|||
|
||||
|
||||
def fill_effect(
|
||||
selenium: WebDriver, name: str, description: str, target: str, remaining_rounds: str
|
||||
selenium: WebDriver,
|
||||
name: str,
|
||||
description: str,
|
||||
target: str,
|
||||
remaining_rounds: str,
|
||||
) -> None:
|
||||
selenium.find_element(By.ID, "add-effect").click()
|
||||
selenium.find_element(By.ID, "id_name").send_keys(name)
|
||||
|
@ -308,7 +334,10 @@ def fill_effect(
|
|||
|
||||
|
||||
def assert_effect_is_created(
|
||||
name: str, description: str, target: str, remaining_rounds: str
|
||||
name: str,
|
||||
description: str,
|
||||
target: str,
|
||||
remaining_rounds: str,
|
||||
) -> BattleEffect:
|
||||
assert BattleEffect.objects.count() == 1
|
||||
effect = BattleEffect.objects.first()
|
||||
|
@ -320,7 +349,11 @@ def assert_effect_is_created(
|
|||
|
||||
|
||||
def go_to_party(
|
||||
selenium: WebDriver, live_server: LiveServer, party: Party, user: str, password: str
|
||||
selenium: WebDriver,
|
||||
live_server: LiveServer,
|
||||
party: Party,
|
||||
user: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
login(selenium, live_server, user, password)
|
||||
url = reverse("party:details", kwargs={"pk": party.pk})
|
||||
|
|
|
@ -17,10 +17,14 @@ urlpatterns = [
|
|||
name="delete_effect",
|
||||
),
|
||||
path(
|
||||
"<int:pk>/increase_rounds/", views.party_increase_rounds, name="increase_rounds"
|
||||
"<int:pk>/increase_rounds/",
|
||||
views.party_increase_rounds,
|
||||
name="increase_rounds",
|
||||
),
|
||||
path(
|
||||
"<int:pk>/decrease_rounds/", views.party_decrease_rounds, name="decrease_rounds"
|
||||
"<int:pk>/decrease_rounds/",
|
||||
views.party_decrease_rounds,
|
||||
name="decrease_rounds",
|
||||
),
|
||||
path("<int:pk>/leave/<int:character_pk>/", views.party_leave, name="leave"),
|
||||
path("<int:pk>/join/<int:character_pk>/", views.party_join, name="join"),
|
||||
|
|
|
@ -139,7 +139,8 @@ def party_change(request, pk):
|
|||
def party_leave(request, pk, character_pk):
|
||||
party = get_object_or_404(Party.objects.played_by(request.user).distinct(), pk=pk)
|
||||
character = get_object_or_404(
|
||||
Character.objects.owned_by(request.user), pk=character_pk
|
||||
Character.objects.owned_by(request.user),
|
||||
pk=character_pk,
|
||||
)
|
||||
context = {"party": party, "character": character}
|
||||
if request.method == "POST":
|
||||
|
@ -154,7 +155,8 @@ def party_leave(request, pk, character_pk):
|
|||
def party_join(request, pk, character_pk):
|
||||
party = get_object_or_404(Party.objects.invited_to(request.user).distinct(), pk=pk)
|
||||
character = get_object_or_404(
|
||||
Character.objects.owned_by(request.user), pk=character_pk
|
||||
Character.objects.owned_by(request.user),
|
||||
pk=character_pk,
|
||||
)
|
||||
party.characters.add(character)
|
||||
party.invited_characters.remove(character)
|
||||
|
@ -167,7 +169,8 @@ def party_join(request, pk, character_pk):
|
|||
def party_refuse(request, pk, character_pk):
|
||||
party = get_object_or_404(Party.objects.invited_to(request.user).distinct(), pk=pk)
|
||||
character = get_object_or_404(
|
||||
Character.objects.owned_by(request.user), pk=character_pk
|
||||
Character.objects.owned_by(request.user),
|
||||
pk=character_pk,
|
||||
)
|
||||
party.invited_characters.remove(character)
|
||||
messages.success(request, f"{character} a refusé l'invitation au groupe {party}.")
|
||||
|
|
4
tasks.py
4
tasks.py
|
@ -84,7 +84,9 @@ def download_db(ctx: Context):
|
|||
)
|
||||
ctx.run("rm -rf src/media/", pty=True, echo=True)
|
||||
ctx.run(
|
||||
"scp -r ubuntu:/mnt/data/charasheet/media/ ./src/media", pty=True, echo=True
|
||||
"scp -r ubuntu:/mnt/data/charasheet/media/ ./src/media",
|
||||
pty=True,
|
||||
echo=True,
|
||||
)
|
||||
with ctx.cd(SRC_DIR):
|
||||
ctx.run("./manage.py changepassword gaugendre", pty=True, echo=True)
|
||||
|
|
Loading…
Reference in a new issue