2024-12-06 23:24:31 +01:00

798 lines
26 KiB
Python

from django.forms import (
ModelForm,
ValidationError,
TextInput,
Form,
URLField,
MultipleHiddenInput,
Textarea,
CharField,
ChoiceField,
RadioSelect,
MultipleChoiceField,
BooleanField,
HiddenInput,
ModelChoiceField,
)
from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget
from .utils import PlaceGuesser
from .models import (
Event,
RecurrentImport,
CategorisationRule,
Place,
Category,
Tag,
ContactMessage
)
from django.conf import settings
from django.core.files import File
from django.utils.translation import gettext_lazy as _
from string import ascii_uppercase as auc
from .templatetags.utils_extra import int_to_abc
from django.utils.safestring import mark_safe
from django.utils.timezone import localtime
from django.utils.formats import localize
from .templatetags.event_extra import event_field_verbose_name, field_to_html
import os
import logging
logger = logging.getLogger(__name__)
class GroupFormMixin:
template_name = 'agenda_culturel/forms/div_group.html'
class FieldGroup:
def __init__(self, id, label, display_label=False, maskable=False, default_masked=True):
self.id = id
self.label = label
self.display_label = display_label
self.maskable = maskable
self.default_masked = default_masked
def toggle_field_name(self):
return 'group_' + self.id
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.groups = []
def add_group(self, *args, **kwargs):
self.groups.append(GroupFormMixin.FieldGroup(*args, **kwargs))
if self.groups[-1].maskable:
self.fields[self.groups[-1].toggle_field_name()] = BooleanField(required=False)
self.fields[self.groups[-1].toggle_field_name()].toggle_group = True
def get_fields_in_group(self, g):
return [f for f in self.visible_fields() if not hasattr(f.field, "toggle_group") and hasattr(f.field, "group_id") and f.field.group_id == g.id]
def get_no_group_fields(self):
return [f for f in self.visible_fields() if not hasattr(f.field, "toggle_group") and (not hasattr(f.field, "group_id") or f.field.group_id == None)]
def fields_by_group(self):
return [(g, self.get_fields_in_group(g)) for g in self.groups] + [(GroupFormMixin.FieldGroup("other", _("Other")), self.get_no_group_fields())]
def clean(self):
result = super().clean()
if result:
data = dict(self.data)
# for each masked group, we remove data
for g in self.groups:
if g.maskable and not g.toggle_field_name() in data:
fields = self.get_fields_in_group(g)
for f in fields:
self.cleaned_data[f.name] = None
return result
class TagForm(ModelForm):
required_css_class = 'required'
class Meta:
model = Tag
fields = ["name", "description", "in_included_suggestions", "in_excluded_suggestions", "principal"]
widgets = {
"name": HiddenInput()
}
class TagRenameForm(Form):
required_css_class = 'required'
name = CharField(
label=_('Name of new tag'),
required=True
)
force = BooleanField(
label=_('Force renaming despite the existence of events already using the chosen tag.'),
)
def __init__(self, *args, **kwargs):
force = kwargs.pop("force", False)
name = kwargs.pop("name", None)
super().__init__(*args, **kwargs)
if not (force or (not len(args) == 0 and 'force' in args[0])):
del self.fields["force"]
if not name is None and self.fields["name"].initial is None:
self.fields["name"].initial = name
def is_force(self):
return "force" in self.fields and self.cleaned_data["force"] == True
class URLSubmissionForm(Form):
required_css_class = 'required'
url = URLField(max_length=512)
category = ModelChoiceField(
label=_("Category"),
queryset=Category.objects.all().order_by("name"),
initial=None,
required=False,
)
tags = MultipleChoiceField(
label=_("Tags"),
initial=None,
choices=[],
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["tags"].choices = Tag.get_tag_groups(all=True)
class DynamicArrayWidgetURLs(DynamicArrayWidget):
template_name = "agenda_culturel/widgets/widget-urls.html"
class DynamicArrayWidgetTags(DynamicArrayWidget):
template_name = "agenda_culturel/widgets/widget-tags.html"
class RecurrentImportForm(ModelForm):
required_css_class = 'required'
defaultTags = MultipleChoiceField(
label=_("Tags"),
initial=None,
choices=[],
required=False
)
class Meta:
model = RecurrentImport
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["defaultTags"].choices = Tag.get_tag_groups(all=True)
class CategorisationRuleImportForm(ModelForm):
required_css_class = 'required'
class Meta:
model = CategorisationRule
fields = "__all__"
class EventForm(GroupFormMixin, ModelForm):
required_css_class = 'required'
old_local_image = CharField(widget=HiddenInput(), required=False)
simple_cloning = CharField(widget=HiddenInput(), required=False)
tags = MultipleChoiceField(
label=_("Tags"),
initial=None,
choices=[],
required=False
)
class Meta:
model = Event
exclude = [
"imported_date",
"modified_date",
"moderated_date",
"import_sources",
"image",
"moderated_by_user",
"modified_by_user",
"created_by_user",
"imported_by_user"
]
widgets = {
"start_day": TextInput(
attrs={
"type": "date",
"onchange": "update_datetimes(event);",
"onfocus": "this.oldvalue = this.value;",
}
),
"start_time": TextInput(
attrs={
"type": "time",
"onchange": "update_datetimes(event);",
"onfocus": "this.oldvalue = this.value;",
}
),
"end_day": TextInput(attrs={"type": "date"}),
"end_time": TextInput(attrs={"type": "time"}),
"other_versions": HiddenInput(),
"uuids": MultipleHiddenInput(),
"reference_urls": DynamicArrayWidgetURLs(),
}
def __init__(self, *args, **kwargs):
is_authenticated = kwargs.pop("is_authenticated", False)
self.cloning = kwargs.pop("is_cloning", False)
self.simple_cloning = kwargs.pop("is_simple_cloning", False)
super().__init__(*args, **kwargs)
if not is_authenticated:
del self.fields["status"]
del self.fields["organisers"]
self.fields['category'].queryset = self.fields['category'].queryset.order_by('name')
self.fields['category'].empty_label = None
self.fields['category'].initial = Category.get_default_category()
self.fields['tags'].choices = Tag.get_tag_groups(all=True)
# set groups
self.add_group('main', _('Main fields'))
self.fields['title'].group_id = 'main'
self.add_group('start', _('Start of event'))
self.fields['start_day'].group_id = 'start'
self.fields['start_time'].group_id = 'start'
self.add_group('end', _('End of event'))
self.fields['end_day'].group_id = 'end'
self.fields['end_time'].group_id = 'end'
self.add_group('recurrences',
_('This is a recurring event'),
maskable=True,
default_masked=not (self.instance and
self.instance.recurrences and
self.instance.recurrences.rrules and
len(self.instance.recurrences.rrules) > 0))
self.fields['recurrences'].group_id = 'recurrences'
self.add_group('details', _('Details'))
self.fields['description'].group_id = 'details'
if is_authenticated:
self.fields['organisers'].group_id = 'details'
self.add_group('location', _('Location'))
self.fields['location'].group_id = 'location'
self.fields['exact_location'].group_id = 'location'
self.add_group('illustration', _('Illustration'))
self.fields['local_image'].group_id = 'illustration'
self.fields['image_alt'].group_id = 'illustration'
if is_authenticated:
self.add_group('meta-admin', _('Meta information'))
self.fields['category'].group_id = 'meta-admin'
self.fields['tags'].group_id = 'meta-admin'
self.fields['status'].group_id = 'meta-admin'
else:
self.add_group('meta', _('Meta information'))
self.fields['category'].group_id = 'meta'
self.fields['tags'].group_id = 'meta'
def is_clone_from_url(self):
return self.cloning
def is_simple_clone_from_url(self):
return self.simple_cloning
def clean_end_day(self):
start_day = self.cleaned_data.get("start_day")
end_day = self.cleaned_data.get("end_day")
if end_day is not None and start_day is not None and end_day < start_day:
raise ValidationError(_("The end date must be after the start date."))
return end_day
def clean_end_time(self):
start_day = self.cleaned_data.get("start_day")
end_day = self.cleaned_data.get("end_day")
start_time = self.cleaned_data.get("start_time")
end_time = self.cleaned_data.get("end_time")
# same day
if start_day is not None and (end_day is None or start_day == end_day):
# both start and end time are defined
if start_time is not None and end_time is not None:
if start_time > end_time:
raise ValidationError(
_("The end time cannot be earlier than the start time.")
)
return end_time
def clean(self):
super().clean()
# when cloning an existing event, we need to copy the local image
if self.cleaned_data['local_image'] is None and \
not self.cleaned_data['old_local_image'] is None and \
self.cleaned_data['old_local_image'] != "":
basename = self.cleaned_data['old_local_image']
old = settings.MEDIA_ROOT + "/" + basename
if os.path.isfile(old):
self.cleaned_data['local_image'] = File(name=basename, file=open(old, "rb"))
class MultipleChoiceFieldAcceptAll(MultipleChoiceField):
def validate(self, value):
pass
class EventModerateForm(ModelForm):
required_css_class = 'required'
tags = MultipleChoiceField(
label=_("Tags"),
help_text=_('Select tags from existing ones.'),
required=False
)
new_tags = MultipleChoiceFieldAcceptAll(
label=_("New tags"),
help_text=_('Create new labels (sparingly). Note: by starting your tag with the characters “TW:”, you''ll create a “trigger warning” tag, and the associated events will be announced as such.'),
widget=DynamicArrayWidget(),
required=False
)
class Meta:
model = Event
fields = [
"status",
"category",
"organisers",
"exact_location",
"tags"
]
widgets = {
"status": RadioSelect
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.fields['category'].queryset.order_by('name')
self.fields['category'].empty_label = None
self.fields['category'].initial = Category.get_default_category()
self.fields['tags'].choices = Tag.get_tag_groups(all=True)
def clean_new_tags(self):
return list(set(self.cleaned_data.get("new_tags")))
def clean(self):
super().clean()
if self.cleaned_data['tags'] is None:
self.cleaned_data['tags'] = []
if not self.cleaned_data.get('new_tags') is None:
self.cleaned_data['tags'] += self.cleaned_data.get('new_tags')
self.cleaned_data['tags'] = list(set(self.cleaned_data['tags']))
class BatchImportationForm(Form):
required_css_class = 'required'
json = CharField(
label="JSON",
widget=Textarea(attrs={"rows": "10"}),
help_text=_("JSON in the format expected for the import."),
required=True,
)
class FixDuplicates(Form):
required_css_class = 'required'
action = ChoiceField()
def __init__(self, *args, **kwargs):
edup = kwargs.pop("edup", None)
events = edup.get_duplicated()
nb_events = len(events)
super().__init__(*args, **kwargs)
choices = []
initial = None
for i, e in enumerate(events):
if e.status != Event.STATUS.TRASH or e.modified():
msg = ""
if e.local_version():
msg = _(" (locally modified version)")
if e.status != Event.STATUS.TRASH:
initial = "Select-" + str(e.pk)
if e.pure_import():
msg = _(" (synchronized on import version)")
choices += [
(
"Select-" + str(e.pk),
_("Select {} as representative version.").format(auc[i] + msg)
)
]
for i, e in enumerate(events):
if e.status != Event.STATUS.TRASH and e.local_version():
choices += [
(
"Update-" + str(e.pk),
_("Update {} using some fields from other versions (interactive mode).").format(auc[i])
)
]
extra = ""
if edup.has_local_version():
extra = _(" Warning: a version is already locally modified.")
if initial is None:
initial = "Merge"
choices += [
("Merge", _("Create a new version by merging (interactive mode).") + extra)
]
for i, e in enumerate(events):
if e.status != Event.STATUS.TRASH:
choices += [
(
"Remove-" + str(e.pk),
_("Make {} independent.").format(auc[i]))
]
choices += [("NotDuplicates", _("Make all versions independent."))]
self.fields["action"].choices = choices
self.fields["action"].initial = initial
def is_action_no_duplicates(self):
return self.cleaned_data["action"] == "NotDuplicates"
def is_action_select(self):
return self.cleaned_data["action"].startswith("Select")
def is_action_update(self):
return self.cleaned_data["action"].startswith("Update")
def is_action_remove(self):
return self.cleaned_data["action"].startswith("Remove")
def get_selected_event_code(self):
if self.is_action_select() or self.is_action_remove() or self.is_action_update():
return int(self.cleaned_data["action"].split("-")[-1])
else:
return None
def get_selected_event(self, edup):
selected = self.get_selected_event_code()
for e in edup.get_duplicated():
if e.pk == selected:
return e
return None
class SelectEventInList(Form):
required_css_class = 'required'
event = ChoiceField(label=_('Event'))
def __init__(self, *args, **kwargs):
events = kwargs.pop("events", None)
super().__init__(*args, **kwargs)
self.fields["event"].choices = [
(e.pk, str(e.start_day) + " " + e.title + ((", " + e.location) if e.location else "")) for e in events
]
class MergeDuplicates(Form):
required_css_class = 'required'
checkboxes_fields = ["reference_urls", "description", "tags"]
def __init__(self, *args, **kwargs):
self.duplicates = kwargs.pop("duplicates", None)
self.event = kwargs.pop("event", None)
self.events = list(self.duplicates.get_duplicated())
nb_events = len(self.events)
super().__init__(*args, **kwargs)
if self.event:
choices = [("event_" + str(self.event.pk), _("Value of the selected version"))] + \
[
("event_" + str(e.pk), _("Value of version {}").format(e.pk)) for e in self.events if e != self.event
]
else:
choices = [
("event_" + str(e.pk), _("Value of version {}").format(e.pk)) for e in self.events
]
for f in self.duplicates.get_items_comparison():
if not f["similar"]:
if f["key"] in MergeDuplicates.checkboxes_fields:
self.fields[f["key"]] = MultipleChoiceField(choices=choices)
self.fields[f["key"]].initial = choices[0][0]
else:
self.fields[f["key"]] = ChoiceField(
widget=RadioSelect, choices=choices
)
self.fields[f["key"]].initial = choices[0][0]
def as_grid(self):
result = '<div class="grid">'
for i, e in enumerate(self.events):
result += '<div class="grid entete-badge">'
result += '<div class="badge-large">' + int_to_abc(i) + "</div>"
result += "<ul>"
result += (
'<li><a href="' + e.get_absolute_url() + '">' + e.title + "</a></li>"
)
result += (
"<li>Création&nbsp;: " + localize(localtime(e.created_date)) + "</li>"
)
result += (
"<li>Dernière modification&nbsp;: "
+ localize(localtime(e.modified_date))
+ "</li>"
)
if e.imported_date:
result += (
"<li>Dernière importation&nbsp;: "
+ localize(localtime(e.imported_date))
+ "</li>"
)
result += "</ul>"
result += "</div>"
result += "</div>"
for e in self.duplicates.get_items_comparison():
key = e["key"]
result += "<h3>" + event_field_verbose_name(e["key"]) + "</h3>"
if e["similar"]:
result += (
'<div class="comparison-item">Identique&nbsp;:'
+ str(field_to_html(e["values"], e["key"]))
+ "</div>"
)
else:
result += "<fieldset>"
if key in self.errors:
result += '<div class="message error"><ul>'
for err in self.errors[key]:
result += "<li>" + err + "</li>"
result += "</ul></div>"
result += '<div class="grid comparison-item">'
if hasattr(self, "cleaned_data"):
checked = self.cleaned_data.get(key)
else:
checked = self.fields[key].initial
i = 0
if self.event:
idx = self.events.index(self.event)
result += self.comparison_item(key, i, e["values"][idx], self.fields[e["key"]].choices[idx], self.event, checked)
i += 1
for (v, radio, ev) in zip(e["values"], self.fields[e["key"]].choices, self.events):
if self.event is None or ev != self.event:
result += self.comparison_item(key, i, v, radio, ev, checked)
i += 1
result += "</div></fieldset>"
return mark_safe(result)
def comparison_item(self, key, i, v, radio, ev, checked):
result = '<div class="duplicated">'
id = "id_" + key + "_" + str(ev.pk)
value = "event_" + str(ev.pk)
result += '<input id="' + id + '" name="' + key + '"'
if key in MergeDuplicates.checkboxes_fields:
result += ' type="checkbox"'
if value in checked:
result += " checked"
else:
result += ' type="radio"'
if checked == value:
result += " checked"
result += ' value="' + value + '"'
result += ">"
result += (
'<div class="badge-small">'
+ int_to_abc(i)
+ "</div>")
result += "<div>"
if key == "image":
result += str(field_to_html(ev.local_image, "local_image")) + "</div>"
result += "<div>Lien d'import&nbsp;: "
result += (str(field_to_html(v, key)) + "</div>")
result += "</div>"
return result
def get_selected_events(self, key):
value = self.cleaned_data.get(key)
if key not in self.fields:
return None
else:
if isinstance(value, list):
selected = [int(v.split("_")[-1]) for v in value]
result = []
for s in selected:
for e in self.duplicates.get_duplicated():
if e.pk == s:
result.append(e)
break
return result
else:
selected = int(value.split("_")[-1])
for e in self.duplicates.get_duplicated():
if e.pk == selected:
return e
return None
class CategorisationForm(Form):
required_css_class = 'required'
def __init__(self, *args, **kwargs):
if "events" in kwargs:
events = kwargs.pop("events", None)
else:
events = []
for f in args[0]:
if "_" not in f:
if f + "_cat" in args[0]:
events.append(
(Event.objects.get(pk=int(f)), args[0][f + "_cat"])
)
super().__init__(*args, **kwargs)
for e, c in events:
self.fields[str(e.pk)] = BooleanField(
initial=False,
label=_("Apply category {} to the event {}").format(c, e.title),
required=False,
)
self.fields[str(e.pk) + "_cat"] = CharField(initial=c, widget=HiddenInput())
def get_validated(self):
return [
(e, self.cleaned_data.get(e + "_cat"))
for e in self.fields
if "_" not in e and self.cleaned_data.get(e)
]
class EventAddPlaceForm(Form):
required_css_class = 'required'
place = ModelChoiceField(
label=_("Place"),
queryset=Place.objects.all().order_by("name"),
empty_label=_("Create a missing place"),
required=False,
)
add_alias = BooleanField(initial=True, required=False)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance", False)
super().__init__(*args, **kwargs)
if self.instance.location:
self.fields["add_alias"].label = _(
'Add "{}" to the aliases of the place'
).format(self.instance.location)
else:
self.fields.pop("add_alias")
def modified_event(self):
return self.cleaned_data.get("place")
def save(self):
if self.cleaned_data.get("place"):
place = self.cleaned_data.get("place")
self.instance.exact_location = place
self.instance.save(update_fields=["exact_location"])
if self.cleaned_data.get("add_alias"):
if place.aliases:
place.aliases.append(self.instance.location.strip())
else:
place.aliases = [self.instance.location.strip()]
place.save()
return self.instance
class PlaceForm(GroupFormMixin, ModelForm):
required_css_class = 'required'
apply_to_all = BooleanField(
initial=True,
label=_(
"On saving, use aliases to detect all matching events with missing place"
),
required=False,
)
class Meta:
model = Place
fields = "__all__"
widgets = {"location": TextInput()}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_group('header', _('Header'))
self.fields['name'].group_id = 'header'
self.add_group('address', _('Address'))
self.fields['address'].group_id = 'address'
self.fields['postcode'].group_id = 'address'
self.fields['city'].group_id = 'address'
self.fields['location'].group_id = 'address'
self.add_group('meta', _('Meta'))
self.fields['aliases'].group_id = 'meta'
self.add_group('information', _('Information'))
self.fields['description'].group_id = 'information'
def as_grid(self):
result = ('<div class="grid"><div>'
+ super().as_p()
+ '''</div><div><div class="map-widget">
<div id="map_location" style="width: 100%; aspect-ratio: 16/9"></div>
<p>Cliquez pour ajuster la position GPS</p></div>
<input type="checkbox" role="switch" id="lock_position">Verrouiller la position</lock>
<script>
document.getElementById("lock_position").onclick = function() {
const field = document.getElementById("id_location");
if (this.checked)
field.setAttribute("readonly", true);
else
field.removeAttribute("readonly");
}
</script>
</div></div>''')
return mark_safe(result)
def apply(self):
return self.cleaned_data.get("apply_to_all")
class ContactMessageForm(ModelForm):
class Meta:
model = ContactMessage
fields = ["subject", "name", "email", "message", "related_event"]
widgets = {"related_event": HiddenInput()}
def __init__(self, *args, **kwargs):
self.event = kwargs.pop("event", False)
super().__init__(*args, **kwargs)
self.fields['related_event'].required = False