From e6798c8c1234e4421fec89ec53448c58017f2a2f Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 17 Sep 2024 14:33:43 +0200 Subject: [PATCH 01/51] feat: Import code from old branch to avoid merge --- attachments/__init__.py | 1 + attachments/admin.py | 3 + attachments/apps.py | 6 ++ attachments/kinds.py | 161 ++++++++++++++++++++++++++++ attachments/migrations/__init__.py | 0 attachments/models.py | 52 +++++++++ attachments/tests.py | 3 + fetc/settings.py | 2 + proposals/views/attachment_views.py | 10 ++ 9 files changed, 238 insertions(+) create mode 100644 attachments/__init__.py create mode 100644 attachments/admin.py create mode 100644 attachments/apps.py create mode 100644 attachments/kinds.py create mode 100644 attachments/migrations/__init__.py create mode 100644 attachments/models.py create mode 100644 attachments/tests.py create mode 100644 proposals/views/attachment_views.py diff --git a/attachments/__init__.py b/attachments/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/attachments/__init__.py @@ -0,0 +1 @@ + diff --git a/attachments/admin.py b/attachments/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/attachments/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/attachments/apps.py b/attachments/apps.py new file mode 100644 index 000000000..87623ad5f --- /dev/null +++ b/attachments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AttachmentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'attachments' diff --git a/attachments/kinds.py b/attachments/kinds.py new file mode 100644 index 000000000..8b8e97d38 --- /dev/null +++ b/attachments/kinds.py @@ -0,0 +1,161 @@ +from django.utils.translation import gettext as _ +from django.urls import reverse + +from proposals.models import Proposal +from studies.models import Study +from main.utils import renderable + +class AttachmentKind: + """Defines a kind of file attachment and when it is required.""" + + db_name = "" + name = "" + description = "" + max_num = None + attached_field = "attachments" + + def __init__(self, obj): + self.object = obj + + def get_instances_for_object(self): + manager = getattr(self.object, self.attached_field) + return manager.filter(kind=self.db_name) + + def num_required(self): + return 1 + + def num_provided(self): + return self.get_instances_for_proposal().count() + + def still_required(self): + return self.num_required() - self.num_provided() + + def test_required(self): + """Returns False if the given proposal requires this kind + of attachment""" + return self.num_required() > self.num_provided() + + def test_recommended(self): + """Returns True if the given proposal recommends, but does not + necessarily require this kind of attachment""" + return True + + def get_attach_url(self): + url_kwargs = { + "other_pk": self.object.pk, + "kind": self.db_name, + } + return reverse("proposals:attach_file", kwargs=url_kwargs) + +class ProposalAttachmentKind(AttachmentKind): + + attached_object = Proposal + +class StudyAttachmentKind(AttachmentKind): + + attached_object = Study + +class InformationLetter(AttachmentKind): + + db_name = "information_letter" + name = _("Informatiebrief") + description = _("Omschrijving informatiebrief") + +class ConsentForm(AttachmentKind): + + db_name = "consent_form" + name = _("Toestemmingsverklaring") + description = _("Omschrijving toestemmingsverklaring") + +class DataManagementPlan(ProposalAttachmentKind): + + db_name = "dmp" + name = _("Data Management Plan") + description = _("Omschrijving DMP") + + def num_recommended(self): + return 1 + +class OtherProposalAttachment(ProposalAttachmentKind): + + db_name = "other" + name = _("Overige bestanden") + description = _("Voor alle overige soorten bestanden") + + def num_required(self): + return 0 + + +STUDY_ATTACHMENTS = [ + InformationLetter, + ConsentForm, +] + +PROPOSAL_ATTACHMENTS = [ + DataManagementPlan, + OtherProposalAttachment, +] + +ATTACHMENTS = PROPOSAL_ATTACHMENTS + STUDY_ATTACHMENTS + +ATTACHMENT_CHOICES = [ + (a.db_name, a.name) for a in ATTACHMENTS +] + +class AttachmentSlot(renderable): + + template_name = "proposals/attachments/slot.html" + + def __init__(self, object, kind, attachment=None): + self.object = object + self.kind = kind + self.attachment = attachment + +class ProposalAttachments: + """ + A utility class that provides most functions related to a proposal's + attachments. The algorithm with which required attachments are determined + is as follows: + + 1. Collect all existing attachments for proposal and studies + 2. Match all existing attachments to kinds + 3. Complement existing attachments with additional kind instances to + represent yet to be fulfilled requirements + + This happens for the proposal as a whole and each of its studies. + """ + + def __init__(self, proposal): + self.proposal = proposal + self.proposal_kinds = self.walk_proposal() + self.study_kinds = self.walk_all_studies() + + def match_proposal(self): + for kind in self.proposal_kinds: + if kind.match(att): + return kind(att) + raise RuntimeError( + "Couldn't match attachment to kind", + ) + + def walk_proposal(self): + kinds = [] + for kind in self.proposal_kinds: + kinds.append( + kind(self.proposal), + ) + return kinds + + def walk_all_studies(self): + study_dict = {} + for study in self.proposal.study_set.all(): + study_dict[study] = self.walk_study(study) + return study_dict + + def walk_study(self, study): + kinds = [] + for kind in self.study_kinds: + kinds.append( + kind(study), + ) + return kinds diff --git a/attachments/migrations/__init__.py b/attachments/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/attachments/models.py b/attachments/models.py new file mode 100644 index 000000000..bc84ea3d0 --- /dev/null +++ b/attachments/models.py @@ -0,0 +1,52 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _ +from .kinds import ATTACHMENT_CHOICES + +from cdh.files.db import FileField as CDHFileField + +# Create your models here. + +class Attachment(): + upload = CDHFileField() + parent = models.ForeignKey( + "attachments.attachment", + related_name="children", + null=True, + on_delete=models.SET_NULL, + default=None, + ) + kind = models.CharField( + max_length=100, + choices=ATTACHMENT_CHOICES, + default=("", _("Gelieve selecteren")), + ) + name = models.CharField( + max_length=50, + default="", + help_text=_( + "Geef je bestand een omschrijvende naam, het liefst " + "maar enkele woorden." + ) + ) + comments = models.TextField( + max_length=2000, + default="", + help_text=_( + "Geef hier je motivatie om dit bestand toe te voegen en waar " + "je het voor gaat gebruiken tijdens je onderzoek. Eventuele " + "opmerkingen voor de FETC kun je hier ook kwijt." + ) + ) + +class ProposalAttachment(Attachment, models.Model): + attached_to = models.ManyToManyField( + "proposals.Proposal", + related_name="attachments", + ) + +class StudyAttachment(Attachment, models.Model): + attached_to = models.ManyToManyField( + "studies.Study", + related_name="attachments", + ) diff --git a/attachments/tests.py b/attachments/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/attachments/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/fetc/settings.py b/fetc/settings.py index 6a7104ba8..0a2fb0183 100644 --- a/fetc/settings.py +++ b/fetc/settings.py @@ -38,6 +38,7 @@ "cdh.core", "cdh.vue", "cdh.rest", + "cdh.files", # Django supplied apps "django.contrib.auth", "django.contrib.contenttypes", @@ -61,6 +62,7 @@ "observations", "reviews", "faqs", + "attachments", ] MIDDLEWARE = [ diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py new file mode 100644 index 000000000..84737575a --- /dev/null +++ b/proposals/views/attachment_views.py @@ -0,0 +1,10 @@ +from django.views import generic + +class ProposalAttachments(generic.TemplateView): + + template_name = "proposals/attachments/proposal_attachments.html" + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + + return context From db4e2e02a1b3c2d0c88cecc6765004d876250f19 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 23 Sep 2024 18:48:09 +0200 Subject: [PATCH 02/51] wip: Working on attachments in new branch --- attachments/kinds.py | 54 +++++-- attachments/migrations/0001_initial.py | 59 ++++++++ ...2_remove_proposalattachment_id_and_more.py | 115 +++++++++++++++ attachments/models.py | 9 +- .../static/attachments/attachments.css | 11 ++ .../attachments/base_single_attachment.html | 21 +++ attachments/templates/attachments/slot.html | 12 ++ attachments/utils.py | 135 ++++++++++++++++++ attachments/views.py | 77 ++++++++++ .../templates/proposals/attachments.html | 23 +++ proposals/urls.py | 6 + proposals/views/proposal_views.py | 23 +++ 12 files changed, 529 insertions(+), 16 deletions(-) create mode 100644 attachments/migrations/0001_initial.py create mode 100644 attachments/migrations/0002_remove_proposalattachment_id_and_more.py create mode 100644 attachments/static/attachments/attachments.css create mode 100644 attachments/templates/attachments/base_single_attachment.html create mode 100644 attachments/templates/attachments/slot.html create mode 100644 attachments/utils.py create mode 100644 attachments/views.py create mode 100644 proposals/templates/proposals/attachments.html diff --git a/attachments/kinds.py b/attachments/kinds.py index 8b8e97d38..67080f405 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -17,12 +17,23 @@ class AttachmentKind: def __init__(self, obj): self.object = obj + def get_slots(self): + slots = [] + for inst in self.get_instances_for_object(): + slots.append(AttachmentSlot(self, inst,)) + for i in range(self.still_required()): + slots.append(AttachmentSlot(self, inst,)) + return slots + def get_instances_for_object(self): manager = getattr(self.object, self.attached_field) return manager.filter(kind=self.db_name) def num_required(self): - return 1 + return 0 + + def num_suggested(self): + return 0 def num_provided(self): return self.get_instances_for_proposal().count() @@ -55,12 +66,15 @@ class StudyAttachmentKind(AttachmentKind): attached_object = Study -class InformationLetter(AttachmentKind): +class InformationLetter(StudyAttachmentKind): db_name = "information_letter" name = _("Informatiebrief") description = _("Omschrijving informatiebrief") + def num_required(self,): + return 1 + class ConsentForm(AttachmentKind): db_name = "consent_form" @@ -85,12 +99,18 @@ class OtherProposalAttachment(ProposalAttachmentKind): def num_required(self): return 0 + def num_suggested(self): + """ + You may always add another miscellaneous file.""" + return self.num_provided + 1 + STUDY_ATTACHMENTS = [ InformationLetter, ConsentForm, ] + PROPOSAL_ATTACHMENTS = [ DataManagementPlan, OtherProposalAttachment, @@ -104,13 +124,18 @@ def num_required(self): class AttachmentSlot(renderable): - template_name = "proposals/attachments/slot.html" + template_name = "attachments/slot.html" - def __init__(self, object, kind, attachment=None): - self.object = object + def __init__(self, kind, attachment=None): self.kind = kind self.attachment = attachment + def get_attach_url(self,): + return "#" + + def get_delete_url(self,): + return "#" + class ProposalAttachments: """ A utility class that provides most functions related to a proposal's @@ -129,18 +154,21 @@ def __init__(self, proposal): self.proposal = proposal self.proposal_kinds = self.walk_proposal() self.study_kinds = self.walk_all_studies() + self.match_slots() - def match_proposal(self): + def match_slots(self,): + self.proposal_slots = [] for kind in self.proposal_kinds: - if kind.match(att): - return kind(att) - raise RuntimeError( - "Couldn't match attachment to kind", - ) + self.proposal_slots += kind.get_slots() + self.study_slots = {} + for study, kinds in self.study_kinds: + self.study_slots[study] = [] + for kind in kinds: + self.study_slots[study] += kind.get_slots() def walk_proposal(self): kinds = [] - for kind in self.proposal_kinds: + for kind in PROPOSAL_ATTACHMENTS: kinds.append( kind(self.proposal), ) @@ -154,7 +182,7 @@ def walk_all_studies(self): def walk_study(self, study): kinds = [] - for kind in self.study_kinds: + for kind in STUDY_ATTACHMENTS: kinds.append( kind(study), ) diff --git a/attachments/migrations/0001_initial.py b/attachments/migrations/0001_initial.py new file mode 100644 index 000000000..5f86bb4a0 --- /dev/null +++ b/attachments/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.11 on 2024-09-23 16:33 + +import attachments.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("studies", "0028_remove_study_sessions_number"), + ("proposals", "0053_auto_20240201_1557"), + ] + + operations = [ + migrations.CreateModel( + name="StudyAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "attached_to", + models.ManyToManyField( + related_name="attachments", to="studies.study" + ), + ), + ], + bases=(attachments.models.Attachment, models.Model), + ), + migrations.CreateModel( + name="ProposalAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "attached_to", + models.ManyToManyField( + related_name="attachments", to="proposals.proposal" + ), + ), + ], + bases=(attachments.models.Attachment, models.Model), + ), + ] diff --git a/attachments/migrations/0002_remove_proposalattachment_id_and_more.py b/attachments/migrations/0002_remove_proposalattachment_id_and_more.py new file mode 100644 index 000000000..1aa74b30e --- /dev/null +++ b/attachments/migrations/0002_remove_proposalattachment_id_and_more.py @@ -0,0 +1,115 @@ +# Generated by Django 4.2.11 on 2024-09-23 16:46 + +import cdh.files.db.fields +from django.db import migrations, models +import django.db.models.deletion +import main.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0004_auto_20210921_1014"), + ("attachments", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="proposalattachment", + name="id", + ), + migrations.RemoveField( + model_name="studyattachment", + name="id", + ), + migrations.CreateModel( + name="Attachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "kind", + models.CharField( + choices=[ + ("dmp", "Data Management Plan"), + ("other", "Overige bestanden"), + ("information_letter", "Informatiebrief"), + ("consent_form", "Toestemmingsverklaring"), + ], + default=("", "Gelieve selecteren"), + max_length=100, + ), + ), + ( + "name", + models.CharField( + default="", + help_text="Geef je bestand een omschrijvende naam, het liefst maar enkele woorden.", + max_length=50, + ), + ), + ( + "comments", + models.TextField( + default="", + help_text="Geef hier je motivatie om dit bestand toe te voegen en waar je het voor gaat gebruiken tijdens je onderzoek. Eventuele opmerkingen voor de FETC kun je hier ook kwijt.", + max_length=2000, + ), + ), + ( + "parent", + models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="children", + to="attachments.attachment", + ), + ), + ( + "upload", + cdh.files.db.fields.FileField( + filename_generator=cdh.files.db.fields._default_filename_generator, + on_delete=django.db.models.deletion.CASCADE, + to="files.file", + ), + ), + ], + bases=(models.Model, main.utils.renderable), + ), + migrations.AddField( + model_name="proposalattachment", + name="attachment_ptr", + field=models.OneToOneField( + auto_created=True, + default=999, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="attachments.attachment", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="studyattachment", + name="attachment_ptr", + field=models.OneToOneField( + auto_created=True, + default=999, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="attachments.attachment", + ), + preserve_default=False, + ), + ] diff --git a/attachments/models.py b/attachments/models.py index bc84ea3d0..ac428d10d 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -1,13 +1,16 @@ from django.db import models from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ +from main.utils import renderable from .kinds import ATTACHMENT_CHOICES from cdh.files.db import FileField as CDHFileField # Create your models here. -class Attachment(): +class Attachment(models.Model, renderable): + + template_name = "attachment/attachment_model.html" upload = CDHFileField() parent = models.ForeignKey( "attachments.attachment", @@ -39,13 +42,13 @@ class Attachment(): ) ) -class ProposalAttachment(Attachment, models.Model): +class ProposalAttachment(Attachment,): attached_to = models.ManyToManyField( "proposals.Proposal", related_name="attachments", ) -class StudyAttachment(Attachment, models.Model): +class StudyAttachment(Attachment,): attached_to = models.ManyToManyField( "studies.Study", related_name="attachments", diff --git a/attachments/static/attachments/attachments.css b/attachments/static/attachments/attachments.css new file mode 100644 index 000000000..62355aa59 --- /dev/null +++ b/attachments/static/attachments/attachments.css @@ -0,0 +1,11 @@ +.attachment-outer { + border: 1px solid #cccccc; + background-color: #eeeeee; +} + +.attachment-upper-border { + background-color: #ccc; + font-size: smaller; + padding: 0.2rem; + padding-left: 0.5rem; +} diff --git a/attachments/templates/attachments/base_single_attachment.html b/attachments/templates/attachments/base_single_attachment.html new file mode 100644 index 000000000..3a3548ae8 --- /dev/null +++ b/attachments/templates/attachments/base_single_attachment.html @@ -0,0 +1,21 @@ + + +
+
+
+ {{ac.get_origin_display}} asdf +
+
+

+ Attachment kind +

+
attachment_filename.docx
+
+ {% for action in container.get_actions %} + {% include action %} + {% endfor %} +
+
+
+
+ diff --git a/attachments/templates/attachments/slot.html b/attachments/templates/attachments/slot.html new file mode 100644 index 000000000..5a6d5c6a9 --- /dev/null +++ b/attachments/templates/attachments/slot.html @@ -0,0 +1,12 @@ +
+ +
+ {{ slot.desiredness }} +
+
+ {% include slot.attachment %} +
+
+ Voeg toe +
+
diff --git a/attachments/utils.py b/attachments/utils.py new file mode 100644 index 000000000..ab82109d2 --- /dev/null +++ b/attachments/utils.py @@ -0,0 +1,135 @@ +from django.template.loader import get_template +from django.utils.translation import gettext as _ + +from attachments.kinds import ATTACHMENTS +from attachments.models import Attachment + +class Renderable: + + template_name = None + + def get_template_name(self,): + return self.template_name + + def get_context_data(self,): + return {} + + def render(self, context): + context = context.flatten() + context.update(self.get_context_data()) + template = get_template(self.get_template_name()) + return template.render(context) + +class AttachmentContainer( + Renderable +): + outer_css_classes = [] + template_name = "attachments/base_single_attachment.html" + + def __init__(self, attachment, proposal=None): + self.proposal = proposal + self.attachment = attachment + + def get_outer_css_classes(self,): + classes = self.outer_css_classes + if self.is_active: + classes += ["attachment-active"] + return " ".join(classes) + + def get_context_data(self): + return { + "ac": self, + "proposal": self.proposal, + } + + @property + def is_from_previous_proposal(self,): + if not self.proposal.parent: + return False + pp = self.proposal.parent + return self.attachment in pp.attachments_set.all() + + @property + def is_revised_file(self,): + if not all( + self.attachment.parent, + self.proposal.parent, + ): + return False + pa = self.attachment.parent + pp = self.proposal.parent + return pa in pp.attachments_set.all() + + @property + def is_brand_new(self,): + if self.attachment.parent: + return False + return True + + def get_origin_display(self): + if not self.attachment.upload: + return _("Nog toe te voegen") + if self.is_from_previous_proposal: + return _("Van vorige revisie") + if self.is_revised_file: + return _("Nieuwe versie") + if self.is_brand_new: + return _("Nieuw aangeleverd bestand") + return _("Herkomst onbekend") + + @property + def is_active(self): + if not self.proposal: + return True + return self.proposal in self.attachment.attached_to.all() + + +class ProposalAttachments(): + + def __init__(self, proposal): + + # Setup + self.proposal = proposal + self.available_kinds = ATTACHMENTS + + # Populate lists + self.all_attachments = self._populate() + + def _populate(self,): + # These are the Attachments that actually exist + self.provided = self._fetch() + # These are the Attachments that are still required + # (we create placeholders for them) + self.complement = self._complement_all() + return self.provided + self.complement + + def _fetch(self,): + qs = self.proposal.attachments_set.all() + return list(qs) + + def _provided_of_kind(self, kind,): + out = filter( + lambda a: a.kind == kind.db_name, + self.provided, + ) + return list(out) + + def _complement_of_kind(self, kind,): + required = kind.num_required(self.proposal) + provided = len(self._provided_of_kind(kind)) + for i in range(required - provided): + new_attachment = Attachment() + new_attachment.kind = kind.db_name + self.complement.append(new_attachment) + + def _complement_all(self,): + self.complement = [] + for kind in self.available_kinds: + self._complement_of_kind(kind) + return self.complement + + def as_containers(self,): + return [ + AttachmentContainer(a, proposal=self.propsal) + for a in self.all_attachments + ] diff --git a/attachments/views.py b/attachments/views.py new file mode 100644 index 000000000..8261de1e5 --- /dev/null +++ b/attachments/views.py @@ -0,0 +1,77 @@ +from django.views import generic +from django.forms import forms, ModelForm +from django.core.exceptions import ImproperlyConfigured +from django.urls import reverse +from .models import Attachment + +# Create your views here. + +class AttachmentForm(ModelForm): + + class Meta: + model = Attachment + fields = [ + "kind", + "upload", + "name", + "comments", + ] + + def __init__(self, kind=None, *args, **kwargs): + self.kind = kind + return super().__init__(*args, **kwargs) + + def get_initial(self, *args, **kwargs): + initial = super().get_initial(*args, **kwargs) + if self.kind: + initial["kind"] = self.kind + return initial + +class AttachmentCreateView(generic.CreateView): + """Generic create view to create a new attachment. Both other_model and + template_name should be provided externally, either through the URL + definition or through subclassing.""" + + model = None + form_class = None + template_name = None + + other_model = None + other_field_name = "attachments" + other_pk_kwarg = "other_pk" + + def __init__(self, *args, **kwargs): + return super().__init__(*args, **kwargs) + + def save(self, form): + result = super().save(form) + self.attach(form.instance) + return result + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + if "kind" in self.kwargs.keys(): + kwargs["kind"] = self.kwargs.get("kind") + return kwargs + + def attach(self, attachment): + if self.other_model is None: + raise ImproperlyConfigured( + "Please provide an other_model as a target for " + "this attachment." + ) + other_pk = self.kwargs.get(self.other_pk_kwarg) + other_object = self.other_model.objects.get( + pk=other_pk + ) + manager = getattr(other_object, self.other_field_name) + manager.add(attachment) + other_object.save() + + def get_success_url(self): + return reverse( + "proposals:attachments", + kwargs={ + "pk": self.kwargs.get(self.other_pk_kwarg), + } + ) diff --git a/proposals/templates/proposals/attachments.html b/proposals/templates/proposals/attachments.html new file mode 100644 index 000000000..5a8c5f071 --- /dev/null +++ b/proposals/templates/proposals/attachments.html @@ -0,0 +1,23 @@ +{% extends "base/fetc_form_base.html" %} + +{% load static %} +{% load i18n %} + +{% block header_title %} +{% trans "Informatie over betrokken onderzoekers" %} - {{ block.super }} +{% endblock %} + + +{% block pre-form-text %} +

{% trans "Documenten" %}

+

{% trans "Algemeen" %}

+{% for study, slots in study_slots %} +

{% trans "Traject " %} {{ study.order }} {% if study.name %}: {{ study.name }} {% endif %}

+ {% for slot in slots %} + {% include slot %} + {% endfor %} +{% endfor %} +

{% trans "Overig" %}

+{% block auto-form-render %} +{% endblock %} +{% endblock %} diff --git a/proposals/urls.py b/proposals/urls.py index 60397e078..c36cf4886 100644 --- a/proposals/urls.py +++ b/proposals/urls.py @@ -17,6 +17,7 @@ ProposalFundingFormView, ProposalResearchGoalFormView, ProposalPreApprovedFormView, + ProposalAttachmentsView, ProposalDataManagement, ProposalSubmit, ProposalSubmitted, @@ -165,6 +166,11 @@ ProposalPreApprovedFormView.as_view(), name="pre_approved", ), + path( + "attachments//", + ProposalAttachmentsView.as_view(), + name="attachments", + ), path( "data_management//", ProposalDataManagement.as_view(), diff --git a/proposals/views/proposal_views.py b/proposals/views/proposal_views.py index c528c93b4..abd148d55 100644 --- a/proposals/views/proposal_views.py +++ b/proposals/views/proposal_views.py @@ -58,6 +58,8 @@ ) from proposals.utils.proposal_utils import FilenameFactory +from attachments.kinds import ProposalAttachments + ############ # List views @@ -374,6 +376,27 @@ def _get_files(self) -> Tuple[Union[None, FieldFile], Union[None, FieldFile]]: return getattr(old, attribute, None), getattr(new, attribute, None) +class ProposalAttachmentsView( + ProposalContextMixin, + generic.DetailView, +): + + template_name = "proposals/attachments.html" + model = Proposal + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + proposal = self.get_object() + + manager = ProposalAttachments( + self.get_proposal(), + ) + context["manager"] = manager + + context["study_slots"] = manager.study_slots + + return context + ########################### # Other actions on Proposal ########################### From 0662e3dd535d6b6b60f0bdf69fa18f8d1bde2190 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 23 Sep 2024 19:04:32 +0200 Subject: [PATCH 03/51] fix: Mishmash of migrations --- attachments/migrations/0001_initial.py | 87 +++++++++++-- ...2_remove_proposalattachment_id_and_more.py | 115 ------------------ 2 files changed, 78 insertions(+), 124 deletions(-) delete mode 100644 attachments/migrations/0002_remove_proposalattachment_id_and_more.py diff --git a/attachments/migrations/0001_initial.py b/attachments/migrations/0001_initial.py index 5f86bb4a0..faf3f8f46 100644 --- a/attachments/migrations/0001_initial.py +++ b/attachments/migrations/0001_initial.py @@ -1,7 +1,9 @@ -# Generated by Django 4.2.11 on 2024-09-23 16:33 +# Generated by Django 4.2.11 on 2024-09-23 17:00 -import attachments.models +import cdh.files.db.fields from django.db import migrations, models +import django.db.models.deletion +import main.utils class Migration(migrations.Migration): @@ -9,13 +11,14 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("studies", "0028_remove_study_sessions_number"), ("proposals", "0053_auto_20240201_1557"), + ("files", "0004_auto_20210921_1014"), + ("studies", "0028_remove_study_sessions_number"), ] operations = [ migrations.CreateModel( - name="StudyAttachment", + name="Attachment", fields=[ ( "id", @@ -26,6 +29,70 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), + ( + "kind", + models.CharField( + choices=[ + ("dmp", "Data Management Plan"), + ("other", "Overige bestanden"), + ("information_letter", "Informatiebrief"), + ("consent_form", "Toestemmingsverklaring"), + ], + default=("", "Gelieve selecteren"), + max_length=100, + ), + ), + ( + "name", + models.CharField( + default="", + help_text="Geef je bestand een omschrijvende naam, het liefst maar enkele woorden.", + max_length=50, + ), + ), + ( + "comments", + models.TextField( + default="", + help_text="Geef hier je motivatie om dit bestand toe te voegen en waar je het voor gaat gebruiken tijdens je onderzoek. Eventuele opmerkingen voor de FETC kun je hier ook kwijt.", + max_length=2000, + ), + ), + ( + "parent", + models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="children", + to="attachments.attachment", + ), + ), + ( + "upload", + cdh.files.db.fields.FileField( + filename_generator=cdh.files.db.fields._default_filename_generator, + on_delete=django.db.models.deletion.CASCADE, + to="files.file", + ), + ), + ], + bases=(models.Model, main.utils.renderable), + ), + migrations.CreateModel( + name="StudyAttachment", + fields=[ + ( + "attachment_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="attachments.attachment", + ), + ), ( "attached_to", models.ManyToManyField( @@ -33,18 +100,20 @@ class Migration(migrations.Migration): ), ), ], - bases=(attachments.models.Attachment, models.Model), + bases=("attachments.attachment",), ), migrations.CreateModel( name="ProposalAttachment", fields=[ ( - "id", - models.BigAutoField( + "attachment_ptr", + models.OneToOneField( auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, - verbose_name="ID", + to="attachments.attachment", ), ), ( @@ -54,6 +123,6 @@ class Migration(migrations.Migration): ), ), ], - bases=(attachments.models.Attachment, models.Model), + bases=("attachments.attachment",), ), ] diff --git a/attachments/migrations/0002_remove_proposalattachment_id_and_more.py b/attachments/migrations/0002_remove_proposalattachment_id_and_more.py deleted file mode 100644 index 1aa74b30e..000000000 --- a/attachments/migrations/0002_remove_proposalattachment_id_and_more.py +++ /dev/null @@ -1,115 +0,0 @@ -# Generated by Django 4.2.11 on 2024-09-23 16:46 - -import cdh.files.db.fields -from django.db import migrations, models -import django.db.models.deletion -import main.utils - - -class Migration(migrations.Migration): - - dependencies = [ - ("files", "0004_auto_20210921_1014"), - ("attachments", "0001_initial"), - ] - - operations = [ - migrations.RemoveField( - model_name="proposalattachment", - name="id", - ), - migrations.RemoveField( - model_name="studyattachment", - name="id", - ), - migrations.CreateModel( - name="Attachment", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "kind", - models.CharField( - choices=[ - ("dmp", "Data Management Plan"), - ("other", "Overige bestanden"), - ("information_letter", "Informatiebrief"), - ("consent_form", "Toestemmingsverklaring"), - ], - default=("", "Gelieve selecteren"), - max_length=100, - ), - ), - ( - "name", - models.CharField( - default="", - help_text="Geef je bestand een omschrijvende naam, het liefst maar enkele woorden.", - max_length=50, - ), - ), - ( - "comments", - models.TextField( - default="", - help_text="Geef hier je motivatie om dit bestand toe te voegen en waar je het voor gaat gebruiken tijdens je onderzoek. Eventuele opmerkingen voor de FETC kun je hier ook kwijt.", - max_length=2000, - ), - ), - ( - "parent", - models.ForeignKey( - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="children", - to="attachments.attachment", - ), - ), - ( - "upload", - cdh.files.db.fields.FileField( - filename_generator=cdh.files.db.fields._default_filename_generator, - on_delete=django.db.models.deletion.CASCADE, - to="files.file", - ), - ), - ], - bases=(models.Model, main.utils.renderable), - ), - migrations.AddField( - model_name="proposalattachment", - name="attachment_ptr", - field=models.OneToOneField( - auto_created=True, - default=999, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="attachments.attachment", - ), - preserve_default=False, - ), - migrations.AddField( - model_name="studyattachment", - name="attachment_ptr", - field=models.OneToOneField( - auto_created=True, - default=999, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="attachments.attachment", - ), - preserve_default=False, - ), - ] From 83c899a1b7ea255b1c6f0d5afb08246768c5ae30 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 23 Sep 2024 19:05:02 +0200 Subject: [PATCH 04/51] wip: Various fixes for attachments --- attachments/kinds.py | 8 ++++---- proposals/templates/proposals/attachments.html | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/attachments/kinds.py b/attachments/kinds.py index 67080f405..816f998c9 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -20,9 +20,9 @@ def __init__(self, obj): def get_slots(self): slots = [] for inst in self.get_instances_for_object(): - slots.append(AttachmentSlot(self, inst,)) + slots.append(AttachmentSlot(self, attachment=inst,)) for i in range(self.still_required()): - slots.append(AttachmentSlot(self, inst,)) + slots.append(AttachmentSlot(self,)) return slots def get_instances_for_object(self): @@ -36,7 +36,7 @@ def num_suggested(self): return 0 def num_provided(self): - return self.get_instances_for_proposal().count() + return self.get_instances_for_object().count() def still_required(self): return self.num_required() - self.num_provided() @@ -161,7 +161,7 @@ def match_slots(self,): for kind in self.proposal_kinds: self.proposal_slots += kind.get_slots() self.study_slots = {} - for study, kinds in self.study_kinds: + for study, kinds in self.study_kinds.items(): self.study_slots[study] = [] for kind in kinds: self.study_slots[study] += kind.get_slots() diff --git a/proposals/templates/proposals/attachments.html b/proposals/templates/proposals/attachments.html index 5a8c5f071..b7a7feeb2 100644 --- a/proposals/templates/proposals/attachments.html +++ b/proposals/templates/proposals/attachments.html @@ -11,7 +11,7 @@ {% block pre-form-text %}

{% trans "Documenten" %}

{% trans "Algemeen" %}

-{% for study, slots in study_slots %} +{% for study, slots in study_slots.items %}

{% trans "Traject " %} {{ study.order }} {% if study.name %}: {{ study.name }} {% endif %}

{% for slot in slots %} {% include slot %} From 8bd9e1ee0b297669b7a60fa866cca95a049bbcbf Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 24 Sep 2024 11:13:36 +0200 Subject: [PATCH 05/51] feat: Attachment renderable templates & updates --- attachments/kinds.py | 6 ++++ .../attachments/attachment_model.html | 1 + attachments/templates/attachments/slot.html | 28 +++++++++++++++---- main/utils.py | 3 +- 4 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 attachments/templates/attachments/attachment_model.html diff --git a/attachments/kinds.py b/attachments/kinds.py index 816f998c9..a7fbf6059 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -129,6 +129,12 @@ class AttachmentSlot(renderable): def __init__(self, kind, attachment=None): self.kind = kind self.attachment = attachment + self.desiredness = _("Verplicht") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["slot"] = self + return context def get_attach_url(self,): return "#" diff --git a/attachments/templates/attachments/attachment_model.html b/attachments/templates/attachments/attachment_model.html new file mode 100644 index 000000000..dd19e5318 --- /dev/null +++ b/attachments/templates/attachments/attachment_model.html @@ -0,0 +1 @@ +Filename goes here diff --git a/attachments/templates/attachments/slot.html b/attachments/templates/attachments/slot.html index 5a6d5c6a9..b686fac25 100644 --- a/attachments/templates/attachments/slot.html +++ b/attachments/templates/attachments/slot.html @@ -1,12 +1,28 @@ -
+{% load i18n %} -
+
+ +
{{ slot.desiredness }}
-
- {% include slot.attachment %} +
+ Bestand: + {% if slot.attachment %} + {% include slot.attachment %} + {% else %} + Nog toe te voegen + {% endif %} + {{ kind.reason }}
-
- Voeg toe +
+ {% if slot.attachment %} + + {% trans "Bewerk" %} + + {% else %} + + {% trans "Voeg toe" %} + + {% endif %}
diff --git a/main/utils.py b/main/utils.py index 763f4714c..55a87ecbf 100644 --- a/main/utils.py +++ b/main/utils.py @@ -167,8 +167,9 @@ def can_view_archive(user): class renderable: - def get_context_data(self): + def get_context_data(self, **kwargs): context = Context() + context.update(kwargs) return context def render(self, extra_context={}): From e87b9ba9016a593d8809401e1eda7eff0b55f231 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 1 Oct 2024 13:45:16 +0200 Subject: [PATCH 06/51] feat: Change var name and minor changes --- attachments/kinds.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/attachments/kinds.py b/attachments/kinds.py index a7fbf6059..73f403faf 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -15,7 +15,7 @@ class AttachmentKind: attached_field = "attachments" def __init__(self, obj): - self.object = obj + self.owner = obj def get_slots(self): slots = [] @@ -26,7 +26,7 @@ def get_slots(self): return slots def get_instances_for_object(self): - manager = getattr(self.object, self.attached_field) + manager = getattr(self.owner, self.attached_field) return manager.filter(kind=self.db_name) def num_required(self): @@ -53,7 +53,7 @@ def test_recommended(self): def get_attach_url(self): url_kwargs = { - "other_pk": self.object.pk, + "other_pk": self.owner.pk, "kind": self.db_name, } return reverse("proposals:attach_file", kwargs=url_kwargs) @@ -122,6 +122,13 @@ def num_suggested(self): (a.db_name, a.name) for a in ATTACHMENTS ] +def get_kind_from_str(db_name): + kinds = { + kind.db_name: kind + for kind in ATTACHMENTS + } + return kinds[db_name] + class AttachmentSlot(renderable): template_name = "attachments/slot.html" @@ -137,23 +144,13 @@ def get_context_data(self, **kwargs): return context def get_attach_url(self,): - return "#" + return self.kind.get_attach_url() def get_delete_url(self,): return "#" class ProposalAttachments: """ - A utility class that provides most functions related to a proposal's - attachments. The algorithm with which required attachments are determined - is as follows: - - 1. Collect all existing attachments for proposal and studies - 2. Match all existing attachments to kinds - 3. Complement existing attachments with additional kind instances to - represent yet to be fulfilled requirements - - This happens for the proposal as a whole and each of its studies. """ def __init__(self, proposal): From 52326424f38a4208cf3e584314759c9a40b2caa1 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 1 Oct 2024 13:46:31 +0200 Subject: [PATCH 07/51] feat: Verbose name for Attachment.upload --- attachments/models.py | 5 ++++- attachments/utils.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/attachments/models.py b/attachments/models.py index ac428d10d..4cf0851f8 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -11,7 +11,10 @@ class Attachment(models.Model, renderable): template_name = "attachment/attachment_model.html" - upload = CDHFileField() + upload = CDHFileField( + verbose_name=_("Bestand"), + help_text=_("Selecteer hier het bestand om toe te voegen."), + ) parent = models.ForeignKey( "attachments.attachment", related_name="children", diff --git a/attachments/utils.py b/attachments/utils.py index ab82109d2..e30f86a15 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -104,7 +104,7 @@ def _populate(self,): return self.provided + self.complement def _fetch(self,): - qs = self.proposal.attachments_set.all() + qs = self.proposal.attachments.all() return list(qs) def _provided_of_kind(self, kind,): From 18336212be3db33d9af9cbc39345cbd119fe55f2 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 1 Oct 2024 13:47:04 +0200 Subject: [PATCH 08/51] feat: Attachment views --- attachments/views.py | 7 +- proposals/urls.py | 18 +++- proposals/views/attachment_views.py | 143 +++++++++++++++++++++++++++- proposals/views/proposal_views.py | 21 ---- 4 files changed, 158 insertions(+), 31 deletions(-) diff --git a/attachments/views.py b/attachments/views.py index 8261de1e5..e36bc1d11 100644 --- a/attachments/views.py +++ b/attachments/views.py @@ -69,9 +69,6 @@ def attach(self, attachment): other_object.save() def get_success_url(self): - return reverse( - "proposals:attachments", - kwargs={ - "pk": self.kwargs.get(self.other_pk_kwarg), - } + raise ImproperlyConfigured( + "Please define get_success_url()" ) diff --git a/proposals/urls.py b/proposals/urls.py index 2a912f22a..541c84205 100644 --- a/proposals/urls.py +++ b/proposals/urls.py @@ -17,7 +17,6 @@ ProposalFundingFormView, ProposalResearchGoalFormView, ProposalPreApprovedFormView, - ProposalAttachmentsView, ProposalDataManagement, ProposalSubmit, ProposalSubmitted, @@ -47,6 +46,13 @@ ProposalUpdateDateStart, ) +from .views.attachment_views import ( + ProposalAttachView, + ProposalAttachmentsView, + ProposalUpdateAttachmentView +) + + from .views.study_views import StudyStart, StudyConsent from .views.wmo_views import ( WmoCreate, @@ -170,6 +176,16 @@ ProposalAttachmentsView.as_view(), name="attachments", ), + path( + "attach///", + ProposalAttachView.as_view(), + name="attach_file", + ), + path( + "attachment//edit/", + ProposalUpdateAttachmentView.as_view(), + name="update_attachment", + ), path( "data_management//", ProposalDataManagement.as_view(), diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index 84737575a..f89119c13 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -1,10 +1,145 @@ from django.views import generic +from django import forms +from django.urls import reverse +from proposals.mixins import ProposalContextMixin +from proposals.models import Proposal +from studies.models import Study +from attachments.kinds import ProposalAttachments, get_kind_from_str +from attachments.models import Attachment, ProposalAttachment, StudyAttachment +from main.forms import ConditionalModelForm +from cdh.core import forms as cdh_forms -class ProposalAttachments(generic.TemplateView): - template_name = "proposals/attachments/proposal_attachments.html" +class AttachForm( + cdh_forms.TemplatedModelForm, +): - def get_context_data(self, *args, **kwargs): - context = super().get_context_data(*args, **kwargs) + class Meta: + model = Attachment + fields = [ + "upload", + "name", + "comments", + ] + + def __init__(self, kind=None, other_object=None, **kwargs): + self.kind = kind + self.other_object = other_object + # Set the correct model based on other_object + if type(other_object) is Proposal: + self._meta.model = ProposalAttachment + elif type(other_object) is Study: + self._meta.model = StudyAttachment + return super().__init__(**kwargs) + + def save(self,): + self.instance.kind = self.kind.db_name + self.instance.save() + self.instance.attached_to.add( + self.other_object, + ) + return super().save() + + +class ProposalAttachView( + ProposalContextMixin, + generic.CreateView, +): + + model = Attachment + form_class = AttachForm + template_name = "proposals/attach_form.html" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def set_upload_field_label(self, form): + upload_field = form.fields["upload"] + kind = self.get_kind() + upload_field.label += f" ({kind.name})" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["proposal"] = self.get_proposal() + owner_object = self.get_owner_object() + if type(owner_object) is not Proposal: + context["study"] = self.get_owner_object() + context["kind"] = self.get_kind() + context["kind_name"] = self.get_kind().name + form = context["form"] + self.set_upload_field_label(form) + return context + + def get_proposal(self): + obj = self.get_owner_object() + if type(obj) is Proposal: + return obj + else: + return obj.proposal + + def get_owner_object(self): + owner_model = self.get_kind().attached_object + return owner_model.objects.get(pk=self.kwargs.get("other_pk")) + + def get_kind(self): + kind_str = self.kwargs.get("kind") + return get_kind_from_str(kind_str) + + def get_success_url(self,): + return reverse( + "proposals:attachments", + kwargs={"pk": self.get_proposal().pk}, + ) + + def get_form_kwargs(self,): + kwargs = super().get_form_kwargs() + kwargs.update({ + "kind": self.get_kind(), + "other_object": self.get_owner_object(), + }) + return kwargs + +class ProposalUpdateAttachmentView( + ProposalContextMixin, + generic.UpdateView, +): + model = Attachment + form_class = AttachForm + template_name = "proposals/attach_form.html" + + def get_proposal(self): + obj = self.get_owner_object() + if type(obj) is Proposal: + return obj + else: + return obj.proposal + + def get_owner_object(self): + owner_model = self.get_kind().attached_object + return owner_model.objects.get(pk=self.kwargs.get("other_pk")) + + def get_kind(self): + kind_str = self.kwargs.get("kind") + return get_kind_from_str(kind_str) + + + +class ProposalAttachmentsView( + ProposalContextMixin, + generic.DetailView, +): + + template_name = "proposals/attachments.html" + model = Proposal + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + manager = ProposalAttachments( + self.get_proposal(), + ) + context["manager"] = manager + + context["study_slots"] = manager.study_slots return context diff --git a/proposals/views/proposal_views.py b/proposals/views/proposal_views.py index abd148d55..7e0e72d9d 100644 --- a/proposals/views/proposal_views.py +++ b/proposals/views/proposal_views.py @@ -376,27 +376,6 @@ def _get_files(self) -> Tuple[Union[None, FieldFile], Union[None, FieldFile]]: return getattr(old, attribute, None), getattr(new, attribute, None) -class ProposalAttachmentsView( - ProposalContextMixin, - generic.DetailView, -): - - template_name = "proposals/attachments.html" - model = Proposal - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - proposal = self.get_object() - - manager = ProposalAttachments( - self.get_proposal(), - ) - context["manager"] = manager - - context["study_slots"] = manager.study_slots - - return context - ########################### # Other actions on Proposal ########################### From c1039b2d7e16b8d360fa81fb1b5d7f2fa190623e Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 1 Oct 2024 13:47:14 +0200 Subject: [PATCH 09/51] feat: Attachment form template --- .../templates/proposals/attach_form.html | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 proposals/templates/proposals/attach_form.html diff --git a/proposals/templates/proposals/attach_form.html b/proposals/templates/proposals/attach_form.html new file mode 100644 index 000000000..65d3f3884 --- /dev/null +++ b/proposals/templates/proposals/attach_form.html @@ -0,0 +1,47 @@ +{% extends "base/fetc_form_base.html" %} +{% load i18n %} +{% load static %} + +{% block header_title %} + {% trans "Voeg een bestand toe" %} - {{ block.super }} +{% endblock %} + +{% block pre-form-text %} +

{% trans "Maak of wijzig een bestand" %}

+

+ {% blocktrans trimmed %} + Je voegt een bestand toe aan het volgende: + {% endblocktrans %} +

+

+ {% if study %} + {% blocktrans with order=study.order name=study.name trimmed %} + Traject {{order}}: {{name}} van + {% endblocktrans %} + {% endif %} + {% trans "de aanvraag" %} {{ proposal.title }}. +

+ {% blocktrans with name=kind_name trimmed %} + Je gaat hier een {{name}} aan toevoegen. Wil je een ander soort bestand toevoegen? Ga dan terug naar de vorige pagina. + {% endblocktrans %} + + +

+{% endblock %} + +{% block form-buttons %} + + + +{% endblock %} From 4767f4cf9afd0522feda6611bbe61748e61d8fc9 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 1 Oct 2024 15:42:15 +0200 Subject: [PATCH 10/51] wip: Minor changes --- attachments/templates/attachments/slot.html | 10 +++++----- proposals/views/attachment_views.py | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/attachments/templates/attachments/slot.html b/attachments/templates/attachments/slot.html index b686fac25..cfe9ed837 100644 --- a/attachments/templates/attachments/slot.html +++ b/attachments/templates/attachments/slot.html @@ -1,13 +1,13 @@ {% load i18n %} -
+
-
+
{{ slot.desiredness }}
-
- Bestand: +
{% if slot.attachment %} +

{{slot.kind.name}}

{% include slot.attachment %} {% else %} Nog toe te voegen @@ -17,7 +17,7 @@
{% if slot.attachment %} - {% trans "Bewerk" %} + {% trans "Wijzig" %} {% else %} diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index f89119c13..c95136f60 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -54,6 +54,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def set_upload_field_label(self, form): + # Remind the user of what they're uploading upload_field = form.fields["upload"] kind = self.get_kind() upload_field.label += f" ({kind.name})" From 2e9474ecde03c85bbe7e1479f5c131f866cd656c Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 1 Oct 2024 15:43:26 +0200 Subject: [PATCH 11/51] feat: Add author field to Attachment --- ...tachment_author_alter_attachment_upload.py | 40 +++++++++++++++++++ attachments/models.py | 14 ++++++- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 attachments/migrations/0002_attachment_author_alter_attachment_upload.py diff --git a/attachments/migrations/0002_attachment_author_alter_attachment_upload.py b/attachments/migrations/0002_attachment_author_alter_attachment_upload.py new file mode 100644 index 000000000..38553fb37 --- /dev/null +++ b/attachments/migrations/0002_attachment_author_alter_attachment_upload.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.11 on 2024-10-01 13:43 + +import cdh.files.db.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("files", "0004_auto_20210921_1014"), + ("attachments", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="attachment", + name="author", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_attachments", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="attachment", + name="upload", + field=cdh.files.db.fields.FileField( + filename_generator=cdh.files.db.fields._default_filename_generator, + help_text="Selecteer hier het bestand om toe te voegen.", + on_delete=django.db.models.deletion.CASCADE, + to="files.file", + verbose_name="Bestand", + ), + ), + ] diff --git a/attachments/models.py b/attachments/models.py index 4cf0851f8..23a7c90b4 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -10,7 +10,14 @@ class Attachment(models.Model, renderable): - template_name = "attachment/attachment_model.html" + template_name = "attachments/attachment_model.html" + author = models.ForeignKey( + get_user_model(), + related_name="created_attachments", + null=True, + on_delete=models.SET_NULL, + default=None, + ) upload = CDHFileField( verbose_name=_("Bestand"), help_text=_("Selecteer hier het bestand om toe te voegen."), @@ -45,6 +52,11 @@ class Attachment(models.Model, renderable): ) ) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["attachment"] = self + return context + class ProposalAttachment(Attachment,): attached_to = models.ManyToManyField( "proposals.Proposal", From c88c247d47a6aae08006975e09c6fa8a7515b4ad Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 1 Oct 2024 15:44:01 +0200 Subject: [PATCH 12/51] feat: Classmethod to initialize kind from an attachment --- attachments/kinds.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/attachments/kinds.py b/attachments/kinds.py index 73f403faf..92c1bb591 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -14,8 +14,20 @@ class AttachmentKind: max_num = None attached_field = "attachments" - def __init__(self, obj): - self.owner = obj + def __init__(self, owner=None): + self.owner = owner + + @classmethod + def from_proposal(cls, proposal, attachment): + kind = get_kind_from_str(attachment.kind) + if kind.model is Proposal: + return kind(owner=proposal) + # This might be more efficient in a QS + for study in proposal.studies: + if attachment in study.attachments: + return kind(owner=study) + msg = f"Attachment {attachment.pk} not found for proposal {proposal}" + raise KeyError(msg) def get_slots(self): slots = [] From d58ff27d5fa86dddd8326f068e061159e4f98875 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Wed, 2 Oct 2024 16:20:15 +0200 Subject: [PATCH 13/51] feat: Provide manager object to children --- attachments/kinds.py | 13 +++++++------ .../templates/attachments/attachment_model.html | 6 +++++- attachments/templates/attachments/slot.html | 2 +- proposals/templates/proposals/attachments.html | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/attachments/kinds.py b/attachments/kinds.py index 92c1bb591..622125cc0 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -29,12 +29,12 @@ def from_proposal(cls, proposal, attachment): msg = f"Attachment {attachment.pk} not found for proposal {proposal}" raise KeyError(msg) - def get_slots(self): + def get_slots(self, manager=None): slots = [] for inst in self.get_instances_for_object(): - slots.append(AttachmentSlot(self, attachment=inst,)) + slots.append(AttachmentSlot(self, attachment=inst, manager=manager,)) for i in range(self.still_required()): - slots.append(AttachmentSlot(self,)) + slots.append(AttachmentSlot(self, manager=manager,)) return slots def get_instances_for_object(self): @@ -145,10 +145,11 @@ class AttachmentSlot(renderable): template_name = "attachments/slot.html" - def __init__(self, kind, attachment=None): + def __init__(self, kind, attachment=None, manager=None): self.kind = kind self.attachment = attachment self.desiredness = _("Verplicht") + self.manager = manager def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -174,12 +175,12 @@ def __init__(self, proposal): def match_slots(self,): self.proposal_slots = [] for kind in self.proposal_kinds: - self.proposal_slots += kind.get_slots() + self.proposal_slots += kind.get_slots(manager=self) self.study_slots = {} for study, kinds in self.study_kinds.items(): self.study_slots[study] = [] for kind in kinds: - self.study_slots[study] += kind.get_slots() + self.study_slots[study] += kind.get_slots(manager=self) def walk_proposal(self): kinds = [] diff --git a/attachments/templates/attachments/attachment_model.html b/attachments/templates/attachments/attachment_model.html index dd19e5318..4fd985d6d 100644 --- a/attachments/templates/attachments/attachment_model.html +++ b/attachments/templates/attachments/attachment_model.html @@ -1 +1,5 @@ -Filename goes here + + + + +{{attachment.upload.original_filename}} diff --git a/attachments/templates/attachments/slot.html b/attachments/templates/attachments/slot.html index cfe9ed837..333785484 100644 --- a/attachments/templates/attachments/slot.html +++ b/attachments/templates/attachments/slot.html @@ -8,7 +8,7 @@
{% if slot.attachment %}

{{slot.kind.name}}

- {% include slot.attachment %} + {% include slot.attachment with manager=manager %} {% else %} Nog toe te voegen {% endif %} diff --git a/proposals/templates/proposals/attachments.html b/proposals/templates/proposals/attachments.html index b7a7feeb2..1d1264e0e 100644 --- a/proposals/templates/proposals/attachments.html +++ b/proposals/templates/proposals/attachments.html @@ -14,7 +14,7 @@

{% trans "Algemeen" %}

{% for study, slots in study_slots.items %}

{% trans "Traject " %} {{ study.order }} {% if study.name %}: {{ study.name }} {% endif %}

{% for slot in slots %} - {% include slot %} + {% include slot with manager=manager %} {% endfor %} {% endfor %}

{% trans "Overig" %}

From 3454a922ea875b5f1bb5673ff7c504768bcc85d2 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Wed, 2 Oct 2024 16:22:35 +0200 Subject: [PATCH 14/51] feat: Download view for attachments --- proposals/urls.py | 17 ++++++++++-- proposals/views/attachment_views.py | 42 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/proposals/urls.py b/proposals/urls.py index 541c84205..89dd8a577 100644 --- a/proposals/urls.py +++ b/proposals/urls.py @@ -49,7 +49,8 @@ from .views.attachment_views import ( ProposalAttachView, ProposalAttachmentsView, - ProposalUpdateAttachmentView + ProposalUpdateAttachmentView, + ProposalAttachmentDownloadView, ) @@ -182,10 +183,22 @@ name="attach_file", ), path( - "attachment//edit/", + "attachments//edit//", ProposalUpdateAttachmentView.as_view(), name="update_attachment", ), + path( + "attachments//download//", + ProposalAttachmentDownloadView.as_view(), + name="download_attachment", + ), + path( + "attachments//download_original//", + ProposalAttachmentDownloadView.as_view( + original_filename=True, + ), + name="download_attachment_original", + ), path( "data_management//", ProposalDataManagement.as_view(), diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index c95136f60..bee8627c3 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -8,6 +8,8 @@ from attachments.models import Attachment, ProposalAttachment, StudyAttachment from main.forms import ConditionalModelForm from cdh.core import forms as cdh_forms +from django.http import FileResponse +from attachments.kinds import ATTACHMENTS, AttachmentKind class AttachForm( @@ -144,3 +146,43 @@ def get_context_data(self, **kwargs): context["study_slots"] = manager.study_slots return context + + +class ProposalAttachmentDownloadView( + generic.View, +): + original_filename = False + + def __init__(self, *args, **kwargs,): + self.original_filename = kwargs.pop("original_filename", False) + super().__init__(*args, **kwargs) + + def get(self, request, proposal_pk, attachment_pk,): + self.attachment = Attachment.objects.get( + pk=attachment_pk, + ) + self.proposal = Proposal.objects.get( + pk=self.kwargs.get("proposal_pk"), + ) + return self.get_file_response() + + def get_filename(self): + if self.original_filename: + return self.attachment.upload.original_filename + else: + return self.get_filename_from_kind() + + def get_filename_from_kind(self): + self.kind = AttachmentKind.from_proposal( + self.proposal, + self.attachment, + ) + return self.kind.name + + def get_file_response(self): + attachment_file = self.attachment.upload.file + return FileResponse( + attachment_file, + filename=self.get_filename(), + as_attachment=True, + ) From f04a8dcc63dfe7c1cfefaec799609255d17be0fe Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Wed, 2 Oct 2024 16:23:06 +0200 Subject: [PATCH 15/51] feat: Break out BaseAttachFormView to make attachment edit view --- attachments/kinds.py | 12 +++++ attachments/models.py | 6 +++ attachments/templates/attachments/slot.html | 2 +- attachments/views.py | 2 + proposals/views/attachment_views.py | 50 +++++++++++---------- 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/attachments/kinds.py b/attachments/kinds.py index 622125cc0..ab46db49e 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -4,6 +4,7 @@ from proposals.models import Proposal from studies.models import Study from main.utils import renderable +from attachments.models import ProposalAttachment, StudyAttachment class AttachmentKind: """Defines a kind of file attachment and when it is required.""" @@ -73,10 +74,12 @@ def get_attach_url(self): class ProposalAttachmentKind(AttachmentKind): attached_object = Proposal + attachment_class = ProposalAttachment class StudyAttachmentKind(AttachmentKind): attached_object = Study + attachment_class = StudyAttachment class InformationLetter(StudyAttachmentKind): @@ -162,6 +165,15 @@ def get_attach_url(self,): def get_delete_url(self,): return "#" + def get_edit_url(self,): + return reverse( + "proposals:update_attachment", + kwargs={ + "attachment_pk": self.attachment.pk, + "proposal_pk": self.manager.proposal.pk, + } + ) + class ProposalAttachments: """ """ diff --git a/attachments/models.py b/attachments/models.py index 23a7c90b4..6af998a5c 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -52,6 +52,12 @@ class Attachment(models.Model, renderable): ) ) + def get_correct_submodel(self): + from .kinds import get_kind_from_str + kind = get_kind_from_str(self.kind) + key = kind.attachment_class.__name__ + return getattr(self, key) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["attachment"] = self diff --git a/attachments/templates/attachments/slot.html b/attachments/templates/attachments/slot.html index 333785484..1fe840084 100644 --- a/attachments/templates/attachments/slot.html +++ b/attachments/templates/attachments/slot.html @@ -16,7 +16,7 @@

{{slot.kind.name}}

{% if slot.attachment %} - + {% trans "Wijzig" %} {% else %} diff --git a/attachments/views.py b/attachments/views.py index e36bc1d11..f1da74ee6 100644 --- a/attachments/views.py +++ b/attachments/views.py @@ -2,7 +2,9 @@ from django.forms import forms, ModelForm from django.core.exceptions import ImproperlyConfigured from django.urls import reverse +from proposals.models import Proposal from .models import Attachment +from attachments.kinds import ATTACHMENTS, AttachmentKind # Create your views here. diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index bee8627c3..ed26e9cd0 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -43,18 +43,12 @@ def save(self,): return super().save() -class ProposalAttachView( - ProposalContextMixin, - generic.CreateView, -): +class AttachFormView(): model = Attachment form_class = AttachForm template_name = "proposals/attach_form.html" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - + def set_upload_field_label(self, form): # Remind the user of what they're uploading upload_field = form.fields["upload"] @@ -80,10 +74,6 @@ def get_proposal(self): else: return obj.proposal - def get_owner_object(self): - owner_model = self.get_kind().attached_object - return owner_model.objects.get(pk=self.kwargs.get("other_pk")) - def get_kind(self): kind_str = self.kwargs.get("kind") return get_kind_from_str(kind_str) @@ -102,31 +92,45 @@ def get_form_kwargs(self,): }) return kwargs + +class ProposalAttachView( + ProposalContextMixin, + AttachFormView, + generic.CreateView, +): + + model = Attachment + form_class = AttachForm + template_name = "proposals/attach_form.html" + + def get_kind(self): + kind_str = self.kwargs.get("kind") + return get_kind_from_str(kind_str) + class ProposalUpdateAttachmentView( ProposalContextMixin, + AttachFormView, generic.UpdateView, ): model = Attachment form_class = AttachForm template_name = "proposals/attach_form.html" - def get_proposal(self): - obj = self.get_owner_object() - if type(obj) is Proposal: - return obj - else: - return obj.proposal + def get_object(self,): + attachment_pk = self.kwargs.get("attachment_pk") + attachment = Attachment.objects.get(pk=attachment_pk) + obj = attachment.get_correct_submodel() + return obj def get_owner_object(self): - owner_model = self.get_kind().attached_object - return owner_model.objects.get(pk=self.kwargs.get("other_pk")) + return self.get_object().attached_to def get_kind(self): - kind_str = self.kwargs.get("kind") + obj = self.get_object() + kind_str = obj.kind return get_kind_from_str(kind_str) - class ProposalAttachmentsView( ProposalContextMixin, generic.DetailView, @@ -142,7 +146,7 @@ def get_context_data(self, **kwargs): self.get_proposal(), ) context["manager"] = manager - + context["proposal"] = self.get_proposal() context["study_slots"] = manager.study_slots return context From cc6c4961eec0fba1da0ea772443887302c630e32 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 7 Oct 2024 12:54:37 +0200 Subject: [PATCH 16/51] fix: Move classes around to prevent circular imports --- attachments/kinds.py | 149 +-------------------------- attachments/models.py | 4 +- attachments/utils.py | 162 +++++++++++++++++++++++++++--- proposals/views/proposal_views.py | 2 +- 4 files changed, 153 insertions(+), 164 deletions(-) diff --git a/attachments/kinds.py b/attachments/kinds.py index ab46db49e..2a31219ea 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -5,71 +5,8 @@ from studies.models import Study from main.utils import renderable from attachments.models import ProposalAttachment, StudyAttachment +from attachments.utils import AttachmentKind -class AttachmentKind: - """Defines a kind of file attachment and when it is required.""" - - db_name = "" - name = "" - description = "" - max_num = None - attached_field = "attachments" - - def __init__(self, owner=None): - self.owner = owner - - @classmethod - def from_proposal(cls, proposal, attachment): - kind = get_kind_from_str(attachment.kind) - if kind.model is Proposal: - return kind(owner=proposal) - # This might be more efficient in a QS - for study in proposal.studies: - if attachment in study.attachments: - return kind(owner=study) - msg = f"Attachment {attachment.pk} not found for proposal {proposal}" - raise KeyError(msg) - - def get_slots(self, manager=None): - slots = [] - for inst in self.get_instances_for_object(): - slots.append(AttachmentSlot(self, attachment=inst, manager=manager,)) - for i in range(self.still_required()): - slots.append(AttachmentSlot(self, manager=manager,)) - return slots - - def get_instances_for_object(self): - manager = getattr(self.owner, self.attached_field) - return manager.filter(kind=self.db_name) - - def num_required(self): - return 0 - - def num_suggested(self): - return 0 - - def num_provided(self): - return self.get_instances_for_object().count() - - def still_required(self): - return self.num_required() - self.num_provided() - - def test_required(self): - """Returns False if the given proposal requires this kind - of attachment""" - return self.num_required() > self.num_provided() - - def test_recommended(self): - """Returns True if the given proposal recommends, but does not - necessarily require this kind of attachment""" - return True - - def get_attach_url(self): - url_kwargs = { - "other_pk": self.owner.pk, - "kind": self.db_name, - } - return reverse("proposals:attach_file", kwargs=url_kwargs) class ProposalAttachmentKind(AttachmentKind): @@ -120,12 +57,12 @@ def num_suggested(self): return self.num_provided + 1 + STUDY_ATTACHMENTS = [ InformationLetter, ConsentForm, ] - PROPOSAL_ATTACHMENTS = [ DataManagementPlan, OtherProposalAttachment, @@ -133,85 +70,3 @@ def num_suggested(self): ATTACHMENTS = PROPOSAL_ATTACHMENTS + STUDY_ATTACHMENTS -ATTACHMENT_CHOICES = [ - (a.db_name, a.name) for a in ATTACHMENTS -] - -def get_kind_from_str(db_name): - kinds = { - kind.db_name: kind - for kind in ATTACHMENTS - } - return kinds[db_name] - -class AttachmentSlot(renderable): - - template_name = "attachments/slot.html" - - def __init__(self, kind, attachment=None, manager=None): - self.kind = kind - self.attachment = attachment - self.desiredness = _("Verplicht") - self.manager = manager - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["slot"] = self - return context - - def get_attach_url(self,): - return self.kind.get_attach_url() - - def get_delete_url(self,): - return "#" - - def get_edit_url(self,): - return reverse( - "proposals:update_attachment", - kwargs={ - "attachment_pk": self.attachment.pk, - "proposal_pk": self.manager.proposal.pk, - } - ) - -class ProposalAttachments: - """ - """ - - def __init__(self, proposal): - self.proposal = proposal - self.proposal_kinds = self.walk_proposal() - self.study_kinds = self.walk_all_studies() - self.match_slots() - - def match_slots(self,): - self.proposal_slots = [] - for kind in self.proposal_kinds: - self.proposal_slots += kind.get_slots(manager=self) - self.study_slots = {} - for study, kinds in self.study_kinds.items(): - self.study_slots[study] = [] - for kind in kinds: - self.study_slots[study] += kind.get_slots(manager=self) - - def walk_proposal(self): - kinds = [] - for kind in PROPOSAL_ATTACHMENTS: - kinds.append( - kind(self.proposal), - ) - return kinds - - def walk_all_studies(self): - study_dict = {} - for study in self.proposal.study_set.all(): - study_dict[study] = self.walk_study(study) - return study_dict - - def walk_study(self, study): - kinds = [] - for kind in STUDY_ATTACHMENTS: - kinds.append( - kind(study), - ) - return kinds diff --git a/attachments/models.py b/attachments/models.py index 6af998a5c..11bd26075 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -2,7 +2,6 @@ from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from main.utils import renderable -from .kinds import ATTACHMENT_CHOICES from cdh.files.db import FileField as CDHFileField @@ -31,7 +30,6 @@ class Attachment(models.Model, renderable): ) kind = models.CharField( max_length=100, - choices=ATTACHMENT_CHOICES, default=("", _("Gelieve selecteren")), ) name = models.CharField( @@ -53,7 +51,7 @@ class Attachment(models.Model, renderable): ) def get_correct_submodel(self): - from .kinds import get_kind_from_str + from attachments.utils import get_kind_from_str kind = get_kind_from_str(self.kind) key = kind.attachment_class.__name__ return getattr(self, key) diff --git a/attachments/utils.py b/attachments/utils.py index e30f86a15..b651fddc0 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -1,27 +1,123 @@ from django.template.loader import get_template from django.utils.translation import gettext as _ +from django.urls import reverse + +from main.utils import renderable +from proposals.models import Proposal -from attachments.kinds import ATTACHMENTS from attachments.models import Attachment -class Renderable: +class AttachmentKind: + """Defines a kind of file attachment and when it is required.""" + + db_name = "" + name = "" + description = "" + max_num = None + attached_field = "attachments" + + def __init__(self, owner=None): + self.owner = owner + + @classmethod + def from_proposal(cls, proposal, attachment): + kind = get_kind_from_str(attachment.kind) + if kind.model is Proposal: + return kind(owner=proposal) + # This might be more efficient in a QS + for study in proposal.studies: + if attachment in study.attachments: + return kind(owner=study) + msg = f"Attachment {attachment.pk} not found for proposal {proposal}" + raise KeyError(msg) + + def get_slots(self, manager=None): + slots = [] + for inst in self.get_instances_for_object(): + slots.append(AttachmentSlot(self, attachment=inst, manager=manager,)) + for i in range(self.still_required()): + slots.append(AttachmentSlot(self, manager=manager,)) + return slots + + def get_instances_for_object(self): + manager = getattr(self.owner, self.attached_field) + return manager.filter(kind=self.db_name) + + def num_required(self): + return 0 + + def num_suggested(self): + return 0 + + def num_provided(self): + return self.get_instances_for_object().count() + + def still_required(self): + return self.num_required() - self.num_provided() + + def test_required(self): + """Returns False if the given proposal requires this kind + of attachment""" + return self.num_required() > self.num_provided() + + def test_recommended(self): + """Returns True if the given proposal recommends, but does not + necessarily require this kind of attachment""" + return True + + def get_attach_url(self): + url_kwargs = { + "other_pk": self.owner.pk, + "kind": self.db_name, + } + return reverse("proposals:attach_file", kwargs=url_kwargs) + - template_name = None +class ProposalAttachments: + """ + """ - def get_template_name(self,): - return self.template_name + def __init__(self, proposal): + self.proposal = proposal + self.proposal_kinds = self.walk_proposal() + self.study_kinds = self.walk_all_studies() + self.match_slots() + + def match_slots(self,): + self.proposal_slots = [] + for kind in self.proposal_kinds: + self.proposal_slots += kind.get_slots(manager=self) + self.study_slots = {} + for study, kinds in self.study_kinds.items(): + self.study_slots[study] = [] + for kind in kinds: + self.study_slots[study] += kind.get_slots(manager=self) + + def walk_proposal(self): + kinds = [] + for kind in PROPOSAL_ATTACHMENTS: + kinds.append( + kind(self.proposal), + ) + return kinds - def get_context_data(self,): - return {} + def walk_all_studies(self): + study_dict = {} + for study in self.proposal.study_set.all(): + study_dict[study] = self.walk_study(study) + return study_dict + + def walk_study(self, study): + kinds = [] + for kind in STUDY_ATTACHMENTS: + kinds.append( + kind(study), + ) + return kinds - def render(self, context): - context = context.flatten() - context.update(self.get_context_data()) - template = get_template(self.get_template_name()) - return template.render(context) class AttachmentContainer( - Renderable + renderable, ): outer_css_classes = [] template_name = "attachments/base_single_attachment.html" @@ -90,6 +186,7 @@ def __init__(self, proposal): # Setup self.proposal = proposal + from attachments.kinds import ATTACHMENTS self.available_kinds = ATTACHMENTS # Populate lists @@ -133,3 +230,42 @@ def as_containers(self,): AttachmentContainer(a, proposal=self.propsal) for a in self.all_attachments ] + + +class AttachmentSlot(renderable): + + template_name = "attachments/slot.html" + + def __init__(self, kind, attachment=None, manager=None): + self.kind = kind + self.attachment = attachment + self.desiredness = _("Verplicht") + self.manager = manager + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["slot"] = self + return context + + def get_attach_url(self,): + return self.kind.get_attach_url() + + def get_delete_url(self,): + return "#" + + def get_edit_url(self,): + return reverse( + "proposals:update_attachment", + kwargs={ + "attachment_pk": self.attachment.pk, + "proposal_pk": self.manager.proposal.pk, + } + ) + +def get_kind_from_str(db_name): + from attachments.kinds import ATTACHMENTS + kinds = { + kind.db_name: kind + for kind in ATTACHMENTS + } + return kinds[db_name] diff --git a/proposals/views/proposal_views.py b/proposals/views/proposal_views.py index 7e0e72d9d..92d83802c 100644 --- a/proposals/views/proposal_views.py +++ b/proposals/views/proposal_views.py @@ -58,7 +58,7 @@ ) from proposals.utils.proposal_utils import FilenameFactory -from attachments.kinds import ProposalAttachments +from attachments.utils import ProposalAttachments ############ From 7254db5a2f98b5d5b6a6fabfb40387f764e7363b Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 7 Oct 2024 12:54:48 +0200 Subject: [PATCH 17/51] wip: Remove attachments manager for now --- proposals/views/attachment_views.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index ed26e9cd0..db371d78c 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -4,12 +4,12 @@ from proposals.mixins import ProposalContextMixin from proposals.models import Proposal from studies.models import Study -from attachments.kinds import ProposalAttachments, get_kind_from_str +from attachments.utils import ProposalAttachments, get_kind_from_str from attachments.models import Attachment, ProposalAttachment, StudyAttachment from main.forms import ConditionalModelForm from cdh.core import forms as cdh_forms from django.http import FileResponse -from attachments.kinds import ATTACHMENTS, AttachmentKind +from attachments.kinds import ATTACHMENTS class AttachForm( @@ -142,13 +142,6 @@ class ProposalAttachmentsView( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - manager = ProposalAttachments( - self.get_proposal(), - ) - context["manager"] = manager - context["proposal"] = self.get_proposal() - context["study_slots"] = manager.study_slots - return context From 66c8f79b2cc52e86454d9466b2b7ce34df027a79 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 7 Oct 2024 13:46:46 +0200 Subject: [PATCH 18/51] feat: Initialize stepper outside of context-getting --- proposals/mixins.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/proposals/mixins.py b/proposals/mixins.py index 385fc31ec..c8f0d6107 100644 --- a/proposals/mixins.py +++ b/proposals/mixins.py @@ -21,21 +21,25 @@ class StepperContextMixin: """ def get_context_data(self, *args, **kwargs): - # Importing here to prevent circular import - from .utils.stepper import Stepper - context = super().get_context_data(*args, **kwargs) + context["stepper"] = self.get_stepper() + return context + + def get_stepper(self,): + if hasattr(self, "stepper",): + return self.stepper # Try to determine proposal proposal = Proposal() if hasattr(self, "get_proposal"): proposal = self.get_proposal() + # Importing here to prevent circular import + from .utils.stepper import Stepper # Initialize and insert stepper object - stepper = Stepper( + self.stepper = Stepper( proposal, request=self.request, ) - context["stepper"] = stepper - return context + return self.stepper class ProposalContextMixin( From dcbe8b41046b6522452cb175622b8a498c222660 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 7 Oct 2024 13:47:26 +0200 Subject: [PATCH 19/51] wip: Start managing attachments by Checkers --- proposals/utils/checkers.py | 39 +++++++++++++++++++++++++++++++------ proposals/utils/stepper.py | 1 + 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/proposals/utils/checkers.py b/proposals/utils/checkers.py index ecc15ae8e..0ed1e0e45 100644 --- a/proposals/utils/checkers.py +++ b/proposals/utils/checkers.py @@ -11,6 +11,9 @@ from tasks.views import task_views, session_views from tasks.models import Task, Session +from attachments.utils import AttachmentSlot +from attachments.kinds import InformationLetter + from .stepper_helpers import ( Checker, PlaceholderItem, @@ -384,12 +387,18 @@ def check( parent=self.current_parent, ), ] - end_checker = StudyEndChecker( - self.stepper, - study=self.study, - parent=self.current_parent, - ) - return checkers + self.determine_study_checkers(self.study) + [end_checker] + final_checkers = [ + StudyEndChecker( + self.stepper, + study=self.study, + parent=self.current_parent, + ), + StudyAttachmentsChecker( + self.stepper, + study=self.study, + ), + ] + return checkers + self.determine_study_checkers(self.study) + final_checkers def determine_study_checkers(self, study): tests = { @@ -423,6 +432,24 @@ def make_sessions(self, study): parent=self.current_parent, ) +class StudyAttachmentsChecker( + Checker, +): + + def __init__(self, *args, **kwargs,): + self.study = kwargs.pop("study") + super().__init__(*args, **kwargs) + + def check( + self, + ): + kind = InformationLetter + info_slot = AttachmentSlot( + kind, + self.study, + ) + self.stepper.attachment_slots.append(info_slot) + return [] class ParticipantsChecker( ModelFormChecker, diff --git a/proposals/utils/stepper.py b/proposals/utils/stepper.py index d59c61ca8..4402293ef 100644 --- a/proposals/utils/stepper.py +++ b/proposals/utils/stepper.py @@ -36,6 +36,7 @@ def __init__( # which item is current self.request = request self.items = [] + self.attachment_slots = [] self.check_all(self.starting_checkers) def get_context_data(self): From be1de9955008329c01087f4ebeb707d9d4f3ed5e Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 7 Oct 2024 13:47:44 +0200 Subject: [PATCH 20/51] feat: Make sure slots know what they're attaching to --- attachments/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/attachments/utils.py b/attachments/utils.py index b651fddc0..b40a65104 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -236,7 +236,7 @@ class AttachmentSlot(renderable): template_name = "attachments/slot.html" - def __init__(self, kind, attachment=None, manager=None): + def __init__(self, kind, attached_object, attachment=None, manager=None): self.kind = kind self.attachment = attachment self.desiredness = _("Verplicht") From aa0176b6c07c9c0edd10a9c1f9940d99f72fa93a Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 7 Oct 2024 16:56:34 +0200 Subject: [PATCH 21/51] fix: Minor fixes and comment updates --- attachments/models.py | 9 +++++++-- attachments/templates/attachments/attachment_model.html | 2 +- attachments/templates/attachments/slot.html | 4 ++-- proposals/mixins.py | 1 + proposals/urls.py | 2 +- proposals/views/attachment_views.py | 3 ++- 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/attachments/models.py b/attachments/models.py index 11bd26075..0a55d1302 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -44,7 +44,8 @@ class Attachment(models.Model, renderable): max_length=2000, default="", help_text=_( - "Geef hier je motivatie om dit bestand toe te voegen en waar " + "Geef hier optioneel je motivatie om dit bestand toe te voegen en " + "waar " "je het voor gaat gebruiken tijdens je onderzoek. Eventuele " "opmerkingen voor de FETC kun je hier ook kwijt." ) @@ -53,7 +54,11 @@ class Attachment(models.Model, renderable): def get_correct_submodel(self): from attachments.utils import get_kind_from_str kind = get_kind_from_str(self.kind) - key = kind.attachment_class.__name__ + # By default, this is how subclassed model relation names + # are generated by Django. That's why the following line works. + # However, if we use a different related_name or a name + # collision we'd have to be smarter about getting the submodel. + key = kind.attachment_class.__name__.lower() return getattr(self, key) def get_context_data(self, **kwargs): diff --git a/attachments/templates/attachments/attachment_model.html b/attachments/templates/attachments/attachment_model.html index 4fd985d6d..a3116d9f4 100644 --- a/attachments/templates/attachments/attachment_model.html +++ b/attachments/templates/attachments/attachment_model.html @@ -1,5 +1,5 @@ - + {{attachment.upload.original_filename}} diff --git a/attachments/templates/attachments/slot.html b/attachments/templates/attachments/slot.html index 1fe840084..48434e4e4 100644 --- a/attachments/templates/attachments/slot.html +++ b/attachments/templates/attachments/slot.html @@ -6,9 +6,9 @@ {{ slot.desiredness }}
- {% if slot.attachment %}

{{slot.kind.name}}

- {% include slot.attachment with manager=manager %} + {% if slot.attachment %} + {% include slot.attachment with proposal=proposal %} {% else %} Nog toe te voegen {% endif %} diff --git a/proposals/mixins.py b/proposals/mixins.py index c8f0d6107..2bbffd12e 100644 --- a/proposals/mixins.py +++ b/proposals/mixins.py @@ -63,6 +63,7 @@ def get_context_data(self, **kwargs): context = super(ProposalContextMixin, self).get_context_data(**kwargs) context["is_supervisor"] = self.current_user_is_supervisor() context["is_practice"] = self.get_proposal().is_practice() + context["proposal"] = self.get_proposal() return context diff --git a/proposals/urls.py b/proposals/urls.py index 89dd8a577..343e2767c 100644 --- a/proposals/urls.py +++ b/proposals/urls.py @@ -183,7 +183,7 @@ name="attach_file", ), path( - "attachments//edit//", + "attachments//edit//", ProposalUpdateAttachmentView.as_view(), name="update_attachment", ), diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index db371d78c..c17cf089e 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -10,6 +10,7 @@ from cdh.core import forms as cdh_forms from django.http import FileResponse from attachments.kinds import ATTACHMENTS +from attachments.utils import AttachmentKind class AttachForm( @@ -108,8 +109,8 @@ def get_kind(self): return get_kind_from_str(kind_str) class ProposalUpdateAttachmentView( - ProposalContextMixin, AttachFormView, + ProposalContextMixin, generic.UpdateView, ): model = Attachment From 987d916d9e2080d1d249fdb0a598b27b6465d783 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 7 Oct 2024 17:00:28 +0200 Subject: [PATCH 22/51] cleanup: Remove old code and simplify object getting --- attachments/kinds.py | 5 ++ attachments/utils.py | 75 ++++++++--------------------- proposals/views/attachment_views.py | 4 +- 3 files changed, 29 insertions(+), 55 deletions(-) diff --git a/attachments/kinds.py b/attachments/kinds.py index 2a31219ea..cfd36367a 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -24,6 +24,11 @@ class InformationLetter(StudyAttachmentKind): name = _("Informatiebrief") description = _("Omschrijving informatiebrief") + def __init__(self, *args, **kwargs): + # Information letters are required by default + self.is_required = True + return super().__init__(*args, **kwargs,) + def num_required(self,): return 1 diff --git a/attachments/utils.py b/attachments/utils.py index b40a65104..cdeffba33 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -180,73 +180,40 @@ def is_active(self): return self.proposal in self.attachment.attached_to.all() -class ProposalAttachments(): - - def __init__(self, proposal): - - # Setup - self.proposal = proposal - from attachments.kinds import ATTACHMENTS - self.available_kinds = ATTACHMENTS - - # Populate lists - self.all_attachments = self._populate() - - def _populate(self,): - # These are the Attachments that actually exist - self.provided = self._fetch() - # These are the Attachments that are still required - # (we create placeholders for them) - self.complement = self._complement_all() - return self.provided + self.complement - - def _fetch(self,): - qs = self.proposal.attachments.all() - return list(qs) - - def _provided_of_kind(self, kind,): - out = filter( - lambda a: a.kind == kind.db_name, - self.provided, - ) - return list(out) - - def _complement_of_kind(self, kind,): - required = kind.num_required(self.proposal) - provided = len(self._provided_of_kind(kind)) - for i in range(required - provided): - new_attachment = Attachment() - new_attachment.kind = kind.db_name - self.complement.append(new_attachment) - - def _complement_all(self,): - self.complement = [] - for kind in self.available_kinds: - self._complement_of_kind(kind) - return self.complement - - def as_containers(self,): - return [ - AttachmentContainer(a, proposal=self.propsal) - for a in self.all_attachments - ] - - class AttachmentSlot(renderable): template_name = "attachments/slot.html" def __init__(self, kind, attached_object, attachment=None, manager=None): - self.kind = kind self.attachment = attachment + self.attached_object = attached_object self.desiredness = _("Verplicht") self.manager = manager + self.kind = kind + if not isinstance(self.kind, AttachmentKind): + self.kind = self.kind(self.attached_object) + self.match_attachment() + + def match_attachment(self): + """ + Tries to fill this slot with an existing attachment. + """ + for instance in self.kind.get_instances_for_object(): + self.attachment = instance + break def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["slot"] = self + context["proposal"] = self.get_proposal() return context + def get_proposal(self): + if type(self.attached_object) is Proposal: + return self.attached_object + else: + return self.attached_object.proposal + def get_attach_url(self,): return self.kind.get_attach_url() @@ -258,7 +225,7 @@ def get_edit_url(self,): "proposals:update_attachment", kwargs={ "attachment_pk": self.attachment.pk, - "proposal_pk": self.manager.proposal.pk, + "other_pk": self.attached_object.pk, } ) diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index c17cf089e..1615fbf32 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -124,7 +124,9 @@ def get_object(self,): return obj def get_owner_object(self): - return self.get_object().attached_to + other_class = self.get_kind().attached_object + other_pk = self.kwargs.get("other_pk") + return other_class.objects.get(pk=other_pk) def get_kind(self): obj = self.get_object() From 97db730ef5fa30e8ce451c5c2c7f9eaec8e80086 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 7 Oct 2024 17:00:54 +0200 Subject: [PATCH 23/51] feat: Just fill in study slots by hand for now --- proposals/views/attachment_views.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index 1615fbf32..beaf8ce40 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -144,7 +144,14 @@ class ProposalAttachmentsView( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - + all_slots = self.get_stepper().attachment_slots + study_slots = {} + for study in self.get_proposal().study_set.all(): + study_slots[study] = [] + for slot in all_slots: + if type(slot.attached_object) is Study: + study_slots[slot.attached_object].append(slot) + context["study_slots"] = study_slots return context From c8198d815b05074628c71fc3b7940dd1cd5f126f Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Wed, 9 Oct 2024 14:38:05 +0200 Subject: [PATCH 24/51] feat: Detach functionality for Attachments --- attachments/models.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/attachments/models.py b/attachments/models.py index 0a55d1302..13bdbbc5e 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -61,6 +61,23 @@ def get_correct_submodel(self): key = kind.attachment_class.__name__.lower() return getattr(self, key) + def detach(self, other_object): + """ + This method is simple enough to define for all submodels, + assuming they use the attached_to attribute name. However, + base Attachments do not have an attached_to attribute, so + we have to defer to the submodel if detach is called on a + base Attachment instance. + """ + if self.__name__ == "Attachment": + attachment = self.get_correct_submodel() + return attachment.detach(other_object) + # The following part only runs if called from a submodel + if self.attached_to.count > 1: + self.attached_to.remove(other_object) + else: + self.delete() + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["attachment"] = self @@ -72,8 +89,21 @@ class ProposalAttachment(Attachment,): related_name="attachments", ) + def get_owner_for_proposal(self, proposal,): + """ + This method doesn't do much, it's just here to provide + a consistent interface for getting owner objects. + """ + return proposal + class StudyAttachment(Attachment,): attached_to = models.ManyToManyField( "studies.Study", related_name="attachments", ) + + def get_owner_for_proposal(self, proposal,): + """ + Gets the owner study based on given proposal. + """ + return self.attached_to.get(proposal=proposal) From d2f4fdf975eea05b2a899bb428fbdf208bd01528 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Fri, 11 Oct 2024 15:26:54 +0200 Subject: [PATCH 25/51] feat: Get Attachment subclasses without kind --- attachments/models.py | 35 ++++++++++++++++++------- proposals/urls.py | 6 +++++ proposals/views/attachment_views.py | 40 +++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/attachments/models.py b/attachments/models.py index 13bdbbc5e..702f27e8a 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -30,6 +30,12 @@ class Attachment(models.Model, renderable): ) kind = models.CharField( max_length=100, + # It would be nice to be able to define fixed choices here. + # But I haven't found a way to nicely import them from kinds.py + # without circular imports. So for now we just set the choices in + # whatever form needs them. + # From Django 5 onwards we can define a callable to get + # the choices which would be the preferred solution. default=("", _("Gelieve selecteren")), ) name = models.CharField( @@ -52,28 +58,39 @@ class Attachment(models.Model, renderable): ) def get_correct_submodel(self): - from attachments.utils import get_kind_from_str - kind = get_kind_from_str(self.kind) - # By default, this is how subclassed model relation names - # are generated by Django. That's why the following line works. - # However, if we use a different related_name or a name + if self.__class__.__name__ != "Attachment": + # In this case, we're already dealing with a submodel + return self + submodels = [StudyAttachment, ProposalAttachment] + # By default, lowering the class name is how subclassed model + # relation names are generated by Django. That's why the following + # lines work. + # However, if we use a different related_name or run into a name # collision we'd have to be smarter about getting the submodel. - key = kind.attachment_class.__name__.lower() - return getattr(self, key) + for submodel in submodels: + key = submodel.__name__.lower() + if hasattr(self, key): + return getattr(self, key) + raise KeyError( + "Couldn't find a matching submodel." + ) def detach(self, other_object): """ + Remove an attachment from an owner object. If no other + owner objects remain, delete the attachment. + This method is simple enough to define for all submodels, assuming they use the attached_to attribute name. However, base Attachments do not have an attached_to attribute, so we have to defer to the submodel if detach is called on a base Attachment instance. """ - if self.__name__ == "Attachment": + if self.__class__.__name__ == "Attachment": attachment = self.get_correct_submodel() return attachment.detach(other_object) # The following part only runs if called from a submodel - if self.attached_to.count > 1: + if self.attached_to.count() > 1: self.attached_to.remove(other_object) else: self.delete() diff --git a/proposals/urls.py b/proposals/urls.py index 343e2767c..d00ef5ffb 100644 --- a/proposals/urls.py +++ b/proposals/urls.py @@ -48,6 +48,7 @@ from .views.attachment_views import ( ProposalAttachView, + ProposalDetachView, ProposalAttachmentsView, ProposalUpdateAttachmentView, ProposalAttachmentDownloadView, @@ -182,6 +183,11 @@ ProposalAttachView.as_view(), name="attach_file", ), + path( + "/detach//", + ProposalDetachView.as_view(), + name="attach_file", + ), path( "attachments//edit//", ProposalUpdateAttachmentView.as_view(), diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index beaf8ce40..455256f26 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -1,6 +1,7 @@ from django.views import generic from django import forms from django.urls import reverse +from django import forms from proposals.mixins import ProposalContextMixin from proposals.models import Proposal from studies.models import Study @@ -133,6 +134,45 @@ def get_kind(self): kind_str = obj.kind return get_kind_from_str(kind_str) +class DetachForm( + forms.Form, +): + confirmation = forms.BooleanField() + +class ProposalDetachView( + generic.detail.SingleObjectMixin, + generic.FormView, +): + form_class = DetachForm + template_name = "proposals/detach_form.html" + pk_url_kwarg = "attachment_pk" + + def get_owner_object(self,): + attachment = self.get_object() + return attachment.get_owner_for_proposal( + self.get_proposal(), + ) + + def get_proposal(self,): + proposal_pk = self.kwargs.get("proposal_pk") + return Proposal.objects.get(pk=proposal_pk) + + def form_valid(self, form): + attachment = self.get_object() + attachment.detach(self.get_owner_object()) + return super().form_valid(form) + + def get_success_url(self,): + return reverse( + "proposals:attachments", + kwargs={"pk": self.get_proposal().pk}, + ) + +class AttachmentDetailView( + generic.DetailView, +): + template_name = "proposals/attachment_detail.html" + model = Attachment class ProposalAttachmentsView( ProposalContextMixin, From 9c64aa433865cf6176c90818ae06497e985a9351 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Fri, 11 Oct 2024 15:28:28 +0200 Subject: [PATCH 26/51] feat: Make slots less dependent on kinds --- attachments/utils.py | 159 ++++++++++++------------------------------- 1 file changed, 42 insertions(+), 117 deletions(-) diff --git a/attachments/utils.py b/attachments/utils.py index cdeffba33..55f88d283 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -4,6 +4,7 @@ from main.utils import renderable from proposals.models import Proposal +from studies.models import Study from attachments.models import Attachment @@ -39,10 +40,6 @@ def get_slots(self, manager=None): slots.append(AttachmentSlot(self, manager=manager,)) return slots - def get_instances_for_object(self): - manager = getattr(self.owner, self.attached_field) - return manager.filter(kind=self.db_name) - def num_required(self): return 0 @@ -73,135 +70,45 @@ def get_attach_url(self): return reverse("proposals:attach_file", kwargs=url_kwargs) -class ProposalAttachments: - """ - """ - - def __init__(self, proposal): - self.proposal = proposal - self.proposal_kinds = self.walk_proposal() - self.study_kinds = self.walk_all_studies() - self.match_slots() - - def match_slots(self,): - self.proposal_slots = [] - for kind in self.proposal_kinds: - self.proposal_slots += kind.get_slots(manager=self) - self.study_slots = {} - for study, kinds in self.study_kinds.items(): - self.study_slots[study] = [] - for kind in kinds: - self.study_slots[study] += kind.get_slots(manager=self) - - def walk_proposal(self): - kinds = [] - for kind in PROPOSAL_ATTACHMENTS: - kinds.append( - kind(self.proposal), - ) - return kinds - - def walk_all_studies(self): - study_dict = {} - for study in self.proposal.study_set.all(): - study_dict[study] = self.walk_study(study) - return study_dict - - def walk_study(self, study): - kinds = [] - for kind in STUDY_ATTACHMENTS: - kinds.append( - kind(study), - ) - return kinds - - -class AttachmentContainer( - renderable, -): - outer_css_classes = [] - template_name = "attachments/base_single_attachment.html" - - def __init__(self, attachment, proposal=None): - self.proposal = proposal - self.attachment = attachment - - def get_outer_css_classes(self,): - classes = self.outer_css_classes - if self.is_active: - classes += ["attachment-active"] - return " ".join(classes) - - def get_context_data(self): - return { - "ac": self, - "proposal": self.proposal, - } - - @property - def is_from_previous_proposal(self,): - if not self.proposal.parent: - return False - pp = self.proposal.parent - return self.attachment in pp.attachments_set.all() - - @property - def is_revised_file(self,): - if not all( - self.attachment.parent, - self.proposal.parent, - ): - return False - pa = self.attachment.parent - pp = self.proposal.parent - return pa in pp.attachments_set.all() - - @property - def is_brand_new(self,): - if self.attachment.parent: - return False - return True - - def get_origin_display(self): - if not self.attachment.upload: - return _("Nog toe te voegen") - if self.is_from_previous_proposal: - return _("Van vorige revisie") - if self.is_revised_file: - return _("Nieuwe versie") - if self.is_brand_new: - return _("Nieuw aangeleverd bestand") - return _("Herkomst onbekend") - - @property - def is_active(self): - if not self.proposal: - return True - return self.proposal in self.attachment.attached_to.all() - - class AttachmentSlot(renderable): template_name = "attachments/slot.html" - def __init__(self, kind, attached_object, attachment=None, manager=None): + def __init__( + self, + attached_object, + attachment=None, + manager=None, + kind=None, + ): self.attachment = attachment self.attached_object = attached_object self.desiredness = _("Verplicht") self.manager = manager self.kind = kind - if not isinstance(self.kind, AttachmentKind): - self.kind = self.kind(self.attached_object) self.match_attachment() def match_attachment(self): """ Tries to fill this slot with an existing attachment. """ - for instance in self.kind.get_instances_for_object(): + for instance in self.get_instances_for_slot(): self.attachment = instance break + def desiredness(self): + if self.force_required: + return self.force_desiredness + + def get_instances_for_slot(self,): + manager = getattr( + self.attached_object, + "attachments", + ) + if self.kind: + manager = manager.filter(kind=self.kind.db_name) + return manager.all() + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["slot"] = self @@ -215,10 +122,28 @@ def get_proposal(self): return self.attached_object.proposal def get_attach_url(self,): - return self.kind.get_attach_url() + url_name = { + Proposal: "proposals:attach_proposal", + Study: "proposals:attach_study", + }[type(self.attached_object)] + reverse_kwargs = { + "other_pk": self.attached_object.pk, + } + if self.kind: + reverse_kwargs["kind"] = self.kind.db_name + return reverse( + url_name, + kwargs=reverse_kwargs, + ) def get_delete_url(self,): - return "#" + return reverse( + "proposals:detach", + kwargs={ + "attachment_pk": self.attachment.pk, + "other_pk": self.attached_object.pk, + } + ) def get_edit_url(self,): return reverse( From 9131b703f4a2e160d597d73c4f3308a758e5517f Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Fri, 11 Oct 2024 15:29:12 +0200 Subject: [PATCH 27/51] feat: Detach view and links, editing flag for AttachFormView --- .../templates/proposals/attach_form.html | 43 +++++++++++++------ proposals/urls.py | 38 +++++++++++++--- 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/proposals/templates/proposals/attach_form.html b/proposals/templates/proposals/attach_form.html index 65d3f3884..b3cb01401 100644 --- a/proposals/templates/proposals/attach_form.html +++ b/proposals/templates/proposals/attach_form.html @@ -9,9 +9,19 @@ {% block pre-form-text %}

{% trans "Maak of wijzig een bestand" %}

- {% blocktrans trimmed %} - Je voegt een bestand toe aan het volgende: - {% endblocktrans %} + {% if not view.editing %} + {% blocktrans trimmed %} + Je voegt een bestand toe aan het volgende: + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + Je bewerkt het volgende bestand: + {% endblocktrans %} +

{% include object with proposal=view.get_proposal %}

+ {% blocktrans trimmed %} + Dit bestand is gekoppeld aan het volgende: + {% endblocktrans %} + {% endif %}

{% if study %} @@ -21,9 +31,11 @@

{% trans "Maak of wijzig een bestand" %}

{% endif %} {% trans "de aanvraag" %} {{ proposal.title }}.

+ {% if not view.editing %} {% blocktrans with name=kind_name trimmed %} Je gaat hier een {{name}} aan toevoegen. Wil je een ander soort bestand toevoegen? Ga dan terug naar de vorige pagina. {% endblocktrans %} + {% endif %}

@@ -33,15 +45,20 @@

{% trans "Maak of wijzig een bestand" %}

+ + {% trans '<< Ga terug' %} + +{% if view.editing %} + + {% trans 'Bestand verwijderen' %} + +{% endif %} + + + +
{% endblock %} diff --git a/proposals/urls.py b/proposals/urls.py index d00ef5ffb..0fe9ed539 100644 --- a/proposals/urls.py +++ b/proposals/urls.py @@ -1,5 +1,8 @@ from django.urls import path, include +from proposals.models import Proposal +from studies.models import Study + from .views.proposal_views import ( CompareDocumentsView, MyConceptsView, @@ -179,14 +182,39 @@ name="attachments", ), path( - "attach///", - ProposalAttachView.as_view(), - name="attach_file", + "attach_proposal///", + ProposalAttachView.as_view( + owner_model=Proposal, + ), + name="attach_proposal", + ), + path( + "attach_study///", + ProposalAttachView.as_view( + owner_model=Study, + ), + name="attach_study", + ), + path( + "attach_proposal/extra//", + ProposalAttachView.as_view( + owner_model=Proposal, + extra=True + ), + name="attach_proposal", + ), + path( + "attach_study/extra//", + ProposalAttachView.as_view( + owner_model=Study, + extra=True + ), + name="attach_study", ), path( - "/detach//", + "/detach//", ProposalDetachView.as_view(), - name="attach_file", + name="detach_file", ), path( "attachments//edit//", From 2682a7c01ba6a17babdc183380ac763191f484e3 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Fri, 11 Oct 2024 15:30:45 +0200 Subject: [PATCH 28/51] feat: Extra/optional attachments with DMP example --- .../templates/proposals/attachments.html | 7 ++-- proposals/utils/checkers.py | 19 ++++++++--- proposals/views/attachment_views.py | 34 ++++++++++++++++--- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/proposals/templates/proposals/attachments.html b/proposals/templates/proposals/attachments.html index 1d1264e0e..9f653a0f3 100644 --- a/proposals/templates/proposals/attachments.html +++ b/proposals/templates/proposals/attachments.html @@ -9,8 +9,11 @@ {% block pre-form-text %} -

{% trans "Documenten" %}

-

{% trans "Algemeen" %}

+

{% trans "Documenten" %}

+

{% trans "Algemeen" %}

+{% for slot in proposal_slots %} + {% include slot %} +{% endfor %} {% for study, slots in study_slots.items %}

{% trans "Traject " %} {{ study.order }} {% if study.name %}: {{ study.name }} {% endif %}

{% for slot in slots %} diff --git a/proposals/utils/checkers.py b/proposals/utils/checkers.py index 0ed1e0e45..af5eaf072 100644 --- a/proposals/utils/checkers.py +++ b/proposals/utils/checkers.py @@ -12,7 +12,7 @@ from tasks.models import Task, Session from attachments.utils import AttachmentSlot -from attachments.kinds import InformationLetter +from attachments.kinds import InformationLetter, DataManagementPlan from .stepper_helpers import ( Checker, @@ -326,7 +326,7 @@ def remaining_checkers( self, ): return [ - DocumentsChecker, + AttachmentsChecker, DataManagementChecker, SubmitChecker, ] @@ -445,8 +445,8 @@ def check( ): kind = InformationLetter info_slot = AttachmentSlot( - kind, self.study, + kind=kind, ) self.stepper.attachment_slots.append(info_slot) return [] @@ -798,12 +798,13 @@ def get_checker_errors(self): return [] -class DocumentsChecker( +class AttachmentsChecker( Checker, ): def check( self, ): + self.add_dmp_slot() item = self.make_stepper_item() self.stepper.items.append(item) return [ @@ -813,9 +814,16 @@ def check( ), ] + def add_dmp_slot(self): + slot = AttachmentSlot( + self.stepper.proposal, + kind=DataManagementPlan, + ) + self.stepper.attachment_slots.append(slot) + def make_stepper_item(self): url = reverse( - "proposals:consent", + "proposals:attachments", args=[self.stepper.proposal.pk], ) item = PlaceholderItem( @@ -825,6 +833,7 @@ def make_stepper_item(self): ) item.get_url = lambda: url return item + return [TranslationChecker] class TranslationChecker( diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index 455256f26..9bbdbb2d1 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -21,12 +21,13 @@ class AttachForm( class Meta: model = Attachment fields = [ + "kind", "upload", "name", "comments", ] - def __init__(self, kind=None, other_object=None, **kwargs): + def __init__(self, kind=None, other_object=None, extra=False, **kwargs): self.kind = kind self.other_object = other_object # Set the correct model based on other_object @@ -34,7 +35,9 @@ def __init__(self, kind=None, other_object=None, **kwargs): self._meta.model = ProposalAttachment elif type(other_object) is Study: self._meta.model = StudyAttachment - return super().__init__(**kwargs) + super().__init__(**kwargs) + if not extra: + del self.fields["kind"] def save(self,): self.instance.kind = self.kind.db_name @@ -50,7 +53,7 @@ class AttachFormView(): model = Attachment form_class = AttachForm template_name = "proposals/attach_form.html" - + def set_upload_field_label(self, form): # Remind the user of what they're uploading upload_field = form.fields["upload"] @@ -76,6 +79,11 @@ def get_proposal(self): else: return obj.proposal + def get_owner_object(self): + owner_class = self.owner_model + other_pk = self.kwargs.get("other_pk") + return owner_class.objects.get(pk=other_pk) + def get_kind(self): kind_str = self.kwargs.get("kind") return get_kind_from_str(kind_str) @@ -96,14 +104,16 @@ def get_form_kwargs(self,): class ProposalAttachView( - ProposalContextMixin, AttachFormView, + ProposalContextMixin, generic.CreateView, ): model = Attachment + owner_model = None form_class = AttachForm template_name = "proposals/attach_form.html" + extra = False def get_kind(self): kind_str = self.kwargs.get("kind") @@ -117,6 +127,7 @@ class ProposalUpdateAttachmentView( model = Attachment form_class = AttachForm template_name = "proposals/attach_form.html" + editing = True def get_object(self,): attachment_pk = self.kwargs.get("attachment_pk") @@ -140,10 +151,12 @@ class DetachForm( confirmation = forms.BooleanField() class ProposalDetachView( + ProposalContextMixin, generic.detail.SingleObjectMixin, generic.FormView, ): form_class = DetachForm + model = Attachment template_name = "proposals/detach_form.html" pk_url_kwarg = "attachment_pk" @@ -153,6 +166,15 @@ def get_owner_object(self,): self.get_proposal(), ) + def get_context_data(self, *args, **kwargs): + self.object = self.get_object() + context = super().get_context_data(*args, **kwargs) + return context + + def get_object(self,): + obj = super().get_object() + return obj.get_correct_submodel() + def get_proposal(self,): proposal_pk = self.kwargs.get("proposal_pk") return Proposal.objects.get(pk=proposal_pk) @@ -185,6 +207,9 @@ class ProposalAttachmentsView( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) all_slots = self.get_stepper().attachment_slots + proposal_slots = [ + slot for slot in all_slots if type(slot.attached_object) is Proposal + ] study_slots = {} for study in self.get_proposal().study_set.all(): study_slots[study] = [] @@ -192,6 +217,7 @@ def get_context_data(self, **kwargs): if type(slot.attached_object) is Study: study_slots[slot.attached_object].append(slot) context["study_slots"] = study_slots + context["proposal_slots"] = proposal_slots return context From 54c71a690b502f7591b1a8ba26e362b650eb6f0d Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Fri, 11 Oct 2024 16:17:17 +0200 Subject: [PATCH 29/51] feat: New desiredness flags --- attachments/kinds.py | 11 +-- .../static/attachments/attachments.css | 11 --- attachments/utils.py | 71 ++++--------------- 3 files changed, 15 insertions(+), 78 deletions(-) delete mode 100644 attachments/static/attachments/attachments.css diff --git a/attachments/kinds.py b/attachments/kinds.py index cfd36367a..98121d933 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -5,7 +5,7 @@ from studies.models import Study from main.utils import renderable from attachments.models import ProposalAttachment, StudyAttachment -from attachments.utils import AttachmentKind +from attachments.utils import AttachmentKind, desiredness class ProposalAttachmentKind(AttachmentKind): @@ -23,14 +23,7 @@ class InformationLetter(StudyAttachmentKind): db_name = "information_letter" name = _("Informatiebrief") description = _("Omschrijving informatiebrief") - - def __init__(self, *args, **kwargs): - # Information letters are required by default - self.is_required = True - return super().__init__(*args, **kwargs,) - - def num_required(self,): - return 1 + desiredness = desiredness.REQUIRED class ConsentForm(AttachmentKind): diff --git a/attachments/static/attachments/attachments.css b/attachments/static/attachments/attachments.css deleted file mode 100644 index 62355aa59..000000000 --- a/attachments/static/attachments/attachments.css +++ /dev/null @@ -1,11 +0,0 @@ -.attachment-outer { - border: 1px solid #cccccc; - background-color: #eeeeee; -} - -.attachment-upper-border { - background-color: #ccc; - font-size: smaller; - padding: 0.2rem; - padding-left: 0.5rem; -} diff --git a/attachments/utils.py b/attachments/utils.py index 55f88d283..f42e0a6ec 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -6,7 +6,13 @@ from proposals.models import Proposal from studies.models import Study -from attachments.models import Attachment + +class desiredness: + REQUIRED = _("Verplicht") + RECOMMENDED = _("Aangeraden") + OPTIONAL = _("Optioneel") + EXTRA = _("Extra") + class AttachmentKind: """Defines a kind of file attachment and when it is required.""" @@ -16,58 +22,7 @@ class AttachmentKind: description = "" max_num = None attached_field = "attachments" - - def __init__(self, owner=None): - self.owner = owner - - @classmethod - def from_proposal(cls, proposal, attachment): - kind = get_kind_from_str(attachment.kind) - if kind.model is Proposal: - return kind(owner=proposal) - # This might be more efficient in a QS - for study in proposal.studies: - if attachment in study.attachments: - return kind(owner=study) - msg = f"Attachment {attachment.pk} not found for proposal {proposal}" - raise KeyError(msg) - - def get_slots(self, manager=None): - slots = [] - for inst in self.get_instances_for_object(): - slots.append(AttachmentSlot(self, attachment=inst, manager=manager,)) - for i in range(self.still_required()): - slots.append(AttachmentSlot(self, manager=manager,)) - return slots - - def num_required(self): - return 0 - - def num_suggested(self): - return 0 - - def num_provided(self): - return self.get_instances_for_object().count() - - def still_required(self): - return self.num_required() - self.num_provided() - - def test_required(self): - """Returns False if the given proposal requires this kind - of attachment""" - return self.num_required() > self.num_provided() - - def test_recommended(self): - """Returns True if the given proposal recommends, but does not - necessarily require this kind of attachment""" - return True - - def get_attach_url(self): - url_kwargs = { - "other_pk": self.owner.pk, - "kind": self.db_name, - } - return reverse("proposals:attach_file", kwargs=url_kwargs) + desiredness = desiredness.OPTIONAL class AttachmentSlot(renderable): @@ -78,15 +33,13 @@ def __init__( self, attached_object, attachment=None, - manager=None, kind=None, + force_desiredness=None, ): self.attachment = attachment self.attached_object = attached_object - self.desiredness = _("Verplicht") - self.manager = manager self.kind = kind - self.match_attachment() + self.force_desiredness = force_desiredness def match_attachment(self): """ @@ -96,9 +49,11 @@ def match_attachment(self): self.attachment = instance break + @property def desiredness(self): - if self.force_required: + if self.force_desiredness: return self.force_desiredness + return self.kind.desiredness def get_instances_for_slot(self,): manager = getattr( From 4212ced7046fe933c8d9cc3468d34b02b5be7e44 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Fri, 11 Oct 2024 16:17:50 +0200 Subject: [PATCH 30/51] feat: New add_slot method --- attachments/utils.py | 14 ++++++++++---- proposals/utils/checkers.py | 6 +++--- proposals/utils/stepper.py | 11 +++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/attachments/utils.py b/attachments/utils.py index f42e0a6ec..a75db2e83 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -41,13 +41,15 @@ def __init__( self.kind = kind self.force_desiredness = force_desiredness - def match_attachment(self): + def match(self, exclude): """ - Tries to fill this slot with an existing attachment. + Tries to fill this slot with an existing attachment that is not + in the exclusion set of already matched attachments. """ for instance in self.get_instances_for_slot(): - self.attachment = instance - break + if instance not in exclude: + self.attachment = instance + break @property def desiredness(self): @@ -56,6 +58,10 @@ def desiredness(self): return self.kind.desiredness def get_instances_for_slot(self,): + """ + Returns a QS of existing Attachments that potentially + could fit in this slot. + """ manager = getattr( self.attached_object, "attachments", diff --git a/proposals/utils/checkers.py b/proposals/utils/checkers.py index af5eaf072..9dffa4f6b 100644 --- a/proposals/utils/checkers.py +++ b/proposals/utils/checkers.py @@ -11,7 +11,7 @@ from tasks.views import task_views, session_views from tasks.models import Task, Session -from attachments.utils import AttachmentSlot +from attachments.utils import AttachmentSlot, desiredness from attachments.kinds import InformationLetter, DataManagementPlan from .stepper_helpers import ( @@ -448,7 +448,7 @@ def check( self.study, kind=kind, ) - self.stepper.attachment_slots.append(info_slot) + self.stepper.add_slot(info_slot) return [] class ParticipantsChecker( @@ -819,7 +819,7 @@ def add_dmp_slot(self): self.stepper.proposal, kind=DataManagementPlan, ) - self.stepper.attachment_slots.append(slot) + self.stepper.add_slot(slot) def make_stepper_item(self): url = reverse( diff --git a/proposals/utils/stepper.py b/proposals/utils/stepper.py index 4402293ef..cc7924437 100644 --- a/proposals/utils/stepper.py +++ b/proposals/utils/stepper.py @@ -146,6 +146,17 @@ def check_all(self, next_checkers): # Recurse until next_checkers is empty return self.check_all(next_checkers) + def add_slot(self, slot): + """ + Append an attachment slot to the stepper. As an intermediate step, + we attempt to match the slot to an existing attachment. We do this + here because the stepper has ownership of the already matched + attachments to be excluded from matching. + """ + exclude = [slot.attachment for slot in self.attachment_slots] + slot.match(exclude) + self.attachment_slots.append(slot) + def has_multiple_studies( self, ): From 31415551e619ad9e8683acd4c0ee72a392bb938d Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Fri, 11 Oct 2024 16:18:06 +0200 Subject: [PATCH 31/51] feat: Template enhancements and cleanup --- attachments/templates/attachments/slot.html | 2 +- proposals/templates/proposals/attachments.html | 4 ++-- proposals/views/attachment_views.py | 2 +- proposals/views/proposal_views.py | 3 --- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/attachments/templates/attachments/slot.html b/attachments/templates/attachments/slot.html index 48434e4e4..8a038ee40 100644 --- a/attachments/templates/attachments/slot.html +++ b/attachments/templates/attachments/slot.html @@ -6,7 +6,7 @@ {{ slot.desiredness }}
-

{{slot.kind.name}}

+
{{slot.kind.name}}
{% if slot.attachment %} {% include slot.attachment with proposal=proposal %} {% else %} diff --git a/proposals/templates/proposals/attachments.html b/proposals/templates/proposals/attachments.html index 9f653a0f3..53c8deb76 100644 --- a/proposals/templates/proposals/attachments.html +++ b/proposals/templates/proposals/attachments.html @@ -10,17 +10,17 @@ {% block pre-form-text %}

{% trans "Documenten" %}

-

{% trans "Algemeen" %}

+

{% trans "Algemeen" %}

{% for slot in proposal_slots %} {% include slot %} {% endfor %} {% for study, slots in study_slots.items %} +

{% trans "Traject " %} {{ study.order }} {% if study.name %}: {{ study.name }} {% endif %}

{% for slot in slots %} {% include slot with manager=manager %} {% endfor %} {% endfor %} -

{% trans "Overig" %}

{% block auto-form-render %} {% endblock %} {% endblock %} diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index 9bbdbb2d1..727931add 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -5,7 +5,7 @@ from proposals.mixins import ProposalContextMixin from proposals.models import Proposal from studies.models import Study -from attachments.utils import ProposalAttachments, get_kind_from_str +from attachments.utils import get_kind_from_str from attachments.models import Attachment, ProposalAttachment, StudyAttachment from main.forms import ConditionalModelForm from cdh.core import forms as cdh_forms diff --git a/proposals/views/proposal_views.py b/proposals/views/proposal_views.py index d910b4334..c3d3ffdb7 100644 --- a/proposals/views/proposal_views.py +++ b/proposals/views/proposal_views.py @@ -57,9 +57,6 @@ ) from proposals.utils.proposal_utils import FilenameFactory -from attachments.utils import ProposalAttachments - - ############ # List views ############ From d82222cd007df8c409cf5784733d666fde031f0e Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 14 Oct 2024 22:10:03 +0200 Subject: [PATCH 32/51] style: Black --- attachments/apps.py | 4 +- attachments/kinds.py | 7 ++- attachments/models.py | 32 ++++++---- attachments/utils.py | 37 +++++++----- attachments/views.py | 13 ++-- proposals/mixins.py | 10 +++- proposals/urls.py | 10 +--- proposals/utils/checkers.py | 10 +++- proposals/views/attachment_views.py | 92 +++++++++++++++++++---------- 9 files changed, 135 insertions(+), 80 deletions(-) diff --git a/attachments/apps.py b/attachments/apps.py index 87623ad5f..95e98c547 100644 --- a/attachments/apps.py +++ b/attachments/apps.py @@ -2,5 +2,5 @@ class AttachmentsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'attachments' + default_auto_field = "django.db.models.BigAutoField" + name = "attachments" diff --git a/attachments/kinds.py b/attachments/kinds.py index 98121d933..05112c00f 100644 --- a/attachments/kinds.py +++ b/attachments/kinds.py @@ -13,11 +13,13 @@ class ProposalAttachmentKind(AttachmentKind): attached_object = Proposal attachment_class = ProposalAttachment + class StudyAttachmentKind(AttachmentKind): attached_object = Study attachment_class = StudyAttachment + class InformationLetter(StudyAttachmentKind): db_name = "information_letter" @@ -25,12 +27,14 @@ class InformationLetter(StudyAttachmentKind): description = _("Omschrijving informatiebrief") desiredness = desiredness.REQUIRED + class ConsentForm(AttachmentKind): db_name = "consent_form" name = _("Toestemmingsverklaring") description = _("Omschrijving toestemmingsverklaring") + class DataManagementPlan(ProposalAttachmentKind): db_name = "dmp" @@ -40,6 +44,7 @@ class DataManagementPlan(ProposalAttachmentKind): def num_recommended(self): return 1 + class OtherProposalAttachment(ProposalAttachmentKind): db_name = "other" @@ -55,7 +60,6 @@ def num_suggested(self): return self.num_provided + 1 - STUDY_ATTACHMENTS = [ InformationLetter, ConsentForm, @@ -67,4 +71,3 @@ def num_suggested(self): ] ATTACHMENTS = PROPOSAL_ATTACHMENTS + STUDY_ATTACHMENTS - diff --git a/attachments/models.py b/attachments/models.py index 702f27e8a..d38a11016 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -7,6 +7,7 @@ # Create your models here. + class Attachment(models.Model, renderable): template_name = "attachments/attachment_model.html" @@ -42,9 +43,8 @@ class Attachment(models.Model, renderable): max_length=50, default="", help_text=_( - "Geef je bestand een omschrijvende naam, het liefst " - "maar enkele woorden." - ) + "Geef je bestand een omschrijvende naam, het liefst " "maar enkele woorden." + ), ) comments = models.TextField( max_length=2000, @@ -54,7 +54,7 @@ class Attachment(models.Model, renderable): "waar " "je het voor gaat gebruiken tijdens je onderzoek. Eventuele " "opmerkingen voor de FETC kun je hier ook kwijt." - ) + ), ) def get_correct_submodel(self): @@ -71,9 +71,7 @@ def get_correct_submodel(self): key = submodel.__name__.lower() if hasattr(self, key): return getattr(self, key) - raise KeyError( - "Couldn't find a matching submodel." - ) + raise KeyError("Couldn't find a matching submodel.") def detach(self, other_object): """ @@ -100,26 +98,38 @@ def get_context_data(self, **kwargs): context["attachment"] = self return context -class ProposalAttachment(Attachment,): + +class ProposalAttachment( + Attachment, +): attached_to = models.ManyToManyField( "proposals.Proposal", related_name="attachments", ) - def get_owner_for_proposal(self, proposal,): + def get_owner_for_proposal( + self, + proposal, + ): """ This method doesn't do much, it's just here to provide a consistent interface for getting owner objects. """ return proposal -class StudyAttachment(Attachment,): + +class StudyAttachment( + Attachment, +): attached_to = models.ManyToManyField( "studies.Study", related_name="attachments", ) - def get_owner_for_proposal(self, proposal,): + def get_owner_for_proposal( + self, + proposal, + ): """ Gets the owner study based on given proposal. """ diff --git a/attachments/utils.py b/attachments/utils.py index a75db2e83..21df9e7ca 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -30,11 +30,11 @@ class AttachmentSlot(renderable): template_name = "attachments/slot.html" def __init__( - self, - attached_object, - attachment=None, - kind=None, - force_desiredness=None, + self, + attached_object, + attachment=None, + kind=None, + force_desiredness=None, ): self.attachment = attachment self.attached_object = attached_object @@ -57,7 +57,9 @@ def desiredness(self): return self.force_desiredness return self.kind.desiredness - def get_instances_for_slot(self,): + def get_instances_for_slot( + self, + ): """ Returns a QS of existing Attachments that potentially could fit in this slot. @@ -82,7 +84,9 @@ def get_proposal(self): else: return self.attached_object.proposal - def get_attach_url(self,): + def get_attach_url( + self, + ): url_name = { Proposal: "proposals:attach_proposal", Study: "proposals:attach_study", @@ -97,28 +101,31 @@ def get_attach_url(self,): kwargs=reverse_kwargs, ) - def get_delete_url(self,): + def get_delete_url( + self, + ): return reverse( "proposals:detach", kwargs={ "attachment_pk": self.attachment.pk, "other_pk": self.attached_object.pk, - } + }, ) - def get_edit_url(self,): + def get_edit_url( + self, + ): return reverse( "proposals:update_attachment", kwargs={ "attachment_pk": self.attachment.pk, "other_pk": self.attached_object.pk, - } + }, ) + def get_kind_from_str(db_name): from attachments.kinds import ATTACHMENTS - kinds = { - kind.db_name: kind - for kind in ATTACHMENTS - } + + kinds = {kind.db_name: kind for kind in ATTACHMENTS} return kinds[db_name] diff --git a/attachments/views.py b/attachments/views.py index f1da74ee6..541c2f517 100644 --- a/attachments/views.py +++ b/attachments/views.py @@ -8,6 +8,7 @@ # Create your views here. + class AttachmentForm(ModelForm): class Meta: @@ -29,6 +30,7 @@ def get_initial(self, *args, **kwargs): initial["kind"] = self.kind return initial + class AttachmentCreateView(generic.CreateView): """Generic create view to create a new attachment. Both other_model and template_name should be provided externally, either through the URL @@ -59,18 +61,13 @@ def get_form_kwargs(self): def attach(self, attachment): if self.other_model is None: raise ImproperlyConfigured( - "Please provide an other_model as a target for " - "this attachment." + "Please provide an other_model as a target for " "this attachment." ) other_pk = self.kwargs.get(self.other_pk_kwarg) - other_object = self.other_model.objects.get( - pk=other_pk - ) + other_object = self.other_model.objects.get(pk=other_pk) manager = getattr(other_object, self.other_field_name) manager.add(attachment) other_object.save() def get_success_url(self): - raise ImproperlyConfigured( - "Please define get_success_url()" - ) + raise ImproperlyConfigured("Please define get_success_url()") diff --git a/proposals/mixins.py b/proposals/mixins.py index 2bbffd12e..5d4c2f391 100644 --- a/proposals/mixins.py +++ b/proposals/mixins.py @@ -25,8 +25,13 @@ def get_context_data(self, *args, **kwargs): context["stepper"] = self.get_stepper() return context - def get_stepper(self,): - if hasattr(self, "stepper",): + def get_stepper( + self, + ): + if hasattr( + self, + "stepper", + ): return self.stepper # Try to determine proposal proposal = Proposal() @@ -34,6 +39,7 @@ def get_stepper(self,): proposal = self.get_proposal() # Importing here to prevent circular import from .utils.stepper import Stepper + # Initialize and insert stepper object self.stepper = Stepper( proposal, diff --git a/proposals/urls.py b/proposals/urls.py index 0fe9ed539..1b84ecf5b 100644 --- a/proposals/urls.py +++ b/proposals/urls.py @@ -197,18 +197,12 @@ ), path( "attach_proposal/extra//", - ProposalAttachView.as_view( - owner_model=Proposal, - extra=True - ), + ProposalAttachView.as_view(owner_model=Proposal, extra=True), name="attach_proposal", ), path( "attach_study/extra//", - ProposalAttachView.as_view( - owner_model=Study, - extra=True - ), + ProposalAttachView.as_view(owner_model=Study, extra=True), name="attach_study", ), path( diff --git a/proposals/utils/checkers.py b/proposals/utils/checkers.py index 9dffa4f6b..2cb954500 100644 --- a/proposals/utils/checkers.py +++ b/proposals/utils/checkers.py @@ -432,16 +432,21 @@ def make_sessions(self, study): parent=self.current_parent, ) + class StudyAttachmentsChecker( Checker, ): - def __init__(self, *args, **kwargs,): + def __init__( + self, + *args, + **kwargs, + ): self.study = kwargs.pop("study") super().__init__(*args, **kwargs) def check( - self, + self, ): kind = InformationLetter info_slot = AttachmentSlot( @@ -451,6 +456,7 @@ def check( self.stepper.add_slot(info_slot) return [] + class ParticipantsChecker( ModelFormChecker, ): diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index 727931add..48d00160e 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -15,7 +15,7 @@ class AttachForm( - cdh_forms.TemplatedModelForm, + cdh_forms.TemplatedModelForm, ): class Meta: @@ -39,7 +39,9 @@ def __init__(self, kind=None, other_object=None, extra=False, **kwargs): if not extra: del self.fields["kind"] - def save(self,): + def save( + self, + ): self.instance.kind = self.kind.db_name self.instance.save() self.instance.attached_to.add( @@ -48,7 +50,7 @@ def save(self,): return super().save() -class AttachFormView(): +class AttachFormView: model = Attachment form_class = AttachForm @@ -88,25 +90,31 @@ def get_kind(self): kind_str = self.kwargs.get("kind") return get_kind_from_str(kind_str) - def get_success_url(self,): + def get_success_url( + self, + ): return reverse( "proposals:attachments", kwargs={"pk": self.get_proposal().pk}, ) - def get_form_kwargs(self,): + def get_form_kwargs( + self, + ): kwargs = super().get_form_kwargs() - kwargs.update({ - "kind": self.get_kind(), - "other_object": self.get_owner_object(), - }) + kwargs.update( + { + "kind": self.get_kind(), + "other_object": self.get_owner_object(), + } + ) return kwargs class ProposalAttachView( - AttachFormView, - ProposalContextMixin, - generic.CreateView, + AttachFormView, + ProposalContextMixin, + generic.CreateView, ): model = Attachment @@ -119,17 +127,20 @@ def get_kind(self): kind_str = self.kwargs.get("kind") return get_kind_from_str(kind_str) + class ProposalUpdateAttachmentView( - AttachFormView, - ProposalContextMixin, - generic.UpdateView, + AttachFormView, + ProposalContextMixin, + generic.UpdateView, ): model = Attachment form_class = AttachForm template_name = "proposals/attach_form.html" editing = True - def get_object(self,): + def get_object( + self, + ): attachment_pk = self.kwargs.get("attachment_pk") attachment = Attachment.objects.get(pk=attachment_pk) obj = attachment.get_correct_submodel() @@ -145,22 +156,26 @@ def get_kind(self): kind_str = obj.kind return get_kind_from_str(kind_str) + class DetachForm( - forms.Form, + forms.Form, ): confirmation = forms.BooleanField() + class ProposalDetachView( - ProposalContextMixin, - generic.detail.SingleObjectMixin, - generic.FormView, + ProposalContextMixin, + generic.detail.SingleObjectMixin, + generic.FormView, ): form_class = DetachForm model = Attachment template_name = "proposals/detach_form.html" pk_url_kwarg = "attachment_pk" - def get_owner_object(self,): + def get_owner_object( + self, + ): attachment = self.get_object() return attachment.get_owner_for_proposal( self.get_proposal(), @@ -171,11 +186,15 @@ def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) return context - def get_object(self,): + def get_object( + self, + ): obj = super().get_object() return obj.get_correct_submodel() - def get_proposal(self,): + def get_proposal( + self, + ): proposal_pk = self.kwargs.get("proposal_pk") return Proposal.objects.get(pk=proposal_pk) @@ -184,21 +203,25 @@ def form_valid(self, form): attachment.detach(self.get_owner_object()) return super().form_valid(form) - def get_success_url(self,): + def get_success_url( + self, + ): return reverse( "proposals:attachments", kwargs={"pk": self.get_proposal().pk}, ) + class AttachmentDetailView( - generic.DetailView, + generic.DetailView, ): template_name = "proposals/attachment_detail.html" model = Attachment + class ProposalAttachmentsView( - ProposalContextMixin, - generic.DetailView, + ProposalContextMixin, + generic.DetailView, ): template_name = "proposals/attachments.html" @@ -222,15 +245,24 @@ def get_context_data(self, **kwargs): class ProposalAttachmentDownloadView( - generic.View, + generic.View, ): original_filename = False - def __init__(self, *args, **kwargs,): + def __init__( + self, + *args, + **kwargs, + ): self.original_filename = kwargs.pop("original_filename", False) super().__init__(*args, **kwargs) - def get(self, request, proposal_pk, attachment_pk,): + def get( + self, + request, + proposal_pk, + attachment_pk, + ): self.attachment = Attachment.objects.get( pk=attachment_pk, ) From 19ca5e103f87276d13c66aba6dd559bcb9298ee7 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 14 Oct 2024 22:12:31 +0200 Subject: [PATCH 33/51] style: djlint --- .../attachments/attachment_model.html | 5 +-- .../attachments/base_single_attachment.html | 13 ++----- attachments/templates/attachments/slot.html | 15 +++----- .../templates/proposals/attach_form.html | 34 ++++++++----------- .../templates/proposals/attachments.html | 31 +++++++++-------- 5 files changed, 39 insertions(+), 59 deletions(-) diff --git a/attachments/templates/attachments/attachment_model.html b/attachments/templates/attachments/attachment_model.html index a3116d9f4..351a2c2a6 100644 --- a/attachments/templates/attachments/attachment_model.html +++ b/attachments/templates/attachments/attachment_model.html @@ -1,5 +1,2 @@ - - - -{{attachment.upload.original_filename}} +{{ attachment.upload.original_filename }} diff --git a/attachments/templates/attachments/base_single_attachment.html b/attachments/templates/attachments/base_single_attachment.html index 3a3548ae8..66e01727a 100644 --- a/attachments/templates/attachments/base_single_attachment.html +++ b/attachments/templates/attachments/base_single_attachment.html @@ -1,14 +1,8 @@ - -
-
-
- {{ac.get_origin_display}} asdf -
+
+
{{ ac.get_origin_display }} asdf
-

- Attachment kind -

+

Attachment kind

attachment_filename.docx
{% for action in container.get_actions %} @@ -18,4 +12,3 @@

- diff --git a/attachments/templates/attachments/slot.html b/attachments/templates/attachments/slot.html index 8a038ee40..7826057a6 100644 --- a/attachments/templates/attachments/slot.html +++ b/attachments/templates/attachments/slot.html @@ -1,12 +1,9 @@ {% load i18n %}
- -
- {{ slot.desiredness }} -
+
{{ slot.desiredness }}
-
{{slot.kind.name}}
+
{{ slot.kind.name }}
{% if slot.attachment %} {% include slot.attachment with proposal=proposal %} {% else %} @@ -16,13 +13,9 @@
{{slot.kind.name}}
diff --git a/proposals/templates/proposals/attach_form.html b/proposals/templates/proposals/attach_form.html index b3cb01401..76f7132e2 100644 --- a/proposals/templates/proposals/attach_form.html +++ b/proposals/templates/proposals/attach_form.html @@ -1,4 +1,5 @@ {% extends "base/fetc_form_base.html" %} + {% load i18n %} {% load static %} @@ -15,7 +16,7 @@

{% trans "Maak of wijzig een bestand" %}

{% endblocktrans %} {% else %} {% blocktrans trimmed %} - Je bewerkt het volgende bestand: + Je bewerkt het volgende bestand: {% endblocktrans %}

{% include object with proposal=view.get_proposal %}

{% blocktrans trimmed %} @@ -26,39 +27,34 @@

{% trans "Maak of wijzig een bestand" %}

{% if study %} {% blocktrans with order=study.order name=study.name trimmed %} - Traject {{order}}: {{name}} van + Traject {{ order }}: {{ name }} van {% endblocktrans %} {% endif %} {% trans "de aanvraag" %} {{ proposal.title }}.

{% if not view.editing %} - {% blocktrans with name=kind_name trimmed %} - Je gaat hier een {{name}} aan toevoegen. Wil je een ander soort bestand toevoegen? Ga dan terug naar de vorige pagina. - {% endblocktrans %} + {% blocktrans with name=kind_name trimmed %} + Je gaat hier een {{ name }} aan toevoegen. Wil je een ander soort bestand toevoegen? Ga dan terug naar de vorige pagina. + {% endblocktrans %} {% endif %} - - -

+

{% endblock %} {% block form-buttons %} -
- - + {% trans '<< Ga terug' %} -{% if view.editing %} - - {% trans 'Bestand verwijderen' %} - -{% endif %} - + {% if view.editing %} + + {% trans 'Bestand verwijderen' %} + + {% endif %} -
- {% endblock %} diff --git a/proposals/templates/proposals/attachments.html b/proposals/templates/proposals/attachments.html index 53c8deb76..5584d17c4 100644 --- a/proposals/templates/proposals/attachments.html +++ b/proposals/templates/proposals/attachments.html @@ -4,23 +4,24 @@ {% load i18n %} {% block header_title %} -{% trans "Informatie over betrokken onderzoekers" %} - {{ block.super }} + {% trans "Informatie over betrokken onderzoekers" %} - {{ block.super }} {% endblock %} - {% block pre-form-text %} -

{% trans "Documenten" %}

-

{% trans "Algemeen" %}

-{% for slot in proposal_slots %} - {% include slot %} -{% endfor %} -{% for study, slots in study_slots.items %} -
-

{% trans "Traject " %} {{ study.order }} {% if study.name %}: {{ study.name }} {% endif %}

- {% for slot in slots %} - {% include slot with manager=manager %} +

{% trans "Documenten" %}

+

{% trans "Algemeen" %}

+ {% for slot in proposal_slots %} + {% include slot %} {% endfor %} -{% endfor %} -{% block auto-form-render %} -{% endblock %} + {% for study, slots in study_slots.items %} +
+

+ {% trans "Traject " %} {{ study.order }} + {% if study.name %}: {{ study.name }}{% endif %} +

+ {% for slot in slots %} + {% include slot with manager=manager %} + {% endfor %} + {% endfor %} + {% block auto-form-render %}{% endblock %} {% endblock %} From ec17850e6a6d63ee41f57797a0edad72888dad36 Mon Sep 17 00:00:00 2001 From: Edo Storm Date: Tue, 22 Oct 2024 14:17:59 +0200 Subject: [PATCH 34/51] add cdh_files/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 71f2b417a..156f04204 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ certs/ fetc/ldap_settings.py fetc/saml_settings.py *.sqlite3 +cdh_files/ ### Coverage ### .coverage From 563154b05a728c851371dcceacbf86cf4579f586 Mon Sep 17 00:00:00 2001 From: Edo Storm Date: Wed, 23 Oct 2024 13:33:19 +0200 Subject: [PATCH 35/51] fix: create and implement attachment filename generator --- attachments/models.py | 9 ++--- .../attachments/attachment_model.html | 2 +- attachments/utils.py | 34 +++++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/attachments/models.py b/attachments/models.py index d38a11016..7f5f6a2a9 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -2,12 +2,10 @@ from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from main.utils import renderable +from attachments.utils import attachment_filename_generator from cdh.files.db import FileField as CDHFileField -# Create your models here. - - class Attachment(models.Model, renderable): template_name = "attachments/attachment_model.html" @@ -21,6 +19,7 @@ class Attachment(models.Model, renderable): upload = CDHFileField( verbose_name=_("Bestand"), help_text=_("Selecteer hier het bestand om toe te voegen."), + filename_generator=attachment_filename_generator, ) parent = models.ForeignKey( "attachments.attachment", @@ -41,10 +40,6 @@ class Attachment(models.Model, renderable): ) name = models.CharField( max_length=50, - default="", - help_text=_( - "Geef je bestand een omschrijvende naam, het liefst " "maar enkele woorden." - ), ) comments = models.TextField( max_length=2000, diff --git a/attachments/templates/attachments/attachment_model.html b/attachments/templates/attachments/attachment_model.html index 351a2c2a6..cec49341d 100644 --- a/attachments/templates/attachments/attachment_model.html +++ b/attachments/templates/attachments/attachment_model.html @@ -1,2 +1,2 @@ -{{ attachment.upload.original_filename }} +{{ attachment.upload }} diff --git a/attachments/utils.py b/attachments/utils.py index 21df9e7ca..ccaec6c40 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -1,3 +1,5 @@ +import mimetypes + from django.template.loader import get_template from django.utils.translation import gettext as _ from django.urls import reverse @@ -123,6 +125,38 @@ def get_edit_url( }, ) +def attachment_filename_generator(file): + #get the correct attachment + try: + attachment = ProposalAttachment.objects.get(upload=file.file_instance) + proposal = attachment.attached_to.last() + trajectory = None + except ProposalAttachment.DoesNotExist: + attachment = StudyAttachment.objects.get(upload=file.file_instance) + study = attachment.attached_to.last() + proposal = study.proposal + trajectory = f"T{study.order}" + + chamber = proposal.reviewing_committee.name + lastname = proposal.created_by.last_name + refnum = proposal.reference_number + kind = attachment.kind + extension = mimetypes.guess_extension(file.file_instance.content_type) + + fn_parts = [ + "FETC", + chamber, + refnum, + lastname, + trajectory, + kind, + ] + + # Translations will trip up join(), so we convert them here + fn_parts = [str(p) for p in fn_parts if p] + + return "-".join(fn_parts) + extension + def get_kind_from_str(db_name): from attachments.kinds import ATTACHMENTS From f489256d8f4ce145c7b384cec17677f24e07eb89 Mon Sep 17 00:00:00 2001 From: Edo Storm Date: Mon, 25 Nov 2024 14:14:09 +0100 Subject: [PATCH 36/51] feat: proper implementation of filename generator --- ...attachment_kind_alter_attachment_upload.py | 37 +++++++++++++++++++ .../attachments/attachment_model.html | 2 +- attachments/utils.py | 4 +- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 attachments/migrations/0005_alter_attachment_kind_alter_attachment_upload.py diff --git a/attachments/migrations/0005_alter_attachment_kind_alter_attachment_upload.py b/attachments/migrations/0005_alter_attachment_kind_alter_attachment_upload.py new file mode 100644 index 000000000..f3a065139 --- /dev/null +++ b/attachments/migrations/0005_alter_attachment_kind_alter_attachment_upload.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.11 on 2024-11-25 11:18 + +import attachments.utils +import cdh.files.db.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0004_auto_20210921_1014"), + ("attachments", "0004_alter_attachment_kind"), + ] + + operations = [ + migrations.AlterField( + model_name="attachment", + name="kind", + field=models.CharField( + default=("other", "Overig bestand"), + max_length=100, + verbose_name="Type bestand", + ), + ), + migrations.AlterField( + model_name="attachment", + name="upload", + field=cdh.files.db.fields.FileField( + filename_generator=attachments.utils.attachment_filename_generator, + help_text="Selecteer hier het bestand om toe te voegen.", + on_delete=django.db.models.deletion.CASCADE, + to="files.file", + verbose_name="Bestand", + ), + ), + ] diff --git a/attachments/templates/attachments/attachment_model.html b/attachments/templates/attachments/attachment_model.html index ab6a43757..5be78a781 100644 --- a/attachments/templates/attachments/attachment_model.html +++ b/attachments/templates/attachments/attachment_model.html @@ -3,5 +3,5 @@ {{ attachment.upload.original_filename }} {% if include_normalized %} - ({% trans "FETC-bestandsnaam" %}) + ({{ attachment.upload }}) {% endif %} diff --git a/attachments/utils.py b/attachments/utils.py index 7d2d6d35e..302f03fd1 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -250,7 +250,9 @@ def merge_groups(slots): return out def attachment_filename_generator(file): - #get the correct attachment + from attachments.kinds import ProposalAttachment, StudyAttachment + + #try to get a Proposal attachment, otherwise, it must be a Study attachment try: attachment = ProposalAttachment.objects.get(upload=file.file_instance) proposal = attachment.attached_to.last() From a8edd10e7c19a5295d684bc37bbd177766e075d6 Mon Sep 17 00:00:00 2001 From: Edo Storm Date: Mon, 25 Nov 2024 14:16:14 +0100 Subject: [PATCH 37/51] fix: bug in Study.research_settings_contains_schools --- studies/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/studies/models.py b/studies/models.py index 7232d9e2e..e0c58b705 100644 --- a/studies/models.py +++ b/studies/models.py @@ -448,16 +448,16 @@ def has_no_sessions(self): def research_settings_contains_schools(self): """Checks if any research track contains a school in it's setting""" - if self.has_intervention and self.intervention.settings_contains_schools(): + if self.get_intervention and self.intervention.settings_contains_schools(): return True if ( - self.has_sessions + self.get_sessions and self.session_set.filter(setting__is_school=True).exists() ): return True - if self.has_observation and self.observation.settings_contains_schools(): + if self.get_observation and self.observation.settings_contains_schools(): return True return False From dd68a5f73a21cd647e865904bf557e4436356975 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 6 Jan 2025 15:22:46 +0100 Subject: [PATCH 38/51] fix: Remove FnGen from model --- attachments/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/attachments/models.py b/attachments/models.py index 7a08b5b69..ae8e1f629 100644 --- a/attachments/models.py +++ b/attachments/models.py @@ -3,7 +3,6 @@ from django.utils.translation import gettext as _ from django.urls import reverse from main.utils import renderable -from attachments.utils import attachment_filename_generator from cdh.files.db import FileField as CDHFileField @@ -24,8 +23,6 @@ class Attachment(models.Model, renderable): upload = CDHFileField( verbose_name=_("Bestand"), help_text=_("Selecteer hier het bestand om toe te voegen."), - filename_generator=attachment_filename_generator, - ) parent = models.ForeignKey( "attachments.attachment", From 36b8d723a6a5757fe0ec0add55de92369eac6980 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 6 Jan 2025 16:01:42 +0100 Subject: [PATCH 39/51] feat: Auto retrieve Kind from attachment if none provided --- attachments/utils.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/attachments/utils.py b/attachments/utils.py index 38cd69c65..143441da1 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -42,7 +42,10 @@ def __init__( ): self.attachment = attachment self.attached_object = attached_object - self.kind = kind + if kind: + self.kind = kind + else: + self.kind = self.get_kind_from_attachment() self.force_desiredness = force_desiredness self.optionality_group = optionality_group if self.optionality_group: @@ -70,6 +73,14 @@ def match_and_set(self, exclude): return self.attachment return False + def get_kind_from_attachment(self,): + if not self.attachment: + raise RuntimeError( + "An AttachmentSlot must be provided with either a kind " + "or an attachment" + ) + return get_kind_from_str(self.attachment.kind) + @property def classes(self): if self.required: From c58e0bb88635308f4a728886363f4ad642c32cc7 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 6 Jan 2025 16:02:17 +0100 Subject: [PATCH 40/51] feat: Classmethod to get a slot from a proposal --- attachments/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/attachments/utils.py b/attachments/utils.py index 143441da1..55237a3e1 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -51,6 +51,14 @@ def __init__( if self.optionality_group: self.optionality_group.members.append(self) + @classmethod + def from_proposal(attachment, proposal): + attached_object = attachment.get_owner_for_proposal(proposal) + return AttachmentSlot( + attached_object, + attachment=attachment, + ) + def match(self, exclude=[]): """ Tries to find a matching attachment for this slot. If it finds one, From b76cdb4541aa7823e034acec778f6f7ab30cfcd7 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 6 Jan 2025 16:04:17 +0100 Subject: [PATCH 41/51] wip: Get filename from slot instead of kind --- attachments/utils.py | 2 +- proposals/views/attachment_views.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/attachments/utils.py b/attachments/utils.py index 55237a3e1..3dd21764b 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -314,7 +314,7 @@ def attachment_filename_generator(file): study = attachment.attached_to.last() proposal = study.proposal trajectory = f"T{study.order}" - + chamber = proposal.reviewing_committee.name lastname = proposal.created_by.last_name refnum = proposal.reference_number diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index 41d493b41..69b01d6c9 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -16,7 +16,7 @@ from reviews.mixins import HideStepperMixin from django.utils.translation import gettext as _ from attachments.kinds import ATTACHMENTS, KIND_CHOICES -from attachments.utils import AttachmentKind, merge_groups +from attachments.utils import AttachmentKind, merge_groups, AttachmentSlot from cdh.core import forms as cdh_forms from django.utils.translation import gettext as _ from reviews.mixins import UsersOrGroupsAllowedMixin @@ -371,12 +371,12 @@ def get_filename(self): else: return self.get_filename_from_kind() - def get_filename_from_kind(self): - self.kind = AttachmentKind.from_proposal( - self.proposal, + def get_filename_from_slot(self): + self.slot = AttachmentSlot.from_proposal( self.attachment, + self.proposal, ) - return self.kind.name + return self.slot.get_fetc_filename() def get_file_response(self): attachment_file = self.attachment.upload.file From e065c2e25b0bbb8382a0fdc11d956a595593a028 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 6 Jan 2025 16:57:07 +0100 Subject: [PATCH 42/51] feat: Rename and finalize generate_filename() function --- attachments/utils.py | 46 +++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/attachments/utils.py b/attachments/utils.py index 3dd21764b..d26720657 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -301,36 +301,34 @@ def merge_groups(slots): out.append(item) return out -def attachment_filename_generator(file): - from attachments.kinds import ProposalAttachment, StudyAttachment - - #try to get a Proposal attachment, otherwise, it must be a Study attachment - try: - attachment = ProposalAttachment.objects.get(upload=file.file_instance) - proposal = attachment.attached_to.last() - trajectory = None - except ProposalAttachment.DoesNotExist: - attachment = StudyAttachment.objects.get(upload=file.file_instance) - study = attachment.attached_to.last() - proposal = study.proposal - trajectory = f"T{study.order}" +def generate_filename(slot): + proposal = slot.get_proposal() chamber = proposal.reviewing_committee.name lastname = proposal.created_by.last_name refnum = proposal.reference_number - kind = attachment.kind - extension = mimetypes.guess_extension(file.file_instance.content_type) + original_fn = slot.attachment.upload.original_filename + kind = slot.kind.name + + extension = ( + "." + original_fn.split(".")[-1][-7:] + ) # At most 7 chars seems reasonable + + trajectory = None + if not type(slot.attached_object) is Proposal: + trajectory = "T" + str(slot.attached_object.order) fn_parts = [ - "FETC", - chamber, - refnum, - lastname, - trajectory, - kind, - ] - - # Translations will trip up join(), so we convert them here + "FETC", + chamber, + refnum, + lastname, + trajectory, + kind, + ] + + # Translations will trip up join(), so we convert them here. + # This will also remove the trajectory if None. fn_parts = [str(p) for p in fn_parts if p] return "-".join(fn_parts) + extension From 1b89cc9233c523619b4cc6501c978885bd860722 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 6 Jan 2025 16:57:30 +0100 Subject: [PATCH 43/51] feat: Point methods in right direction --- attachments/utils.py | 3 +++ proposals/views/attachment_views.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/attachments/utils.py b/attachments/utils.py index d26720657..e30219a4a 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -89,6 +89,9 @@ def get_kind_from_attachment(self,): ) return get_kind_from_str(self.attachment.kind) + def get_fetc_filename(self,): + return generate_filename(self) + @property def classes(self): if self.required: diff --git a/proposals/views/attachment_views.py b/proposals/views/attachment_views.py index 69b01d6c9..8a20ff015 100644 --- a/proposals/views/attachment_views.py +++ b/proposals/views/attachment_views.py @@ -369,7 +369,7 @@ def get_filename(self): if self.original_filename: return self.attachment.upload.original_filename else: - return self.get_filename_from_kind() + return self.get_filename_from_slot() def get_filename_from_slot(self): self.slot = AttachmentSlot.from_proposal( From 135b4a7a04700c869189a1f12ea3fc8c95977aea Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 6 Jan 2025 17:04:08 +0100 Subject: [PATCH 44/51] style --- attachments/utils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/attachments/utils.py b/attachments/utils.py index e30219a4a..1742264a0 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -81,7 +81,9 @@ def match_and_set(self, exclude): return self.attachment return False - def get_kind_from_attachment(self,): + def get_kind_from_attachment( + self, + ): if not self.attachment: raise RuntimeError( "An AttachmentSlot must be provided with either a kind " @@ -89,7 +91,9 @@ def get_kind_from_attachment(self,): ) return get_kind_from_str(self.attachment.kind) - def get_fetc_filename(self,): + def get_fetc_filename( + self, + ): return generate_filename(self) @property @@ -304,6 +308,7 @@ def merge_groups(slots): out.append(item) return out + def generate_filename(slot): proposal = slot.get_proposal() @@ -336,6 +341,7 @@ def generate_filename(slot): return "-".join(fn_parts) + extension + def get_kind_from_str(db_name): from attachments.kinds import ATTACHMENTS, OtherAttachment From c75fe130acda58a548be80c26721d5d5e316d88a Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Mon, 6 Jan 2025 17:47:47 +0100 Subject: [PATCH 45/51] feat: Custom fn_part for kinds --- attachments/utils.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/attachments/utils.py b/attachments/utils.py index 1742264a0..551a25ff3 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -27,6 +27,13 @@ class AttachmentKind: attached_field = "attachments" desiredness = desiredness.OPTIONAL + def get_fn_part(self): + if hasattr(self, "fn_part"): + return self.fn_part + # Capitalize DB name + parts = self.db_name.split("_") + return "_".join([part.capitalize() for part in parts]) + class AttachmentSlot(renderable): @@ -316,7 +323,7 @@ def generate_filename(slot): lastname = proposal.created_by.last_name refnum = proposal.reference_number original_fn = slot.attachment.upload.original_filename - kind = slot.kind.name + kind = slot.kind.get_fn_part() extension = ( "." + original_fn.split(".")[-1][-7:] From 352fc48fb6e5a9b028387db4785ccd09ac1af9db Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 7 Jan 2025 16:25:33 +0100 Subject: [PATCH 46/51] feat: Add order variable for Slots and FnGen --- attachments/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/attachments/utils.py b/attachments/utils.py index 551a25ff3..5383ebfe4 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -46,12 +46,14 @@ def __init__( kind=None, force_desiredness=None, optionality_group=None, + order=None, ): self.attachment = attachment self.attached_object = attached_object if kind: self.kind = kind else: + self.order = order self.kind = self.get_kind_from_attachment() self.force_desiredness = force_desiredness self.optionality_group = optionality_group @@ -324,6 +326,7 @@ def generate_filename(slot): refnum = proposal.reference_number original_fn = slot.attachment.upload.original_filename kind = slot.kind.get_fn_part() + order = slot.order extension = ( "." + original_fn.split(".")[-1][-7:] @@ -340,6 +343,7 @@ def generate_filename(slot): lastname, trajectory, kind, + order, ] # Translations will trip up join(), so we convert them here. From 4b87f00315fb8ce675d5b7c6330f57e62b198f87 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 7 Jan 2025 16:26:15 +0100 Subject: [PATCH 47/51] fix: Turn get_fn_part into classmethod --- attachments/utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/attachments/utils.py b/attachments/utils.py index 5383ebfe4..43325a5b2 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -27,11 +27,12 @@ class AttachmentKind: attached_field = "attachments" desiredness = desiredness.OPTIONAL - def get_fn_part(self): - if hasattr(self, "fn_part"): - return self.fn_part + @classmethod + def get_fn_part(cls): + if hasattr(cls, "fn_part"): + return cls.fn_part # Capitalize DB name - parts = self.db_name.split("_") + parts = cls.db_name.split("_") return "_".join([part.capitalize() for part in parts]) From 9858dfa91c47b81808b567699b14e746bea44585 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 7 Jan 2025 16:26:35 +0100 Subject: [PATCH 48/51] fix: Kind derivation for empty slot matching --- attachments/utils.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/attachments/utils.py b/attachments/utils.py index 43325a5b2..78956a0f5 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -51,11 +51,15 @@ def __init__( ): self.attachment = attachment self.attached_object = attached_object - if kind: - self.kind = kind - else: self.order = order + + if attachment and not kind: + # If an attachment was provided but no kind, + # attempt to get the kind from the attachment self.kind = self.get_kind_from_attachment() + else: + self.kind = kind + self.force_desiredness = force_desiredness self.optionality_group = optionality_group if self.optionality_group: @@ -94,11 +98,6 @@ def match_and_set(self, exclude): def get_kind_from_attachment( self, ): - if not self.attachment: - raise RuntimeError( - "An AttachmentSlot must be provided with either a kind " - "or an attachment" - ) return get_kind_from_str(self.attachment.kind) def get_fetc_filename( From ab40cdb90f2abe71c72b11764988a9fcbbc2edea Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 7 Jan 2025 16:27:02 +0100 Subject: [PATCH 49/51] fix: Delete anachronistic migration --- ...attachment_kind_alter_attachment_upload.py | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 attachments/migrations/0005_alter_attachment_kind_alter_attachment_upload.py diff --git a/attachments/migrations/0005_alter_attachment_kind_alter_attachment_upload.py b/attachments/migrations/0005_alter_attachment_kind_alter_attachment_upload.py deleted file mode 100644 index f3a065139..000000000 --- a/attachments/migrations/0005_alter_attachment_kind_alter_attachment_upload.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 4.2.11 on 2024-11-25 11:18 - -import attachments.utils -import cdh.files.db.fields -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("files", "0004_auto_20210921_1014"), - ("attachments", "0004_alter_attachment_kind"), - ] - - operations = [ - migrations.AlterField( - model_name="attachment", - name="kind", - field=models.CharField( - default=("other", "Overig bestand"), - max_length=100, - verbose_name="Type bestand", - ), - ), - migrations.AlterField( - model_name="attachment", - name="upload", - field=cdh.files.db.fields.FileField( - filename_generator=attachments.utils.attachment_filename_generator, - help_text="Selecteer hier het bestand om toe te voegen.", - on_delete=django.db.models.deletion.CASCADE, - to="files.file", - verbose_name="Bestand", - ), - ), - ] From 14e27832dae7cd23698539864e0a770288fd2ac4 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 7 Jan 2025 16:28:57 +0100 Subject: [PATCH 50/51] feat: Functions to enumerate slots --- attachments/utils.py | 42 ++++++++++++++++++++++++++++++++++++++ proposals/utils/stepper.py | 14 +++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/attachments/utils.py b/attachments/utils.py index 78956a0f5..abb09abba 100644 --- a/attachments/utils.py +++ b/attachments/utils.py @@ -1,4 +1,5 @@ import mimetypes +from collections import Counter from django.template.loader import get_template from django.utils.translation import gettext as _ @@ -352,6 +353,47 @@ def generate_filename(slot): return "-".join(fn_parts) + extension +def enumerate_slots(slots): + """ + Provides an order attribute to all attachment slots whose kind + appears more than once in the provided list. + """ + # Create seperate slot lists per attached_object + per_ao = sort_into_dict( + slots, lambda x: x.attached_object, + ).values() + # Assign orders to them separately + for ao_slots in per_ao: + assign_orders(ao_slots) + +def sort_into_dict(iterable, key_func): + """ + Split iterable into separate lists in a dict whose keys + are the shared response to all its items' key_func(item). + """ + out_dict = {} + for item in iterable: + key = key_func(item) + if key not in out_dict: + out_dict[key] = [item] + else: + out_dict[key].append(item) + return out_dict + +def assign_orders(slots): + # Count total kind occurrences + totals = Counter( + [slot.kind for slot in slots] + ) + # Create counter to increment gradually + kind_counter = Counter() + # Loop through the slots + for slot in slots: + if totals[slot.kind] < 2: + # Skip slots with unique kinds + continue + kind_counter[slot.kind] += 1 + slot.order = kind_counter[slot.kind] def get_kind_from_str(db_name): from attachments.kinds import ATTACHMENTS, OtherAttachment diff --git a/proposals/utils/stepper.py b/proposals/utils/stepper.py index 27cfeb30b..77d029ec8 100644 --- a/proposals/utils/stepper.py +++ b/proposals/utils/stepper.py @@ -18,7 +18,7 @@ from interventions.forms import InterventionForm from proposals.utils.validate_sessions_tasks import validate_sessions_tasks -from attachments.utils import AttachmentSlot +from attachments.utils import AttachmentSlot, enumerate_slots from attachments.kinds import desiredness @@ -82,7 +82,17 @@ def attachment_slots( success = empty_slot.match_and_set(exclude=exclude) if success: extra_slots.append(empty_slot) - return self._attachment_slots + extra_slots + all_slots = self._attachment_slots + extra_slots + enumerate_slots(all_slots) + return all_slots + + @property + def filled_slots( + self, + ): + return [ + slot for slot in self.attachment_slots if slot.attachment + ] def get_context_data(self): context = super().get_context_data() From 72b751f0be16dcf033f48831bc25b68ae3c0e530 Mon Sep 17 00:00:00 2001 From: Michael Villeneuve Date: Tue, 7 Jan 2025 16:29:14 +0100 Subject: [PATCH 51/51] feat: Template changes to display normalized filename after
--- attachments/templates/attachments/attachment_model.html | 5 ++--- reviews/templates/reviews/review_attachments.html | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/attachments/templates/attachments/attachment_model.html b/attachments/templates/attachments/attachment_model.html index 5be78a781..b25305ab4 100644 --- a/attachments/templates/attachments/attachment_model.html +++ b/attachments/templates/attachments/attachment_model.html @@ -2,6 +2,5 @@ {{ attachment.upload.original_filename }} -{% if include_normalized %} - ({{ attachment.upload }}) -{% endif %} +{% if normalized_filename %} +
{{ normalized_filename }}
{% endif %} diff --git a/reviews/templates/reviews/review_attachments.html b/reviews/templates/reviews/review_attachments.html index 0562301ef..9b8a09747 100644 --- a/reviews/templates/reviews/review_attachments.html +++ b/reviews/templates/reviews/review_attachments.html @@ -75,7 +75,7 @@
{{ slot.kind.name }}
{% trans "Bestand" %} - {% include slot.attachment with proposal=proposal include_normalized=True %} + {% include slot.attachment with proposal=proposal normalized_filename=slot.get_fetc_filename %} {% trans "Commentaar" %}