TabbycatDebate/tabbycat

View on GitHub
tabbycat/importer/forms.py

Summary

Maintainability
A
35 mins
Test Coverage
F
34%
import csv
import logging
from itertools import zip_longest

from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.utils.translation import gettext as _, ngettext, ngettext_lazy

from checkins.models import PersonIdentifier, VenueIdentifier
from participants.models import Adjudicator, Institution, Speaker, Team
from privateurls.utils import populate_url_keys
from venues.models import Venue

logger = logging.getLogger(__name__)
TEAM_SHORT_REFERENCE_LENGTH = Team._meta.get_field('short_reference').max_length

# There are 7 fields for formset/wizard management and CSRF detection
MAX_FORM_DATA_FIELDS = settings.DATA_UPLOAD_MAX_NUMBER_FIELDS - 7


class ImportValidationError(ValidationError):

    def __init__(self, lineno, message, *args, **kwargs):
        message = _("line %(lineno)d: %(message)s") % {
            'lineno': lineno,
            'message': message,
        }
        super().__init__(message, *args, **kwargs)


# ==============================================================================
# Raw forms (CSV-style import)
# ==============================================================================

class ImportInstitutionsRawForm(forms.Form):
    """Form that takes in a CSV-style list of institutions, splits it and stores
    the split data."""

    institutions_raw = forms.CharField(widget=forms.Textarea(attrs={'rows': 20}))

    def clean_institutions_raw(self):
        lines = self.cleaned_data['institutions_raw'].split('\n')
        errors = []
        institutions = []

        for i, line in enumerate(csv.reader(lines), start=1):
            if len(line) < 1:
                continue # skip blank lines
            if len(line) < 2:
                errors.append(ImportValidationError(i,
                    _("This line (for %(institution)s) didn't have a code") %
                    {'institution': line[0]}))
                continue
            if len(line) > 2:
                errors.append(ImportValidationError(i,
                    _("This line (for %(institution)s) had too many columns") %
                    {'institution': line[0]}))

            line = [x.strip() for x in line]
            institutions.append({'name': line[0], 'code': line[1]})

        if errors:
            raise ValidationError(errors)

        if len(institutions) == 0:
            raise ValidationError(_("There were no institutions to import."))

        max_allowed = MAX_FORM_DATA_FIELDS // 3  # 3 fields: 'name', 'code', 'id'.
        if len(institutions) > max_allowed:
            raise ValidationError(ngettext(
                "Sorry, you can only import up to %(max_allowed)d institution at a "
                "time. (You currently have %(given)d.) "
                "Try splitting your import into smaller chunks.",
                "Sorry, you can only import up to %(max_allowed)d institutions at a "
                "time. (You currently have %(given)d.) "
                "Try splitting your import into smaller chunks.",
                max_allowed) % {'max_allowed': max_allowed, 'given': len(institutions)})

        return institutions


class ImportVenuesRawForm(forms.Form):
    """Form that takes in a CSV-style list of venues, splits it and stores the
    split data."""

    venues_raw = forms.CharField(widget=forms.Textarea(attrs={'rows': 20}))

    def clean_venues_raw(self):
        lines = self.cleaned_data['venues_raw'].split('\n')
        venues = []

        for i, line in enumerate(csv.reader(lines), start=1):
            if len(line) < 1:
                continue # skip blank lines
            params = {}
            params['name'] = line[0]
            params['priority'] = line[1] if len(line) > 1 else '100'

            params = {k: v.strip() for k, v in params.items()}
            venues.append(params)

        if len(venues) == 0:
            raise ValidationError(_("There were no rooms to import."))

        max_allowed = MAX_FORM_DATA_FIELDS // (len(VenueDetailsForm.base_fields) + 1)
        if len(venues) > max_allowed:
            raise ValidationError(ngettext(
                "Sorry, you can only import up to %(max_allowed)d room at a "
                "time. (You currently have %(given)d.) "
                "Try splitting your import into smaller chunks.",
                "Sorry, you can only import up to %(max_allowed)d rooms at a "
                "time. (You currently have %(given)d.) "
                "Try splitting your import into smaller chunks.",
                max_allowed) % {'max_allowed': max_allowed, 'given': len(venues)})

        return venues


# ==============================================================================
# Details forms
# ==============================================================================

class BaseTournamentObjectDetailsForm(forms.ModelForm):
    """Form for the formset used in the second step of the simple importer. As
    well as the usual functions for managing an instance, this model also
    manages the tournament separately.

    This doesn't do everything. Subclasses must override save() to populate the
    tournament field, if applicable.
    """

    def __init__(self, tournament, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.tournament = tournament

    def _get_validation_exclusions(self):
        exclude = super()._get_validation_exclusions()
        if 'tournament' in exclude:
            exclude.remove('tournament')
        return exclude

    def full_clean(self):
        self.instance.tournament = self.tournament
        return super().full_clean()


class VenueDetailsForm(BaseTournamentObjectDetailsForm):

    class Meta:
        model = Venue
        fields = ('name', 'priority')

    def save(self, commit=True):
        venue = super().save(commit=commit)
        if commit:
            VenueIdentifier.objects.create(venue=venue)
        return venue


class BaseInstitutionObjectDetailsForm(BaseTournamentObjectDetailsForm):
    """Adds a hidden input for the institution and automatic detection of the
    institution from initial or data.

    Subclasses must ensure that `'institution'` is in the `fields` attribute
    of the Meta class.
    """

    # This field protects against changes to the form between rendering and
    # submission, for example, if the user reloads the team details step in a
    # different tab with different numbers of teams/adjudicators. Putting the
    # institution ID in a hidden field makes the client send it with the form,
    # keeping the information in a submission consistent.
    institution = forms.ModelChoiceField(queryset=Institution.objects.all(),
                                         widget=forms.HiddenInput, required=False)

    def __init__(self, tournament, *args, **kwargs):
        super().__init__(tournament, *args, **kwargs)

        # Grab an `institution_for_display` to help render the form. This is
        # not used anywhere in the form logic. First try `initial` (for when
        # the form is initially rendered), then try `data` (for when the form
        # is rerendered after a validation error).
        institution_id = self.initial.get('institution') or self.data.get(self.add_prefix('institution'))

        if institution_id:
            try:
                self.institution_for_display = Institution.objects.get(id=institution_id)
            except Institution.DoesNotExist:
                logger.exception("Could not find institution from initial or data")
        else:
            self.institution_for_display = None


class TeamDetailsForm(BaseInstitutionObjectDetailsForm):
    """Adds provision for a textarea input for speakers."""

    # widgets are set in form constructor
    speakers = forms.CharField(required=True, label=_("Speakers' names"), help_text=_("Can be separated by newlines, tabs, commas or ampersands"))
    emails = forms.CharField(required=False, label=_("Speakers' email addresses"),
        help_text=_("Optional, useful to include if distributing private URLs, list in same order as speakers' names"))
    short_reference = forms.CharField(widget=forms.HiddenInput, required=False) # doesn't actually do anything, just placeholder to avoid validation failure

    field_order = ['reference', 'use_institution_prefix', 'speakers', 'emails', 'seed']

    class Meta:
        model = Team
        fields = ('reference', 'short_reference', 'use_institution_prefix', 'institution', 'seed')
        labels = {
            'reference': _("Name (excluding institution name)"),
            'use_institution_prefix': _("Prefix team name with institution name?"),
        }
        help_texts = {
            'reference': _("Do not include institution name (check the \"Prefix team name with institution name?\" field instead)"),
        }

    def __init__(self, tournament, *args, **kwargs):
        super().__init__(tournament, *args, **kwargs)

        if self.institution_for_display is None:
            self.initial['use_institution_prefix'] = False
            self.fields['use_institution_prefix'].disabled = True
            self.fields['use_institution_prefix'].help_text = _("(Not applicable to unaffiliated teams)")

        # Set speaker and email widgets to match tournament settings
        nspeakers = tournament.pref('substantive_speakers')
        self.fields['speakers'].widget = forms.Textarea(attrs={'rows': nspeakers,
                'placeholder': _("One speaker's name per line")})
        self.initial.setdefault('speakers', "\n".join(
                _("Speaker %d") % i for i in range(1, nspeakers+1)))
        self.fields['emails'].widget = forms.Textarea(attrs={'rows': nspeakers,
                'placeholder': "\n".join(_("speaker%d@example.edu") % i for i in range(1, nspeakers+1))})

        seed_label = self.fields['seed'].label
        seed_help = self.fields['seed'].help_text
        if tournament.pref('show_seed_in_importer') == 'numeric':
            self.fields['seed'] = forms.IntegerField(required=False, label=seed_label, help_text=seed_help, min_value=0)
        elif tournament.pref('show_seed_in_importer') == 'title':
            self.fields['seed'] = forms.ChoiceField(required=False, label=seed_label, choices=(
                (0, _("Unseeded")),
                (1, _("Free seed")),
                (2, _("Half seed")),
                (3, _("Full seed")),
            ), help_text=seed_help)
        else:
            self.fields.pop('seed')

    @staticmethod
    def _split_lines(data):
        """Split into list of names or emails; removing blank lines."""
        items = data.replace('\t', '\n').replace(',', '\n').replace('&', '\n')
        items = items.split('\n')
        items = [item.strip() for item in items]
        items = [item for item in items if item]
        return items

    def clean_speakers(self):
        names = self._split_lines(self.cleaned_data['speakers'])
        if len(names) == 0:
            self.add_error('speakers', _("There must be at least one speaker."))
        return names

    def clean_emails(self):
        emails = self._split_lines(self.cleaned_data['emails'])
        for email in emails:
            try:
                validate_email(email)
            except ValidationError:
                self.add_error('emails', _("%(email)s is not a valid email address.") % {'email': email})
        return emails

    def clean_short_reference(self):
        # Ignore the actual field value, and replace with the (long) reference.
        # The purpose of this is to ensure that this field is populated, because
        # Team.clean() checks it, so it can't just be excluded using `exclude=`.
        reference = self.cleaned_data.get('reference', '')
        return reference[:TEAM_SHORT_REFERENCE_LENGTH]

    def clean(self):
        super().clean()
        if len(self.cleaned_data.get('emails', [])) > len(self.cleaned_data.get('speakers', [])):
            self.add_error('emails', _("There are more email addresses than speakers."))

    def _post_clean_speakers(self):
        """Validates the Speaker instances that would be created."""
        for i, name in enumerate(self.cleaned_data.get('speakers', [])):
            try:
                speaker = Speaker(name=name)
                speaker.full_clean(exclude=('team',))
            except ValidationError as errors:
                for field, e in errors: # replace field with `speakers`
                    self.add_error('speakers', e)

    def _post_clean(self):
        super()._post_clean()
        self._post_clean_speakers()

    def save(self, commit=True):
        # First save the team, then create the speakers
        team = super().save(commit=False)
        team.tournament = self.tournament

        if commit:
            team.save()
            for name, email in zip_longest(self.cleaned_data['speakers'], self.cleaned_data['emails']):
                speaker = team.speaker_set.create(name=name, email=email)

                PersonIdentifier.objects.create(person=speaker)
                populate_url_keys([speaker])

            team.break_categories.set(team.tournament.breakcategory_set.filter(is_general=True))

            if team.institution:
                team.teaminstitutionconflict_set.create(institution=team.institution)

        return team


class TeamDetailsFormSet(forms.BaseModelFormSet):

    def get_unique_error_message(self, unique_check):
        # Overrides the base implementation
        if unique_check == ('reference', 'institution', 'tournament'):
            return _("Every team in a single tournament from the same institution must "
                "have a different name. Please correct the duplicate data.")
        else:
            return super().get_unique_error_message(unique_check)


class AdjudicatorDetailsForm(BaseInstitutionObjectDetailsForm):

    class Meta:
        model = Adjudicator
        fields = ('name', 'base_score', 'institution', 'email')
        labels = {
            'base_score': _("Rating"),
        }

    def clean_base_score(self):
        base_score = self.cleaned_data['base_score']
        min_score = self.tournament.pref('adj_min_score')
        max_score = self.tournament.pref('adj_max_score')
        if base_score < min_score or max_score < base_score:
            self.add_error('base_score', _("This value must be between %(min)d and %(max)d.") %
                {'min': min_score, 'max': max_score})
        return base_score

    def save(self, commit=True):
        adj = super().save(commit=commit)

        if commit:
            if adj.institution:
                adj.adjudicatorinstitutionconflict_set.create(institution=adj.institution)
            PersonIdentifier.objects.create(person=adj)
            populate_url_keys([adj])

        return adj


# ==============================================================================
# Numbers forms
# ==============================================================================
# These reference the details forms, so must come after them.

class BaseNumberForEachInstitutionForm(forms.Form):
    """Form that presents one numeric field for each institution, for the user
    to indicate how many objects to create from that institution. This is used
    for importing teams and adjudicators."""

    number_unaffiliated = forms.IntegerField(min_value=0, required=False,
        label=_("Unaffiliated (no institution)"),
        widget=forms.NumberInput(attrs={'placeholder': 0}))

    def __init__(self, institutions, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.institutions = institutions
        self._create_fields()

    def _create_fields(self):
        """Dynamically generate one integer field for each institution, for the
        user to indicate how many teams are from that institution."""
        for institution in self.institutions:
            label = _("%(name)s (%(code)s)") % {'name': institution.name, 'code': institution.code}
            self.fields['number_institution_%d' % institution.id] = forms.IntegerField(
                    min_value=0, label=label, required=False,
                    widget=forms.NumberInput(attrs={'placeholder': 0}))

    def clean(self):
        super().clean()

        given = self.cleaned_data.get('number_unaffiliated') or 0  # data might be None
        for institution in self.institutions:
            given += self.cleaned_data.get('number_institution_%d' % institution.id) or 0  # data might be None
        max_allowed = MAX_FORM_DATA_FIELDS // self.num_detail_fields
        if given > max_allowed:
            raise ValidationError(self.too_many_error_message % {
                'max_allowed': max_allowed, 'given': given})


class ImportTeamsNumbersForm(BaseNumberForEachInstitutionForm):

    num_detail_fields = len(TeamDetailsForm.base_fields) + 1
    too_many_error_message = ngettext_lazy(
        "Sorry, you can only import up to %(max_allowed)d team at a time. "
        "(These numbers currently add to %(given)d.) "
        "Try splitting your import into smaller chunks.",
        "Sorry, you can only import up to %(max_allowed)d teams at a time. "
        "(These numbers currently add to %(given)d.) "
        "Try splitting your import into smaller chunks.",
        'max_allowed')


class ImportAdjudicatorsNumbersForm(BaseNumberForEachInstitutionForm):

    num_detail_fields = len(AdjudicatorDetailsForm.base_fields) + 1
    too_many_error_message = ngettext_lazy(
        "Sorry, you can only import up to %(max_allowed)d adjudicator at a time. "
        "(These numbers currently add to %(given)d.) "
        "Try splitting your import into smaller chunks.",
        "Sorry, you can only import up to %(max_allowed)d adjudicators at a time. "
        "(These numbers currently add to %(given)d.) "
        "Try splitting your import into smaller chunks.",
        'max_allowed')


class ArchiveImportForm(forms.Form):

    xml = forms.CharField(required=True, label=_("XML"),
        widget=forms.Textarea(), help_text=_("The Debate XML archive to parse")) # attrs={'rows': 20}