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/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()
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 }}"
+
+
+
+
+ {% 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
+
+
+ | Name |
+ URL |
+ |
+
+ {% for a in event.links.all %}
+
+ | {{ a.note }} |
+ {{ a.url }} |
+
+
+ {% empty %}
+ | No links have been submitted |
+ {% endfor %}
+
+
Files