From 4f5fe09cbc09b5a4961510101e1857033c34f412 Mon Sep 17 00:00:00 2001 From: Ben Feinberg <66845228+benfeinberg@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:57:03 -0400 Subject: [PATCH 1/2] add a way to save links to event resources without uploading them directly --- events/forms.py | 34 ++++++++++- events/migrations/0014_eventresourcelink.py | 23 +++++++ events/models.py | 6 ++ events/urls/events.py | 4 +- events/views/flow.py | 52 +++++++++++++++- .../formset_crispy_event_resource_links.html | 60 +++++++++++++++++++ site_tmpl/uglydetail.html | 26 +++++++- 7 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 events/migrations/0014_eventresourcelink.py create mode 100644 site_tmpl/formset_crispy_event_resource_links.html diff --git a/events/forms.py b/events/forms.py index 39d8c70f..ae80eae1 100644 --- a/events/forms.py +++ b/events/forms.py @@ -15,7 +15,7 @@ from django.core.exceptions import ValidationError from django.core.validators import RegexValidator from django.db.models import Model, Q -from django.forms import ModelChoiceField, ModelMultipleChoiceField, ModelForm, SelectDateWidget, TextInput +from django.forms import ModelChoiceField, ModelMultipleChoiceField, ModelForm, SelectDateWidget, TextInput, URLInput from django.utils import timezone # python multithreading bug workaround from easymde.widgets import EasyMDEEditor @@ -23,7 +23,7 @@ from data.forms import DynamicFieldContainer, FieldAccessForm, FieldAccessLevel from events.fields import GroupedModelChoiceField from events.models import (BaseEvent, Billing, MultiBilling, BillingEmail, MultiBillingEmail, - Category, CCReport, Event, Event2019, EventAttachment, EventCCInstance, Extra, + Category, CCReport, Event, Event2019, EventAttachment, EventResourceLink, EventCCInstance, Extra, ExtraInstance, Hours, Lighting, Location, Organization, OrganizationTransfer, OrgBillingVerificationEvent, Workshop, WorkshopDate, Projection, Service, ServiceInstance, Sound, PostEventSurvey, OfficeHour) @@ -1419,6 +1419,36 @@ class Meta: model = EventAttachment fields = ('for_service', 'attachment', 'note') +class ResourceLinkForm(forms.ModelForm): + def __init__(self, event, *args, **kwargs): + self.event = event + self.helper = FormHelper() + self.helper.form_class = "form-inline" + self.helper.template = 'bootstrap/table_inline_formset.html' + self.helper.form_tag = False + self.helper.layout = Layout( + Field('note'), + Field('url'), + HTML('
'), + ) + super(ResourceLinkForm, self).__init__(*args, **kwargs) + + def save(self, commit=True): + obj = super(ResourceLinkForm, self).save(commit=False) + obj.event = self.event + if commit: + obj.save() + self.save_m2m() + return obj + + class Meta: + model = EventResourceLink + fields = ('note', 'url') + widgets = { + 'note': TextInput(attrs={"style": "width: 100%"}), + 'url': URLInput(attrs={"style": "width: 100%"}), + } + class ExtraForm(forms.ModelForm): class Meta: diff --git a/events/migrations/0014_eventresourcelink.py b/events/migrations/0014_eventresourcelink.py new file mode 100644 index 00000000..83fbb5ae --- /dev/null +++ b/events/migrations/0014_eventresourcelink.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2025-06-27 20:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0013_alter_baseevent_polymorphic_ctype_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='EventResourceLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('note', models.TextField(blank=True, default='', null=True)), + ('url', models.URLField()), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='events.baseevent')), + ], + ), + ] diff --git a/events/models.py b/events/models.py index 5dec46a4..a068ab7c 100755 --- a/events/models.py +++ b/events/models.py @@ -1341,6 +1341,12 @@ class EventAttachment(models.Model): externally_uploaded = models.BooleanField(default=False) +class EventResourceLink(models.Model): + event = models.ForeignKey(BaseEvent, on_delete=models.CASCADE, related_name="links") + note = models.TextField(null=True, blank=True, default="") + url = models.URLField() + + @reversion.register() class EventArbitrary(models.Model): """ Additional "OneOff" charges (i.e. rentals, additional fees) """ diff --git a/events/urls/events.py b/events/urls/events.py index dd508c14..be42f144 100644 --- a/events/urls/events.py +++ b/events/urls/events.py @@ -80,8 +80,8 @@ def generate_date_patterns(func, name): re_path(r'^selfcrew/(?P[0-9]+)/$', flow_views.hours_prefill_self, name="selfcrew"), re_path(r'^crewchief/(?P[0-9a-f]+)/$', flow_views.assigncc, name="chiefs"), re_path(r'^rmcc/(?P[0-9a-f]+)/(?P[0-9a-f]+)/$', flow_views.rmcc, name="remove-chief"), - re_path(r'^attachments/(?P[0-9a-f]+)/$', flow_views.assignattach, - name="files"), + re_path(r'^attachments/(?P[0-9a-f]+)/$', flow_views.assignattach, name="files"), + re_path(r'^links/(?P[0-9a-f]+)/$', flow_views.event_resources, name="resource-links"), re_path(r'^extras/(?P[0-9a-f]+)/$', flow_views.extras, name="extras"), re_path(r'^oneoff/(?P[0-9a-f]+)/$', flow_views.oneoff, name="oneoffs"), re_path(r'^enter-worktag/(?P[0-9a-f]+)/$', flow_views.WorkdayEntry.as_view(), name="worktag-form"), diff --git a/events/views/flow.py b/events/views/flow.py index f8b1aaca..1bee0487 100644 --- a/events/views/flow.py +++ b/events/views/flow.py @@ -28,11 +28,11 @@ MultiBillingUpdateForm, CCIForm, CrewAssign, EventApprovalForm, EventDenialForm, EventReviewForm, ExtraForm, InternalReportForm, MKHoursForm, BillingEmailForm, MultiBillingEmailForm, ServiceInstanceForm, WorkdayForm, CrewCheckinForm, - CrewCheckoutForm, CheckoutHoursForm, BulkCheckinForm + CrewCheckoutForm, CheckoutHoursForm, BulkCheckinForm, ResourceLinkForm ) from events.models import (BaseEvent, Billing, MultiBilling, BillingEmail, MultiBillingEmail, Category, CCReport, Event, - Event2019, EventArbitrary, EventAttachment, EventCCInstance, ExtraInstance, Hours, - ReportReminder, ServiceInstance, PostEventSurvey, CCR_DELTA, CrewAttendanceRecord) + Event2019, EventArbitrary, EventAttachment, EventResourceLink, EventCCInstance, ExtraInstance, + Hours, ReportReminder, ServiceInstance, PostEventSurvey, CCR_DELTA, CrewAttendanceRecord) from helpers.mixins import (ConditionalFormMixin, HasPermMixin, HasPermOrTestMixin, LoginRequiredMixin, SetFormMsgMixin) from helpers.challenges import is_officer @@ -833,6 +833,52 @@ def assignattach_external(request, id): return render(request, 'formset_crispy_attachments.html', context) +@login_required +def event_resources(request, id): + """ Update resources for an event (links to useful files) """ + context = {} + + event = get_object_or_404(BaseEvent, pk=id) + if not (request.user.has_perm('events.event_attachments') or + request.user.has_perm('events.event_attachments', event)): + raise PermissionDenied + if event.closed: + messages.add_message(request, messages.ERROR, 'Event is closed.') + return HttpResponseRedirect(reverse('events:detail', args=(event.id,))) + context['event'] = event + + links_formset = inlineformset_factory(BaseEvent, EventResourceLink, extra=2, exclude=[]) + links_formset.form = curry_class(ResourceLinkForm, event=event) + + if request.method == 'POST': + set_revision_comment("Edited attachments", None) + formset = links_formset(request.POST, request.FILES, instance=event) + if formset.is_valid(): + formset.save() + event.save() # for revision to be created + should_send_email = not event.test_event + if should_send_email: + to = [settings.EMAIL_TARGET_VP_DB] + if hasattr(event, 'projection') and event.projection \ + or event.serviceinstance_set.filter(service__category__name='Projection').exists(): + to.append(settings.EMAIL_TARGET_HP) + for ccinstance in event.ccinstances.all(): + if ccinstance.crew_chief.email: + prefs, created = UserPreferences.objects.get_or_create(user=ccinstance.crew_chief) + if not prefs.ignore_user_action or ccinstance.crew_chief != request.user: + to.append(ccinstance.crew_chief.email) + subject = "Event Attachments" + email_body = "Attachments for the following event were modified by %s." % request.user.get_full_name() + email = EventEmailGenerator(event=event, subject=subject, to_emails=to, body=email_body) + email.send() + return HttpResponseRedirect(reverse('events:detail', args=(event.id,))) + else: + formset = links_formset(instance=event) + + context['formset'] = formset + + return render(request, 'formset_crispy_event_resource_links.html', context) + @login_required def download_ics(request, pk): diff --git a/site_tmpl/formset_crispy_event_resource_links.html b/site_tmpl/formset_crispy_event_resource_links.html new file mode 100644 index 00000000..c0edc53e --- /dev/null +++ b/site_tmpl/formset_crispy_event_resource_links.html @@ -0,0 +1,60 @@ +{% extends 'base_admin.html' %} +{% load crispy_forms_tags %} + +{% block title %}Links for "{{event}}" | Lens and Lights at WPI{% endblock %} +{% block content %} +
+
+

Links for "{{ event }}"

+
+ {% csrf_token %} + {{ formset.management_form }} + + + + + + + {% for form in formset %} + {% if form.errors %} + + + + + + {% endif %} + + + + + + {% endfor %} +
NameURLDelete?
{% for e in form.note.errors %} {{ e }} {% endfor %}{% for e in form.url.errors %} {{ e }} {% endfor %}{% for e in form.DELETE.errors %} {{ e }} {% endfor %}
{{ form.id }}{{ form.note }}{{ form.url }} {{ form.DELETE }}
+ +
+
+
+ +
+
+ {% include "js_formset_add_row.tmpl" %} + {% with formset.empty_form as form %} + + + + + + + +
{{ form.id }}{{ form.note }}{{ form.url }} {{ form.DELETE }}
+ {% endwith %} +{% endblock %} + +{% block extras %} +{{ formset.media }} +{% include "js_datetimepick.tmpl" %} +{% endblock %} + +{% block finalsay %} + +{% endblock %} diff --git a/site_tmpl/uglydetail.html b/site_tmpl/uglydetail.html index 10b28f86..d6b30fb4 100644 --- a/site_tmpl/uglydetail.html +++ b/site_tmpl/uglydetail.html @@ -883,11 +883,33 @@

Internal Notes

{% if not event.closed %} {% permission request.user has 'events.event_attachments' of event %} -
- Edit + {% endpermission %} {% endif %} + +

Links

+ + + + + + + {% for a in event.links.all %} + + + + + + {% empty %} + + {% endfor %} +
NameURL
{{ a.note }}{{ a.url }}
No links have been submitted
+

Files

From 08caa4b53510a12edf498a705bda847104e73f97 Mon Sep 17 00:00:00 2001 From: Ben Feinberg <66845228+benfeinberg@users.noreply.github.com> Date: Sun, 29 Jun 2025 21:38:52 -0400 Subject: [PATCH 2/2] add test for event resource links --- events/tests/test_event_view.py | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/events/tests/test_event_view.py b/events/tests/test_event_view.py index 4fb5250b..29a72e56 100644 --- a/events/tests/test_event_view.py +++ b/events/tests/test_event_view.py @@ -1128,6 +1128,46 @@ def test_assignattach(self): self.assertRedirects(self.client.post(reverse("events:files", args=[self.e.pk]), valid_data), reverse("events:detail", args=[self.e.pk])) + def test_event_resources(self): + self.setup() + + # By default, should not have permission to modify resource links + self.assertOk(self.client.get(reverse("events:resource-links", args=[self.e.pk])), 403) + + permission = Permission.objects.get(codename="event_attachments") + self.user.user_permissions.add(permission) + + # Will need view_event permission for redirect + permission = Permission.objects.get(codename="view_events") + self.user.user_permissions.add(permission) + + # Check that we redirect to detail page if event is closed + self.e.closed = True + self.e.save() + + self.assertRedirects(self.client.get(reverse("events:resource-links", args=[self.e.pk])), + reverse("events:detail", args=[self.e.pk])) + + self.e.closed = False + self.e.save() + + # Everything should load ok + self.assertOk(self.client.get(reverse("events:resource-links", args=[self.e.pk]))) + + valid_data = { + "links-TOTAL_FORMS": 1, + "links-INITIAL_FORMS": 0, + "links-MIN_NUM_FORMS": 0, + "links-MAX_NUM_FORMS": 1000, + "links-0-note": "test note", + "links-0-url": "https://www.wikipedia.org" + } + + # Check that we can add attachment ok + self.assertRedirects(self.client.post(reverse("events:resource-links", args=[self.e.pk]), valid_data), + reverse("events:detail", args=[self.e.pk])) + self.assertTrue(models.EventResourceLink.objects.filter(event=self.e).exists()) + def test_ics_download(self): self.setup()