diff --git a/app/eventyay/cfp/flow.py b/app/eventyay/cfp/flow.py index 551cc31297..ad46a5360f 100644 --- a/app/eventyay/cfp/flow.py +++ b/app/eventyay/cfp/flow.py @@ -11,7 +11,7 @@ from django.core.files.storage import FileSystemStorage from django.core.files.uploadedfile import UploadedFile from django.db.models import Q -from django.forms import ValidationError +from django.forms import FileField, MultipleChoiceField, ModelMultipleChoiceField, ValidationError from django.http import HttpResponseNotAllowed from django.shortcuts import redirect from django.urls import reverse @@ -59,6 +59,33 @@ def i18n_string(data, locales): return LazyI18nString(data) +class SavedFileWrapper: + """Wrapper for saved files to display filename in form widgets. + + This class is used to represent files that have been saved to session storage + during multi-step form navigation. It mimics FieldFile behavior for display + purposes while preventing these objects from being passed as form data (which + would cause validation errors). + + Attributes: + is_saved_file: Always True, used to identify SavedFileWrapper instances + name: The filename to display in the widget + + The __bool__ method ensures the wrapper evaluates as truthy when it has a name, + allowing template conditionals to work correctly. + """ + is_saved_file = True + + def __init__(self, name): + self.name = name + + def __str__(self): + return self.name + + def __bool__(self): + return bool(self.name) + + def serialize_value(value): if getattr(value, 'pk', None): return value.pk @@ -109,8 +136,13 @@ def is_applicable(self, request): def is_completed(self, request): raise NotImplementedError() - @cached_property + @property def cfp_session(self): + """Get session data for this CfP submission. + + Note: This is a regular property (not cached) to ensure we always + get fresh session data, especially important for back navigation. + """ return cfp_session(self.request) def get_next_applicable(self, request): @@ -138,7 +170,7 @@ def get_next_url(self, request): return next_step.get_step_url(request) def get_step_url(self, request, query=None): - kwargs = request.resolver_match.kwargs + kwargs = request.resolver_match.kwargs.copy() kwargs['step'] = self.identifier url = reverse('cfp:event.submit', kwargs=kwargs) new_query = request.GET.copy() @@ -208,24 +240,38 @@ def get(self, request): def identifier(self): raise NotImplementedError() - class FormFlowStep(TemplateFlowStep): form_class = None file_storage = FileSystemStorage(str(Path(settings.MEDIA_ROOT) / 'cfp_uploads')) + def _mark_session_modified(self): + """Mark the session as modified to ensure changes are persisted.""" + self.request.session.modified = True + def get_form_initial(self): - initial_data = self.cfp_session.get('initial', {}).get(self.identifier, {}) - previous_data = self.cfp_session.get('data', {}).get(self.identifier, {}) - return copy.deepcopy({**initial_data, **previous_data}) + session_data = self.cfp_session + initial_data = session_data.get('initial', {}).get(self.identifier, {}) + previous_data = session_data.get('data', {}).get(self.identifier, {}) + result = copy.deepcopy({**initial_data, **previous_data}) + result.update(self.get_saved_file_objects()) + return result + + def get_saved_file_objects(self): + saved_files = self.cfp_session['files'].get(self.identifier, {}) + return { + field: SavedFileWrapper(name=(info.get('name') or _('Previously uploaded file'))) + for field, info in saved_files.items() + } def get_form(self, from_storage=False): if self.request.method == 'GET' or from_storage: - return self.form_class( - data=self.get_form_initial() if from_storage else None, - initial=self.get_form_initial(), - files=self.get_files(), - **self.get_form_kwargs(), - ) + initial_data = self.get_form_initial() + if from_storage: + filtered = {k: v for k, v in initial_data.items() if not isinstance(v, SavedFileWrapper)} + return self.form_class( + data=filtered, initial=filtered, files=self.get_files(), **self.get_form_kwargs() + ) + return self.form_class(initial=initial_data, **self.get_form_kwargs()) return self.form_class(data=self.request.POST, files=self.request.FILES, **self.get_form_kwargs()) def is_completed(self, request): @@ -241,7 +287,25 @@ def get_context_data(self, **kwargs): def post(self, request): self.request = request + action = request.POST.get('action', 'submit') + clear_file = request.POST.get('clear_file') + if clear_file: + form = self.get_form() + if clear_file in form.fields and isinstance(form.fields[clear_file], FileField): + self._clear_file(clear_file) + return self.get(request) + messages.error(request, _('Invalid field for file clearing.')) + return self.get(request) + form = self.get_form() + + if action == 'back': + self._save_partial_data(form) + if request.FILES: + self.set_files(request.FILES) + prev_url = self.get_prev_url(request) + return redirect(prev_url) if prev_url else self.get(request) + if not form.is_valid(): error_message = '\n\n'.join( (f'{form.fields[key].label}: ' if key != '__all__' else '') + ' '.join(values) @@ -249,29 +313,98 @@ def post(self, request): ) messages.error(self.request, error_message) return self.get(request) - self.set_data(form.cleaned_data) + self.set_data(form.cleaned_data, fields=form.fields) self.set_files(form.files) next_url = self.get_next_url(request) return redirect(next_url) if next_url else None - def set_data(self, data): - self.cfp_session['data'][self.identifier] = json.loads( - json.dumps( - {key: value for key, value in data.items() if not getattr(value, 'file', None)}, - default=serialize_value, - ) + def _clear_file(self, field_name): + """Remove a file from session and storage, or mark existing file for clearing.""" + session_data = self.cfp_session + saved_files = session_data['files'].get(self.identifier, {}) + + # If we have a session-stored file for this field, clear only that session file. + # Do not set the "clear_files" flag in this case (that flag is for clearing existing DB files). + if field_name in saved_files: + file_info = saved_files[field_name] + if 'tmp_name' in file_info: + try: + self.file_storage.delete(file_info['tmp_name']) + except OSError as e: + logger.warning("Failed to delete file '%s': %s", file_info['tmp_name'], e) + messages.error(self.request, _("Could not remove the uploaded file. Please try again.")) + return + del saved_files[field_name] + session_data['files'][self.identifier] = saved_files + + # If this field was previously marked for clearing an existing file, unmark it. + clear_files = session_data.get('clear_files', {}) + if (step_flags := clear_files.get(self.identifier)) and field_name in step_flags: + step_flags.remove(field_name) + if not step_flags: + clear_files.pop(self.identifier, None) + self._mark_session_modified() + return + + # No session-stored file: mark the existing DB-backed file for clearing. + clear_flags = session_data.setdefault('clear_files', {}).setdefault(self.identifier, []) + if field_name not in clear_flags: + clear_flags.append(field_name) + self._mark_session_modified() + + def _save_partial_data(self, form): + """Save form data for back navigation (even if incomplete).""" + is_valid = form.is_valid() + cleaned_data = getattr(form, 'cleaned_data', {}) if is_valid else {} + data_to_save = {} + for field_name, field in form.fields.items(): + if isinstance(field, FileField): + continue + if field_name in cleaned_data: + data_to_save[field_name] = cleaned_data[field_name] + elif field_name in self.request.POST: + if isinstance(field, (MultipleChoiceField, ModelMultipleChoiceField)): + data_to_save[field_name] = self.request.POST.getlist(field_name) + else: + data_to_save[field_name] = self.request.POST.get(field_name) + session_data = self.cfp_session + session_data['data'][self.identifier] = json.loads(json.dumps(data_to_save, default=serialize_value)) + self._mark_session_modified() + + def set_data(self, data, fields=None): + fields = fields or getattr(self.form_class, 'base_fields', {}) + file_field_names = {name for name, field in fields.items() if isinstance(field, FileField)} + data = {k: v for k, v in data.items() if k not in file_field_names} + session_data = self.cfp_session + session_data['data'][self.identifier] = json.loads( + json.dumps(data, default=serialize_value) ) + self._mark_session_modified() def get_files(self): + """Retrieve saved files from session storage. + + If a file cannot be opened (e.g., deleted from storage), it is skipped + with a warning. This allows the form to render gracefully even if some + files are missing, rather than failing completely. The user can re-upload + the missing file if needed. + """ saved_files = self.cfp_session['files'].get(self.identifier, {}) files = {} for field, field_dict in saved_files.items(): field_dict = field_dict.copy() tmp_name = field_dict.pop('tmp_name') - files[field] = UploadedFile(file=self.file_storage.open(tmp_name), **field_dict) + try: + uploaded_file = UploadedFile(file=self.file_storage.open(tmp_name), **field_dict) + files[field] = uploaded_file + except OSError as e: + logger.warning("Could not open file '%s' for field '%s': %s", tmp_name, field, e) return files or None def set_files(self, files): + if not files: + return + session_data = self.cfp_session for field, field_file in files.items(): tmp_filename = self.file_storage.save(field_file.name, field_file) file_dict = { @@ -281,9 +414,13 @@ def set_files(self, files): 'size': field_file.size, 'charset': field_file.charset, } - data = self.cfp_session['files'].get(self.identifier, {}) + data = session_data['files'].get(self.identifier, {}) data[field] = file_dict - self.cfp_session['files'][self.identifier] = data + session_data['files'][self.identifier] = data + clear_files = session_data.get('clear_files', {}) + if self.identifier in clear_files and field in clear_files[self.identifier]: + clear_files[self.identifier].remove(field) + self._mark_session_modified() class GenericFlowStep: @@ -518,7 +655,9 @@ def get_form_kwargs(self): if not result.get('user') and self.request.user.is_authenticated: result['user'] = self.request.user user = result.get('user') - result['name'] = user.fullname if user else user_data.get('register_name') + saved_profile_data = self.cfp_session.get('data', {}).get(self.identifier, {}) + saved_fullname = (saved_profile_data.get('fullname') or '').strip() + result['name'] = saved_fullname or (user.fullname if user else user_data.get('register_name')) result['read_only'] = False result['essential_only'] = True return result @@ -531,13 +670,29 @@ def get_context_data(self, **kwargs): email = data.get('register_email', '') if email: result['gravatar_parameter'] = User(email=email).gravatar_parameter + saved_files = self.cfp_session.get('files', {}).get(self.identifier, {}) + clear_flags = self.cfp_session.get('clear_files', {}).get(self.identifier, []) + if 'avatar' in saved_files: + result['saved_avatar_name'] = saved_files['avatar'].get('name') or _('Previously uploaded') + if self.request.user.is_authenticated and self.request.user.avatar: + avatar_name = self.request.user.avatar.name + result['avatar_basename'] = Path(avatar_name).name + # Hide existing avatar if marked for clearing + if 'avatar' in clear_flags: + result['avatar_cleared'] = True return result def done(self, request, draft=False): form = self.get_form(from_storage=True) - form.is_valid() + if not form.is_valid(): + raise ValidationError(_("Profile form is invalid.")) form.user = request.user + avatar_uploaded = bool(form.files and form.files.get('avatar')) form.save() + # Clear avatar if marked for clearing + clear_flags = self.cfp_session.get('clear_files', {}).get(self.identifier, []) + if 'avatar' in clear_flags and request.user.avatar and not avatar_uploaded: + request.user.avatar.delete(save=True) @property def label(self): diff --git a/app/eventyay/cfp/templates/cfp/event/submission_base.html b/app/eventyay/cfp/templates/cfp/event/submission_base.html index 5284913e1d..f785411a34 100644 --- a/app/eventyay/cfp/templates/cfp/event/submission_base.html +++ b/app/eventyay/cfp/templates/cfp/event/submission_base.html @@ -14,6 +14,7 @@ {% include "cfp/includes/forms_header.html" %} {% compress js %} + {% endcompress %} {% block cfp_submission_header %}{% endblock cfp_submission_header %} {% endblock cfp_header %} @@ -76,9 +77,9 @@

{% block submission_step_title %}{{ title|default:'' }}{% endblock submissio
{% if prev_url %} - + {% endif %}
diff --git a/app/eventyay/cfp/templates/cfp/event/user_submission_edit.html b/app/eventyay/cfp/templates/cfp/event/user_submission_edit.html index 9456c810f2..b9d14912b5 100644 --- a/app/eventyay/cfp/templates/cfp/event/user_submission_edit.html +++ b/app/eventyay/cfp/templates/cfp/event/user_submission_edit.html @@ -17,6 +17,7 @@ + {% endcompress %} {% endblock cfp_header %} diff --git a/app/eventyay/cfp/views/wizard.py b/app/eventyay/cfp/views/wizard.py index 7e2107e203..bb4329cc4c 100644 --- a/app/eventyay/cfp/views/wizard.py +++ b/app/eventyay/cfp/views/wizard.py @@ -71,6 +71,8 @@ def dispatch(self, request, *args, **kwargs): or step.identifier == 'user' ], ) + if request.method == 'POST' and (request.POST.get('action') == 'back' or request.POST.get('clear_file')): + return result if request.method == 'GET' or (step.get_next_applicable(request) or not step.is_completed(request)): if result and (csp_change := step.get_csp_update(request)): result._csp_update = csp_change diff --git a/app/eventyay/common/forms/widgets.py b/app/eventyay/common/forms/widgets.py index c3560ae1ef..9e505e6191 100644 --- a/app/eventyay/common/forms/widgets.py +++ b/app/eventyay/common/forms/widgets.py @@ -81,7 +81,7 @@ def name(self): return self.file.name def __str__(self): - return Path(self.name).stem + return Path(self.name).name @property def url(self): @@ -89,13 +89,31 @@ def url(self): def get_context(self, name, value, attrs): ctx = super().get_context(name, value, attrs) - ctx['widget']['value'] = self.FakeFile(value) + if value: + if getattr(value, 'is_saved_file', False): + ctx['widget']['value'] = None + else: + ctx['widget']['value'] = self.FakeFile(value) return ctx class ImageInput(ClearableBasenameFileInput): template_name = 'common/widgets/image_input.html' + def get_context(self, name, value, attrs): + ctx = super().get_context(name, value, attrs) + if value and getattr(value, 'is_saved_file', False): + ctx['widget']['is_saved_file'] = True + ctx['widget']['saved_filename'] = Path(value.name).name if getattr(value, 'name', None) else '' + elif value: + if getattr(value, 'name', None): + ctx['widget']['saved_filename'] = Path(value.name).name + elif getattr(value, 'url', None): + ctx['widget']['saved_filename'] = Path(value.url).name + else: + ctx['widget']['saved_filename'] = str(value) + return ctx + class MarkdownWidget(Textarea): template_name = 'common/widgets/markdown.html' diff --git a/app/eventyay/common/templates/common/avatar.html b/app/eventyay/common/templates/common/avatar.html index f8fe39133b..4577442e34 100644 --- a/app/eventyay/common/templates/common/avatar.html +++ b/app/eventyay/common/templates/common/avatar.html @@ -9,18 +9,36 @@
+ {% if saved_avatar_name %} +
+ {% translate "Currently selected" %}: + {{ saved_avatar_name }} + +
+ {% elif user.avatar and not avatar_cleared %} +
+ {% translate "Currently selected" %}: + {% if avatar_basename %}{{ avatar_basename }}{% else %}{{ user.avatar.name }}{% endif %} + +
+ {% endif %} + {{ form.avatar.as_field_group }}
{{ form.get_gravatar.as_field_group }}
- diff --git a/app/eventyay/common/templates/common/widgets/image_input.html b/app/eventyay/common/templates/common/widgets/image_input.html index d9e65658b0..4c001255a9 100644 --- a/app/eventyay/common/templates/common/widgets/image_input.html +++ b/app/eventyay/common/templates/common/widgets/image_input.html @@ -1,13 +1,27 @@ -{% if widget.value and widget.value.url %} -
+{% load i18n static %} +{% if widget.is_saved_file %} +
+ {% translate "Currently selected" %}: + {{ widget.saved_filename }} + +
+{% elif widget.value and widget.value.url %} +
+ {% translate "Currently selected" %}: + {{ widget.saved_filename }} + + +
+
{% endif %} -{% if widget.is_initial %}{{ widget.initial_text }}: {{ widget.value }}{% if not widget.is_required %} - - {% endif %}
- {{ widget.input_text }}:{% endif %} - + + diff --git a/app/eventyay/person/forms/profile.py b/app/eventyay/person/forms/profile.py index e19d37560a..8cd57b8a0e 100644 --- a/app/eventyay/person/forms/profile.py +++ b/app/eventyay/person/forms/profile.py @@ -22,7 +22,6 @@ ) from eventyay.common.forms.renderers import InlineFormRenderer from eventyay.common.forms.widgets import ( - ClearableBasenameFileInput, EnhancedSelect, EnhancedSelectMultiple, MarkdownWidget, @@ -76,7 +75,11 @@ def __init__(self, *args, name=None, **kwargs): initial['name'] = name if self.user: - initial.update({field: getattr(self.user, field) for field in self.user_fields}) + # Only use user's current values for fields not already in initial + # This allows session data to take priority (for back navigation) + for field in self.user_fields: + if field not in initial or initial.get(field) is None: + initial[field] = getattr(self.user, field) for field in self.user_fields: field_class = self.Meta.field_classes.get(field, User._meta.get_field(field).formfield) self.fields[field] = field_class( @@ -171,7 +174,7 @@ class Meta: public_fields = ['fullname', 'biography', 'avatar'] widgets = { 'biography': MarkdownWidget, - 'avatar': ClearableBasenameFileInput, + 'avatar': forms.FileInput, 'avatar_source': MarkdownWidget, 'avatar_license': MarkdownWidget, } diff --git a/app/eventyay/static/common/js/fileInput.js b/app/eventyay/static/common/js/fileInput.js new file mode 100644 index 0000000000..3ed991b9cb --- /dev/null +++ b/app/eventyay/static/common/js/fileInput.js @@ -0,0 +1,77 @@ +function handleFileInputChange(input) { + const name = input.name; + const selectedInfo = document.getElementById(`${name}-selected-info`); + const selectedName = document.getElementById(`${name}-selected-name`); + const savedInfo = document.getElementById(`${name}-saved-info`); + const existingInfo = document.getElementById(`${name}-existing-info`); + const preview = document.getElementById(`${name}-preview`); + const clearCheckbox = document.getElementById(`${name}-clear-checkbox`); + + if (input.files?.[0]) { + if (selectedName) selectedName.textContent = input.files[0].name; + if (selectedInfo) selectedInfo.style.display = 'block'; + if (savedInfo) savedInfo.style.display = 'none'; + if (existingInfo) existingInfo.style.display = 'none'; + if (preview) preview.style.display = 'none'; + if (clearCheckbox) clearCheckbox.checked = false; + } else { + if (selectedInfo) selectedInfo.style.display = 'none'; + if (savedInfo) savedInfo.style.display = 'block'; + } +} + +function clearFileInput(name) { + const input = document.querySelector(`input[name="${name}"]`); + if (input) input.value = ''; + const selectedInfo = document.getElementById(`${name}-selected-info`); + if (selectedInfo) selectedInfo.style.display = 'none'; + const savedInfo = document.getElementById(`${name}-saved-info`); + const existingInfo = document.getElementById(`${name}-existing-info`); + const preview = document.getElementById(`${name}-preview`); + // Only show saved/existing info if they exist in DOM (meaning there was a previous file) + // Check if element exists and has meaningful content (not just whitespace) + const savedStrong = savedInfo?.querySelector('strong'); + if (savedInfo && savedStrong?.textContent.trim()) { + savedInfo.style.display = 'block'; + } + const existingStrong = existingInfo?.querySelector('strong'); + if (existingInfo && existingStrong?.textContent.trim()) { + existingInfo.style.display = 'block'; + } + if (preview) preview.style.display = 'block'; +} + +function clearExistingFile(name) { + const existingInfo = document.getElementById(`${name}-existing-info`); + const preview = document.getElementById(`${name}-preview`); + const clearCheckbox = document.getElementById(`${name}-clear-checkbox`); + if (existingInfo) existingInfo.style.display = 'none'; + if (preview) preview.style.display = 'none'; + if (clearCheckbox) clearCheckbox.checked = true; +} + +document.addEventListener('DOMContentLoaded', () => { + // Auto-attach change handlers to file inputs + document.querySelectorAll('.form-file-selected').forEach((el) => { + const name = el.id.replace('-selected-info', ''); + const input = document.querySelector(`input[name="${name}"]`); + if (input && !input.dataset.fileHandlerAttached) { + input.dataset.fileHandlerAttached = 'true'; + input.addEventListener('change', () => handleFileInputChange(input)); + } + }); + + // Use event delegation for clear buttons with data attributes + document.addEventListener('click', (e) => { + const btn = e.target.closest('[data-clear-file]'); + if (btn) { + e.preventDefault(); + clearFileInput(btn.dataset.clearFile); + } + const existingBtn = e.target.closest('[data-clear-existing]'); + if (existingBtn) { + e.preventDefault(); + clearExistingFile(existingBtn.dataset.clearExisting); + } + }); +});