Odstrel Modelu Odevzdavatko #64
Merged
zelvuska
merged 12 commits from odstrel_modelu_odevzdavatko
into master
2 months ago
20 changed files with 550 additions and 251 deletions
@ -0,0 +1,99 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-22 22:51 |
|||
|
|||
from django.db import migrations, models |
|||
import django.utils.timezone |
|||
import odevzdavatko.models |
|||
|
|||
def nastav_nove_contenttypes(apps, schema_editor): |
|||
ContentType = apps.get_model('contenttypes', 'ContentType') |
|||
for m in ('reseni', 'hodnoceni', 'reseni_resitele', 'prilohareseni'): |
|||
ContentType.objects.filter(app_label='seminar', model=m).update(app_label='odevzdavatko') |
|||
|
|||
def nastav_stare_contenttypes(apps, schema_editor): |
|||
ContentType = apps.get_model('contenttypes', 'ContentType') |
|||
for m in ('reseni', 'hodnoceni', 'reseni_resitele', 'prilohareseni'): |
|||
ContentType.objects.filter(app_label='odevzdavatko', model=m).update(app_label='seminar') |
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
initial = True |
|||
|
|||
dependencies = [ |
|||
('seminar', '0132_unmanage_odevzdavatko'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.CreateModel( |
|||
name='Hodnoceni', |
|||
fields=[ |
|||
('id', models.AutoField(primary_key=True, serialize=False)), |
|||
('body', models.DecimalField(blank=True, decimal_places=1, max_digits=8, null=True, verbose_name='body')), |
|||
('feedback', models.TextField(blank=True, default='', help_text='Zpětná vazba řešiteli (plain text)', verbose_name='zpětná vazba')), |
|||
('cislo_body', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='hodnoceni', to='seminar.cislo', verbose_name='číslo pro body')), |
|||
('deadline_body', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='hodnoceni', to='seminar.deadline', verbose_name='deadline pro body')), |
|||
('problem', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='hodnoceni', to='seminar.problem', verbose_name='problém')), |
|||
('reseni', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='odevzdavatko.reseni', verbose_name='řešení')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Hodnocení', |
|||
'verbose_name_plural': 'Hodnocení', |
|||
'db_table': 'seminar_hodnoceni', |
|||
'managed': False, |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='PrilohaReseni', |
|||
fields=[ |
|||
('id', models.AutoField(primary_key=True, serialize=False)), |
|||
('vytvoreno', models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False, verbose_name='vytvořeno')), |
|||
('soubor', models.FileField(upload_to=odevzdavatko.models.generate_filename, verbose_name='soubor')), |
|||
('poznamka', models.TextField(blank=True, help_text='Neveřejná poznámka k příloze řešení (plain text), např. o původu', verbose_name='neveřejná poznámka')), |
|||
('res_poznamka', models.TextField(blank=True, help_text='Poznámka k příloze řešení, např. co daný soubor obsahuje', verbose_name='poznámka řešitele')), |
|||
('reseni', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prilohy', to='odevzdavatko.reseni', verbose_name='řešení')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Příloha řešení', |
|||
'verbose_name_plural': 'Přílohy řešení', |
|||
'db_table': 'seminar_priloha_reseni', |
|||
'ordering': ['reseni', 'vytvoreno'], |
|||
'managed': False, |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='Reseni', |
|||
fields=[ |
|||
('id', models.AutoField(primary_key=True, serialize=False)), |
|||
('cas_doruceni', models.DateTimeField(blank=True, default=django.utils.timezone.now, verbose_name='čas_doručení')), |
|||
('forma', models.CharField(choices=[('papir', 'Papírové řešení'), ('email', 'Emailem'), ('upload', 'Upload přes web')], default='email', max_length=16, verbose_name='forma řešení')), |
|||
('poznamka', models.TextField(blank=True, help_text='Neveřejná poznámka k řešení (plain text)', verbose_name='neveřejná poznámka')), |
|||
('zverejneno', models.BooleanField(default=False, help_text='Udává, zda je řešení zveřejněno', verbose_name='řešení zveřejněno')), |
|||
('problem', models.ManyToManyField(help_text='Problém', through='odevzdavatko.Hodnoceni', to='seminar.problem', verbose_name='problém')), |
|||
('resitele', models.ManyToManyField(help_text='Seznam autorů řešení', through='odevzdavatko.Reseni_Resitele', to='personalni.resitel', verbose_name='autoři řešení')), |
|||
('text_cely', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reseni_cely_set', to='seminar.reseninode', verbose_name='Plná verze textu řešení')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Řešení', |
|||
'verbose_name_plural': 'Řešení', |
|||
'db_table': 'seminar_reseni', |
|||
'ordering': ['-cas_doruceni'], |
|||
'managed': False, |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='Reseni_Resitele', |
|||
fields=[ |
|||
('id', models.AutoField(primary_key=True, serialize=False)), |
|||
('reseni', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='odevzdavatko.reseni', verbose_name='řešení')), |
|||
('resitele', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='personalni.resitel', verbose_name='řešitel')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Řešení řešitelů', |
|||
'verbose_name_plural': 'Řešení řešitelů', |
|||
'db_table': 'seminar_reseni_resitele', |
|||
'ordering': ['reseni', 'resitele'], |
|||
'managed': False, |
|||
}, |
|||
), |
|||
migrations.RunPython(nastav_nove_contenttypes, nastav_stare_contenttypes), |
|||
|
|||
] |
@ -0,0 +1,30 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-23 21:07 |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('odevzdavatko', '0001_create'), |
|||
('seminar', '0134_delete_odevzdavatko'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterModelOptions( |
|||
name='hodnoceni', |
|||
options={'verbose_name': 'Hodnocení', 'verbose_name_plural': 'Hodnocení'}, |
|||
), |
|||
migrations.AlterModelOptions( |
|||
name='prilohareseni', |
|||
options={'ordering': ['reseni', 'vytvoreno'], 'verbose_name': 'Příloha řešení', 'verbose_name_plural': 'Přílohy řešení'}, |
|||
), |
|||
migrations.AlterModelOptions( |
|||
name='reseni', |
|||
options={'ordering': ['-cas_doruceni'], 'verbose_name': 'Řešení', 'verbose_name_plural': 'Řešení'}, |
|||
), |
|||
migrations.AlterModelOptions( |
|||
name='reseni_resitele', |
|||
options={'ordering': ['reseni', 'resitele'], 'verbose_name': 'Řešení řešitelů', 'verbose_name_plural': 'Řešení řešitelů'}, |
|||
), |
|||
] |
@ -0,0 +1,13 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-23 21:10 |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('odevzdavatko', '0002_manage'), |
|||
] |
|||
|
|||
operations = [ |
|||
] |
@ -0,0 +1,239 @@ |
|||
import os |
|||
|
|||
import reversion |
|||
|
|||
from django.contrib.sites.shortcuts import get_current_site |
|||
from django.db import models |
|||
from django.db.models import Sum |
|||
from django.urls import reverse_lazy |
|||
from django.utils import timezone |
|||
from django.conf import settings |
|||
|
|||
import seminar.models as am # tvorba |
|||
from seminar.models import base as bm |
|||
|
|||
from odevzdavatko.utils import vzorecek_na_prepocet, inverze_vzorecku_na_prepocet |
|||
from personalni.models import Resitel |
|||
|
|||
@reversion.register(ignore_duplicates=True) |
|||
class Reseni(bm.SeminarModelBase): |
|||
|
|||
class Meta: |
|||
db_table = 'seminar_reseni' |
|||
verbose_name = 'Řešení' |
|||
verbose_name_plural = 'Řešení' |
|||
#ordering = ['-problem', 'resitele'] # FIXME: Takhle to chceme, ale nefunguje to. |
|||
ordering = ['-cas_doruceni'] |
|||
|
|||
# Interní ID |
|||
id = models.AutoField(primary_key = True) |
|||
|
|||
# Ke každé dvojici řešní a problém existuje nanejvýš jedno hodnocení, doplnění vazby. |
|||
problem = models.ManyToManyField(am.Problem, verbose_name='problém', help_text='Problém', |
|||
through='Hodnoceni') |
|||
|
|||
resitele = models.ManyToManyField(Resitel, verbose_name='autoři řešení', |
|||
help_text='Seznam autorů řešení', through='Reseni_Resitele') |
|||
|
|||
|
|||
cas_doruceni = models.DateTimeField('čas_doručení', default=timezone.now, blank=True) |
|||
|
|||
FORMA_PAPIR = 'papir' |
|||
FORMA_EMAIL = 'email' |
|||
FORMA_UPLOAD = 'upload' |
|||
FORMA_CHOICES = [ |
|||
(FORMA_PAPIR, 'Papírové řešení'), |
|||
(FORMA_EMAIL, 'Emailem'), |
|||
(FORMA_UPLOAD, 'Upload přes web'), |
|||
] |
|||
forma = models.CharField('forma řešení', max_length=16, choices=FORMA_CHOICES, blank=False, |
|||
default=FORMA_EMAIL) |
|||
|
|||
text_cely = models.OneToOneField('seminar.ReseniNode', verbose_name='Plná verze textu řešení', |
|||
blank=True, null=True, related_name="reseni_cely_set", |
|||
on_delete=models.PROTECT) |
|||
|
|||
poznamka = models.TextField('neveřejná poznámka', blank=True, |
|||
help_text='Neveřejná poznámka k řešení (plain text)') |
|||
|
|||
zverejneno = models.BooleanField('řešení zveřejněno', default=False, |
|||
help_text='Udává, zda je řešení zveřejněno') |
|||
|
|||
def verejne_url(self): |
|||
return str(reverse_lazy('odevzdavatko_detail_reseni', args=[self.id])) |
|||
|
|||
def absolute_url(self): |
|||
return "https://" + str(get_current_site(None)) + self.verejne_url() |
|||
|
|||
# má OneToOneField s: |
|||
# Konfera |
|||
|
|||
# má ForeignKey s: |
|||
# Hodnoceni |
|||
|
|||
def sum_body(self): |
|||
return self.hodnoceni_set.all().aggregate(Sum('body'))["body__sum"] |
|||
|
|||
def __str__(self): |
|||
return "{}({}): {}({})".format(self.resitele.first(),len(self.resitele.all()), self.problem.first() ,len(self.problem.all())) |
|||
# NOTE: Potenciální DB HOG (bez select_related) |
|||
|
|||
def deadline_reseni(self): |
|||
return am.Deadline.objects.filter(deadline__gte=self.cas_doruceni).order_by("deadline").first() |
|||
|
|||
## Pravdepodobne uz nebude potreba: |
|||
# def save(self, *args, **kwargs): |
|||
# if ((self.cislo_body is None) and (self.problem.cislo_reseni) and |
|||
# (self.problem.typ == Problem.TYP_ULOHA)): |
|||
# self.cislo_body = self.problem.cislo_reseni |
|||
# super(Reseni, self).save(*args, **kwargs) |
|||
|
|||
class Hodnoceni(bm.SeminarModelBase): |
|||
class Meta: |
|||
db_table = 'seminar_hodnoceni' |
|||
verbose_name = 'Hodnocení' |
|||
verbose_name_plural = 'Hodnocení' |
|||
|
|||
# Interní ID |
|||
id = models.AutoField(primary_key = True) |
|||
|
|||
|
|||
body = models.DecimalField(max_digits=8, decimal_places=1, verbose_name='body', |
|||
blank=True, null=True) |
|||
|
|||
cislo_body = models.ForeignKey(am.Cislo, verbose_name='číslo pro body', |
|||
related_name='hodnoceni', blank=True, null=True, on_delete=models.PROTECT) |
|||
|
|||
# V ročníku < 26 nastaveno na deadline vygenerovaný pro původní cislo_body |
|||
deadline_body = models.ForeignKey(am.Deadline, verbose_name='deadline pro body', |
|||
related_name='hodnoceni', blank=True, null=True, on_delete=models.PROTECT) |
|||
|
|||
reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE) |
|||
|
|||
problem = models.ForeignKey(am.Problem, verbose_name='problém', |
|||
related_name='hodnoceni', on_delete=models.PROTECT) |
|||
|
|||
feedback = models.TextField('zpětná vazba', blank=True, default='', help_text='Zpětná vazba řešiteli (plain text)') |
|||
|
|||
@property |
|||
def body_celkem(self): |
|||
# FIXME řeším jen prvního řešitele. |
|||
return Hodnoceni.objects.filter(problem=self.problem, reseni__resitele=self.reseni.resitele.first(), body__isnull=False).aggregate(Sum("body"))["body__sum"] |
|||
|
|||
@body_celkem.setter |
|||
def body_celkem(self, value): |
|||
if value is None: |
|||
self.body = None |
|||
else: |
|||
if self.body is None: |
|||
self.body = 0 |
|||
if self.body_celkem is None: |
|||
self.body += value |
|||
else: |
|||
self.body += value - self.body_celkem |
|||
|
|||
@property |
|||
def body_neprepocitane(self): |
|||
if self.body is None: |
|||
return None |
|||
return inverze_vzorecku_na_prepocet(self.body, self.reseni.resitele.count()) |
|||
|
|||
@body_neprepocitane.setter |
|||
def body_neprepocitane(self, value): |
|||
if value is None: |
|||
self.body = None |
|||
else: |
|||
self.body = vzorecek_na_prepocet(value, self.reseni.resitele.count()) |
|||
|
|||
@property |
|||
def body_neprepocitane_celkem(self): |
|||
if self.body_celkem is None: |
|||
return None |
|||
return inverze_vzorecku_na_prepocet(self.body_celkem, self.reseni.resitele.count()) |
|||
|
|||
@body_neprepocitane_celkem.setter |
|||
def body_neprepocitane_celkem(self, value): |
|||
if value is None: |
|||
self.body = None |
|||
else: |
|||
self.body_celkem = vzorecek_na_prepocet(value, self.reseni.resitele.count()) |
|||
|
|||
@property |
|||
def body_max(self): |
|||
if self.body_neprepocitane_max is None: |
|||
return None |
|||
return vzorecek_na_prepocet(self.body_neprepocitane_max, self.reseni.resitele.count()) |
|||
|
|||
@property |
|||
def body_neprepocitane_max(self): |
|||
if not isinstance(self.problem.get_real_instance(), am.Uloha): |
|||
return None |
|||
return self.problem.uloha.max_body |
|||
|
|||
def __str__(self): |
|||
return "{}, {}, {}".format(self.problem, self.reseni, self.body) |
|||
|
|||
def generate_filename(self, filename): |
|||
return os.path.join( |
|||
settings.SEMINAR_RESENI_DIR, |
|||
am.aux_generate_filename(self, filename) |
|||
) |
|||
|
|||
|
|||
@reversion.register(ignore_duplicates=True) |
|||
class PrilohaReseni(bm.SeminarModelBase): |
|||
|
|||
class Meta: |
|||
db_table = 'seminar_priloha_reseni' |
|||
verbose_name = 'Příloha řešení' |
|||
verbose_name_plural = 'Přílohy řešení' |
|||
ordering = ['reseni', 'vytvoreno'] |
|||
|
|||
# Interní ID |
|||
id = models.AutoField(primary_key = True) |
|||
|
|||
reseni = models.ForeignKey(Reseni, verbose_name='řešení', related_name='prilohy', |
|||
on_delete=models.CASCADE) |
|||
|
|||
vytvoreno = models.DateTimeField('vytvořeno', default=timezone.now, blank=True, editable=False) |
|||
|
|||
soubor = models.FileField('soubor', upload_to = generate_filename) |
|||
|
|||
poznamka = models.TextField('neveřejná poznámka', blank=True, |
|||
help_text='Neveřejná poznámka k příloze řešení (plain text), např. o původu') |
|||
|
|||
res_poznamka = models.TextField('poznámka řešitele', blank=True, |
|||
help_text='Poznámka k příloze řešení, např. co daný soubor obsahuje') |
|||
|
|||
def __str__(self): |
|||
return str(self.soubor) |
|||
|
|||
def split(self): |
|||
"Vrátí cestu rozsekanou po složkách. To se hodí v templatech" |
|||
# Věřím, že tohle funguje, případně použít os.path nebo pathlib. |
|||
return self.soubor.url.split('/') |
|||
|
|||
|
|||
# Vazebna tabulka. Mozna se generuje automaticky. |
|||
@reversion.register(ignore_duplicates=True) |
|||
class Reseni_Resitele(models.Model): |
|||
|
|||
class Meta: |
|||
db_table = 'seminar_reseni_resitele' |
|||
verbose_name = 'Řešení řešitelů' |
|||
verbose_name_plural = 'Řešení řešitelů' |
|||
ordering = ['reseni', 'resitele'] |
|||
|
|||
# Interní ID |
|||
id = models.AutoField(primary_key = True) |
|||
|
|||
resitele = models.ForeignKey(Resitel, verbose_name='řešitel', on_delete=models.PROTECT) |
|||
|
|||
reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE) |
|||
|
|||
# podil - jakou merou se ktery resitel podilel na danem reseni |
|||
# - pouziti v budoucnu, pokud by resitele nemeli dostat vsichni stejne bodu za spolecne reseni |
|||
|
|||
def __str__(self): |
|||
return '{} od {}'.format(self.reseni, self.resitel) |
|||
# NOTE: Poteciální DB HOG bez select_related |
@ -0,0 +1,13 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-22 22:17 |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('personalni', '0011_osloveni_vsechny_choices'), |
|||
] |
|||
|
|||
operations = [ |
|||
] |
@ -0,0 +1,14 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-23 21:10 |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('personalni', '0012_odstrel_odevzdavatka_pre'), |
|||
('odevzdavatko', '0003_odstrel_odevzdavatka_post'), |
|||
] |
|||
|
|||
operations = [ |
|||
] |
@ -0,0 +1,14 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-22 22:17 |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('seminar', '0130_clanek_strana'), |
|||
('personalni', '0012_odstrel_odevzdavatka_pre'), |
|||
] |
|||
|
|||
operations = [ |
|||
] |
@ -0,0 +1,29 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-22 22:31 |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('seminar', '0131_odstrel_odevzdavatka_pre'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterModelOptions( |
|||
name='hodnoceni', |
|||
options={'managed': False, 'verbose_name': 'Hodnocení', 'verbose_name_plural': 'Hodnocení'}, |
|||
), |
|||
migrations.AlterModelOptions( |
|||
name='prilohareseni', |
|||
options={'managed': False, 'ordering': ['reseni', 'vytvoreno'], 'verbose_name': 'Příloha řešení', 'verbose_name_plural': 'Přílohy řešení'}, |
|||
), |
|||
migrations.AlterModelOptions( |
|||
name='reseni', |
|||
options={'managed': False, 'ordering': ['-cas_doruceni'], 'verbose_name': 'Řešení', 'verbose_name_plural': 'Řešení'}, |
|||
), |
|||
migrations.AlterModelOptions( |
|||
name='reseni_resitele', |
|||
options={'managed': False, 'ordering': ['reseni', 'resitele'], 'verbose_name': 'Řešení řešitelů', 'verbose_name_plural': 'Řešení řešitelů'}, |
|||
), |
|||
] |
@ -0,0 +1,20 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-23 19:53 |
|||
|
|||
from django.db import migrations, models |
|||
import django.db.models.deletion |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('odevzdavatko', '0001_create'), |
|||
('seminar', '0132_unmanage_odevzdavatko'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterField( |
|||
model_name='reseninode', |
|||
name='reseni', |
|||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='odevzdavatko.reseni', verbose_name='reseni'), |
|||
), |
|||
] |
@ -0,0 +1,50 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-23 19:56 |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('seminar', '0133_relink_odevzdavatko'), |
|||
('odevzdavatko', '0001_create'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.RemoveField( |
|||
model_name='prilohareseni', |
|||
name='reseni', |
|||
), |
|||
migrations.RemoveField( |
|||
model_name='reseni', |
|||
name='problem', |
|||
), |
|||
migrations.RemoveField( |
|||
model_name='reseni', |
|||
name='resitele', |
|||
), |
|||
migrations.RemoveField( |
|||
model_name='reseni', |
|||
name='text_cely', |
|||
), |
|||
migrations.RemoveField( |
|||
model_name='reseni_resitele', |
|||
name='reseni', |
|||
), |
|||
migrations.RemoveField( |
|||
model_name='reseni_resitele', |
|||
name='resitele', |
|||
), |
|||
migrations.DeleteModel( |
|||
name='Hodnoceni', |
|||
), |
|||
migrations.DeleteModel( |
|||
name='PrilohaReseni', |
|||
), |
|||
migrations.DeleteModel( |
|||
name='Reseni', |
|||
), |
|||
migrations.DeleteModel( |
|||
name='Reseni_Resitele', |
|||
), |
|||
] |
@ -0,0 +1,14 @@ |
|||
# Generated by Django 4.2.13 on 2024-10-23 21:10 |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('seminar', '0134_delete_odevzdavatko'), |
|||
('odevzdavatko', '0003_odstrel_odevzdavatka_post'), |
|||
] |
|||
|
|||
operations = [ |
|||
] |
Loading…
Reference in new issue