707 lines
22 KiB
Python
707 lines
22 KiB
Python
# Copyright (C) 2020 by Animath
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
from datetime import date
|
|
import os
|
|
|
|
from address.models import AddressField
|
|
from django.conf import settings
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.validators import RegexValidator
|
|
from django.db import models
|
|
from django.db.models import Index
|
|
from django.urls import reverse_lazy
|
|
from django.utils import timezone
|
|
from django.utils.crypto import get_random_string
|
|
from django.utils.text import format_lazy
|
|
from django.utils.translation import gettext_lazy as _
|
|
from registration.models import VolunteerRegistration
|
|
from tfjm.lists import get_sympa_client
|
|
from tfjm.matrix import Matrix, RoomPreset, RoomVisibility
|
|
|
|
|
|
def get_motivation_letter_filename(instance, filename):
|
|
return f"authorization/motivation_letters/motivation_letter_{instance.trigram}"
|
|
|
|
|
|
class Team(models.Model):
|
|
"""
|
|
The Team model represents a real team that participates to the TFJM².
|
|
This only includes the registration detail.
|
|
"""
|
|
name = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_("name"),
|
|
unique=True,
|
|
)
|
|
|
|
trigram = models.CharField(
|
|
max_length=3,
|
|
verbose_name=_("trigram"),
|
|
help_text=_("The trigram must be composed of three uppercase letters."),
|
|
unique=True,
|
|
validators=[RegexValidator("[A-Z]{3}")],
|
|
)
|
|
|
|
access_code = models.CharField(
|
|
max_length=6,
|
|
verbose_name=_("access code"),
|
|
help_text=_("The access code let other people to join the team."),
|
|
)
|
|
|
|
motivation_letter = models.FileField(
|
|
verbose_name=_("motivation letter"),
|
|
upload_to=get_motivation_letter_filename,
|
|
blank=True,
|
|
default="",
|
|
)
|
|
|
|
@property
|
|
def students(self):
|
|
return self.participants.filter(studentregistration__isnull=False)
|
|
|
|
@property
|
|
def coaches(self):
|
|
return self.participants.filter(coachregistration__isnull=False)
|
|
|
|
@property
|
|
def email(self):
|
|
"""
|
|
:return: The mailing list to contact the team members.
|
|
"""
|
|
return f"equipe-{self.trigram.lower()}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
|
|
|
def create_mailing_list(self):
|
|
"""
|
|
Create a new Sympa mailing list to contact the team.
|
|
"""
|
|
get_sympa_client().create_list(
|
|
f"equipe-{self.trigram.lower()}",
|
|
f"Equipe {self.name} ({self.trigram})",
|
|
"hotline", # TODO Use a custom sympa template
|
|
f"Liste de diffusion pour contacter l'equipe {self.name} du TFJM2",
|
|
"education",
|
|
raise_error=False,
|
|
)
|
|
|
|
def delete_mailing_list(self):
|
|
"""
|
|
Drop the Sympa mailing list, if the team is empty or if the trigram changed.
|
|
"""
|
|
if self.participation.valid: # pragma: no cover
|
|
get_sympa_client().unsubscribe(
|
|
self.email, f"equipes-{self.participation.tournament.name.lower().replace(' ', '-')}", False)
|
|
else:
|
|
get_sympa_client().unsubscribe(self.email, "equipes-non-valides", False)
|
|
get_sympa_client().delete_list(f"equipe-{self.trigram}")
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.access_code:
|
|
# if the team got created, generate the access code, create the contact mailing list
|
|
# and create a dedicated Matrix room.
|
|
self.access_code = get_random_string(6)
|
|
self.create_mailing_list()
|
|
|
|
Matrix.create_room(
|
|
visibility=RoomVisibility.private,
|
|
name=f"#équipe-{self.trigram.lower()}",
|
|
alias=f"equipe-{self.trigram.lower()}",
|
|
topic=f"Discussion de l'équipe {self.name}",
|
|
preset=RoomPreset.private_chat,
|
|
)
|
|
|
|
return super().save(*args, **kwargs)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:team_detail", args=(self.pk,))
|
|
|
|
def __str__(self):
|
|
return _("Team {name} ({trigram})").format(name=self.name, trigram=self.trigram)
|
|
|
|
class Meta:
|
|
verbose_name = _("team")
|
|
verbose_name_plural = _("teams")
|
|
indexes = [
|
|
Index(fields=("trigram", )),
|
|
]
|
|
|
|
|
|
class Tournament(models.Model):
|
|
name = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_("name"),
|
|
unique=True,
|
|
)
|
|
|
|
date_start = models.DateField(
|
|
verbose_name=_("start"),
|
|
default=date.today,
|
|
)
|
|
|
|
date_end = models.DateField(
|
|
verbose_name=_("end"),
|
|
default=date.today,
|
|
)
|
|
|
|
place = AddressField(
|
|
verbose_name=_("place"),
|
|
)
|
|
|
|
max_teams = models.PositiveSmallIntegerField(
|
|
verbose_name=_("max team count"),
|
|
default=9,
|
|
)
|
|
|
|
price = models.PositiveSmallIntegerField(
|
|
verbose_name=_("price"),
|
|
default=21,
|
|
)
|
|
|
|
remote = models.BooleanField(
|
|
verbose_name=_("remote"),
|
|
default=False,
|
|
)
|
|
|
|
inscription_limit = models.DateTimeField(
|
|
verbose_name=_("limit date for registrations"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
solution_limit = models.DateTimeField(
|
|
verbose_name=_("limit date to upload solutions"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
solutions_draw = models.DateTimeField(
|
|
verbose_name=_("random draw for solutions"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
syntheses_first_phase_limit = models.DateTimeField(
|
|
verbose_name=_("limit date to upload the syntheses for the first phase"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
solutions_available_second_phase = models.DateTimeField(
|
|
verbose_name=_("date when the solutions for the second round become available"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
syntheses_second_phase_limit = models.DateTimeField(
|
|
verbose_name=_("limit date to upload the syntheses for the second phase"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
description = models.TextField(
|
|
verbose_name=_("description"),
|
|
blank=True,
|
|
)
|
|
|
|
organizers = models.ManyToManyField(
|
|
VolunteerRegistration,
|
|
verbose_name=_("organizers"),
|
|
related_name="organized_tournaments",
|
|
)
|
|
|
|
final = models.BooleanField(
|
|
verbose_name=_("final"),
|
|
default=False,
|
|
)
|
|
|
|
@property
|
|
def teams_email(self):
|
|
"""
|
|
:return: The mailing list to contact the team members.
|
|
"""
|
|
return f"equipes-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
|
|
|
@property
|
|
def organizers_email(self):
|
|
"""
|
|
:return: The mailing list to contact the team members.
|
|
"""
|
|
return f"organisateurs-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
|
|
|
@property
|
|
def jurys_email(self):
|
|
"""
|
|
:return: The mailing list to contact the team members.
|
|
"""
|
|
return f"jurys-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
|
|
|
def create_mailing_lists(self):
|
|
"""
|
|
Create a new Sympa mailing list to contact the team.
|
|
"""
|
|
get_sympa_client().create_list(
|
|
f"equipes-{self.name.lower().replace(' ', '-')}",
|
|
f"Equipes du tournoi de {self.name}",
|
|
"hotline", # TODO Use a custom sympa template
|
|
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
|
|
"education",
|
|
raise_error=False,
|
|
)
|
|
get_sympa_client().create_list(
|
|
f"organisateurs-{self.name.lower().replace(' ', '-')}",
|
|
f"Organisateurs du tournoi de {self.name}",
|
|
"hotline", # TODO Use a custom sympa template
|
|
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
|
|
"education",
|
|
raise_error=False,
|
|
)
|
|
|
|
@staticmethod
|
|
def final_tournament():
|
|
qs = Tournament.objects.filter(final=True)
|
|
if qs.exists():
|
|
return qs.get()
|
|
|
|
@property
|
|
def participations(self):
|
|
if self.final:
|
|
return Participation.objects.filter(final=True)
|
|
return self.participation_set
|
|
|
|
@property
|
|
def solutions(self):
|
|
if self.final:
|
|
return Solution.objects.filter(final_solution=True)
|
|
return Solution.objects.filter(participation__tournament=self)
|
|
|
|
@property
|
|
def syntheses(self):
|
|
if self.final:
|
|
return Synthesis.objects.filter(final_solution=True)
|
|
return Synthesis.objects.filter(participation__tournament=self)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:tournament_detail", args=(self.pk,))
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
verbose_name = _("tournament")
|
|
verbose_name_plural = _("tournaments")
|
|
indexes = [
|
|
Index(fields=("name", "date_start", "date_end", )),
|
|
]
|
|
|
|
|
|
class Participation(models.Model):
|
|
"""
|
|
The Participation model contains all data that are related to the participation:
|
|
chosen problem, validity status, solutions,...
|
|
"""
|
|
team = models.OneToOneField(
|
|
Team,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("team"),
|
|
)
|
|
|
|
tournament = models.ForeignKey(
|
|
Tournament,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
default=None,
|
|
verbose_name=_("tournament"),
|
|
)
|
|
|
|
valid = models.BooleanField(
|
|
null=True,
|
|
default=None,
|
|
verbose_name=_("valid"),
|
|
help_text=_("The participation got the validation of the organizers."),
|
|
)
|
|
|
|
final = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_("selected for final"),
|
|
help_text=_("The team is selected for the final tournament."),
|
|
)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:participation_detail", args=(self.pk,))
|
|
|
|
def __str__(self):
|
|
return _("Participation of the team {name} ({trigram})").format(name=self.team.name, trigram=self.team.trigram)
|
|
|
|
class Meta:
|
|
verbose_name = _("participation")
|
|
verbose_name_plural = _("participations")
|
|
|
|
|
|
class Pool(models.Model):
|
|
tournament = models.ForeignKey(
|
|
Tournament,
|
|
on_delete=models.CASCADE,
|
|
related_name="pools",
|
|
verbose_name=_("tournament"),
|
|
)
|
|
|
|
round = models.PositiveSmallIntegerField(
|
|
verbose_name=_("round"),
|
|
choices=[
|
|
(1, format_lazy(_("Round {round}"), round=1)),
|
|
(2, format_lazy(_("Round {round}"), round=2)),
|
|
]
|
|
)
|
|
|
|
participations = models.ManyToManyField(
|
|
Participation,
|
|
related_name="pools",
|
|
verbose_name=_("participations"),
|
|
)
|
|
|
|
juries = models.ManyToManyField(
|
|
VolunteerRegistration,
|
|
related_name="jury_in",
|
|
verbose_name=_("juries"),
|
|
)
|
|
|
|
bbb_url = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
verbose_name=_("BigBlueButton URL"),
|
|
help_text=_("The link of the BBB visio for this pool."),
|
|
)
|
|
|
|
results_available = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_("results available"),
|
|
help_text=_("Check this case when results become accessible to teams. "
|
|
"They stay accessible to you. Only averages are given."),
|
|
)
|
|
|
|
@property
|
|
def solutions(self):
|
|
return Solution.objects.filter(participation__in=self.participations, final_solution=self.tournament.final)
|
|
|
|
def average(self, participation):
|
|
return sum(passage.average(participation) for passage in self.passages.all()) \
|
|
+ sum(tweak.diff for tweak in participation.tweaks.filter(pool=self).all())
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:pool_detail", args=(self.pk,))
|
|
|
|
def __str__(self):
|
|
return _("Pool of day {round} for tournament {tournament} with teams {teams}")\
|
|
.format(round=self.round,
|
|
tournament=str(self.tournament),
|
|
teams=", ".join(participation.team.trigram for participation in self.participations.all()))
|
|
|
|
class Meta:
|
|
verbose_name = _("pool")
|
|
verbose_name_plural = _("pools")
|
|
|
|
|
|
class Passage(models.Model):
|
|
pool = models.ForeignKey(
|
|
Pool,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("pool"),
|
|
related_name="passages",
|
|
)
|
|
|
|
solution_number = models.PositiveSmallIntegerField(
|
|
verbose_name=_("defended solution"),
|
|
choices=[
|
|
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1)
|
|
],
|
|
)
|
|
|
|
defender = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.PROTECT,
|
|
verbose_name=_("defender"),
|
|
related_name="+",
|
|
)
|
|
|
|
opponent = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.PROTECT,
|
|
verbose_name=_("opponent"),
|
|
related_name="+",
|
|
)
|
|
|
|
reporter = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.PROTECT,
|
|
verbose_name=_("reporter"),
|
|
related_name="+",
|
|
)
|
|
|
|
defender_penalties = models.PositiveSmallIntegerField(
|
|
verbose_name=_("penalties"),
|
|
default=0,
|
|
help_text=_("Number of penalties for the defender. "
|
|
"The defender will loose a 0.5 coefficient per penalty."),
|
|
)
|
|
|
|
@property
|
|
def defended_solution(self) -> "Solution":
|
|
return Solution.objects.get(
|
|
participation=self.defender,
|
|
problem=self.solution_number,
|
|
final_solution=self.pool.tournament.final)
|
|
|
|
def avg(self, iterator) -> float:
|
|
items = [i for i in iterator if i]
|
|
return sum(items) / len(items) if items else 0
|
|
|
|
@property
|
|
def average_defender_writing(self) -> float:
|
|
return self.avg(note.defender_writing for note in self.notes.all())
|
|
|
|
@property
|
|
def average_defender_oral(self) -> float:
|
|
return self.avg(note.defender_oral for note in self.notes.all())
|
|
|
|
@property
|
|
def average_defender(self) -> float:
|
|
return self.average_defender_writing + (2 - 0.5 * self.defender_penalties) * self.average_defender_oral
|
|
|
|
@property
|
|
def average_opponent_writing(self) -> float:
|
|
return self.avg(note.opponent_writing for note in self.notes.all())
|
|
|
|
@property
|
|
def average_opponent_oral(self) -> float:
|
|
return self.avg(note.opponent_oral for note in self.notes.all())
|
|
|
|
@property
|
|
def average_opponent(self) -> float:
|
|
return self.average_opponent_writing + 2 * self.average_opponent_oral
|
|
|
|
@property
|
|
def average_reporter_writing(self) -> float:
|
|
return self.avg(note.reporter_writing for note in self.notes.all())
|
|
|
|
@property
|
|
def average_reporter_oral(self) -> float:
|
|
return self.avg(note.reporter_oral for note in self.notes.all())
|
|
|
|
@property
|
|
def average_reporter(self) -> float:
|
|
return self.average_reporter_writing + self.average_reporter_oral
|
|
|
|
def average(self, participation):
|
|
return self.average_defender if participation == self.defender else self.average_opponent \
|
|
if participation == self.opponent else self.average_reporter if participation == self.reporter else 0
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:passage_detail", args=(self.pk,))
|
|
|
|
def clean(self):
|
|
if self.defender not in self.pool.participations.all():
|
|
raise ValidationError(_("Team {trigram} is not registered in the pool.")
|
|
.format(trigram=self.defender.team.trigram))
|
|
if self.opponent not in self.pool.participations.all():
|
|
raise ValidationError(_("Team {trigram} is not registered in the pool.")
|
|
.format(trigram=self.opponent.team.trigram))
|
|
if self.reporter not in self.pool.participations.all():
|
|
raise ValidationError(_("Team {trigram} is not registered in the pool.")
|
|
.format(trigram=self.reporter.team.trigram))
|
|
return super().clean()
|
|
|
|
def __str__(self):
|
|
return _("Passage of {defender} for problem {problem}")\
|
|
.format(defender=self.defender.team, problem=self.solution_number)
|
|
|
|
class Meta:
|
|
verbose_name = _("passage")
|
|
verbose_name_plural = _("passages")
|
|
|
|
|
|
class Tweak(models.Model):
|
|
pool = models.ForeignKey(
|
|
Pool,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("passage"),
|
|
)
|
|
|
|
participation = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("participation"),
|
|
related_name='tweaks',
|
|
)
|
|
|
|
diff = models.IntegerField(
|
|
verbose_name=_("difference"),
|
|
help_text=_("Score to add/remove on the final score"),
|
|
)
|
|
|
|
def __str__(self):
|
|
return f"Tweak for {self.participation.team} of {self.diff} points"
|
|
|
|
class Meta:
|
|
verbose_name = _("tweak")
|
|
verbose_name_plural = _("tweaks")
|
|
|
|
|
|
def get_solution_filename(instance, filename):
|
|
return f"solutions/{instance.participation.team.trigram}_{instance.problem}" \
|
|
+ ("final" if instance.final_solution else "")
|
|
|
|
|
|
def get_synthesis_filename(instance, filename):
|
|
return f"syntheses/{instance.participation.team.trigram}_{instance.type}_{instance.passage.pk}"
|
|
|
|
|
|
class Solution(models.Model):
|
|
participation = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("participation"),
|
|
related_name="solutions",
|
|
)
|
|
|
|
problem = models.PositiveSmallIntegerField(
|
|
verbose_name=_("problem"),
|
|
choices=[
|
|
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1)
|
|
],
|
|
)
|
|
|
|
final_solution = models.BooleanField(
|
|
verbose_name=_("solution for the final tournament"),
|
|
default=False,
|
|
)
|
|
|
|
file = models.FileField(
|
|
verbose_name=_("file"),
|
|
upload_to=get_solution_filename,
|
|
unique=True,
|
|
)
|
|
|
|
def __str__(self):
|
|
return _("Solution of team {team} for problem {problem}")\
|
|
.format(team=self.participation.team.name, problem=self.problem)\
|
|
+ (" " + str(_("for final")) if self.final_solution else "")
|
|
|
|
class Meta:
|
|
verbose_name = _("solution")
|
|
verbose_name_plural = _("solutions")
|
|
unique_together = (('participation', 'problem', 'final_solution', ), )
|
|
ordering = ('participation__team__trigram', 'final_solution', 'problem',)
|
|
|
|
|
|
class Synthesis(models.Model):
|
|
participation = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("participation"),
|
|
)
|
|
|
|
passage = models.ForeignKey(
|
|
Passage,
|
|
on_delete=models.CASCADE,
|
|
related_name="syntheses",
|
|
verbose_name=_("passage"),
|
|
)
|
|
|
|
type = models.PositiveSmallIntegerField(
|
|
choices=[
|
|
(1, _("opponent"), ),
|
|
(2, _("reporter"), ),
|
|
]
|
|
)
|
|
|
|
file = models.FileField(
|
|
verbose_name=_("file"),
|
|
upload_to=get_synthesis_filename,
|
|
unique=True,
|
|
)
|
|
|
|
def __str__(self):
|
|
return _("Synthesis of {team} as {type} for problem {problem} of {defender}").format(
|
|
team=self.participation.team.trigram,
|
|
type=self.get_type_display(),
|
|
problem=self.passage.solution_number,
|
|
defender=self.passage.defender.team.trigram,
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("synthesis")
|
|
verbose_name_plural = _("syntheses")
|
|
unique_together = (('participation', 'passage', 'type', ), )
|
|
ordering = ('passage__pool__round', 'type',)
|
|
|
|
|
|
class Note(models.Model):
|
|
jury = models.ForeignKey(
|
|
VolunteerRegistration,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("jury"),
|
|
related_name="notes",
|
|
)
|
|
|
|
passage = models.ForeignKey(
|
|
Passage,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("passage"),
|
|
related_name="notes",
|
|
)
|
|
|
|
defender_writing = models.PositiveSmallIntegerField(
|
|
verbose_name=_("defender writing note"),
|
|
choices=[(i, i) for i in range(0, 21)],
|
|
default=0,
|
|
)
|
|
|
|
defender_oral = models.PositiveSmallIntegerField(
|
|
verbose_name=_("defender oral note"),
|
|
choices=[(i, i) for i in range(0, 17)],
|
|
default=0,
|
|
)
|
|
|
|
opponent_writing = models.PositiveSmallIntegerField(
|
|
verbose_name=_("opponent writing note"),
|
|
choices=[(i, i) for i in range(0, 10)],
|
|
default=0,
|
|
)
|
|
|
|
opponent_oral = models.PositiveSmallIntegerField(
|
|
verbose_name=_("opponent oral note"),
|
|
choices=[(i, i) for i in range(0, 11)],
|
|
default=0,
|
|
)
|
|
|
|
reporter_writing = models.PositiveSmallIntegerField(
|
|
verbose_name=_("reporter writing note"),
|
|
choices=[(i, i) for i in range(0, 10)],
|
|
default=0,
|
|
)
|
|
|
|
reporter_oral = models.PositiveSmallIntegerField(
|
|
verbose_name=_("reporter oral note"),
|
|
choices=[(i, i) for i in range(0, 11)],
|
|
default=0,
|
|
)
|
|
|
|
def set_all(self, defender_writing: int, defender_oral: int, opponent_writing: int, opponent_oral: int,
|
|
reporter_writing: int, reporter_oral: int):
|
|
self.defender_writing = defender_writing
|
|
self.defender_oral = defender_oral
|
|
self.opponent_writing = opponent_writing
|
|
self.opponent_oral = opponent_oral
|
|
self.reporter_writing = reporter_writing
|
|
self.reporter_oral = reporter_oral
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
|
|
|
|
def __str__(self):
|
|
return _("Notes of {jury} for {passage}").format(jury=self.jury, passage=self.passage)
|
|
|
|
def __bool__(self):
|
|
return any((self.defender_writing, self.defender_oral, self.opponent_writing, self.opponent_oral,
|
|
self.reporter_writing, self.reporter_oral))
|
|
|
|
class Meta:
|
|
verbose_name = _("note")
|
|
verbose_name_plural = _("notes")
|