diff --git a/config/settings/base.py b/config/settings/base.py index 970992875..bdce6cd20 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -111,6 +111,7 @@ "maintenance.apps.MaintenanceConfig", "regmaps.apps.RegmapsConfig", "beaconsite.apps.BeaconsiteConfig", + "reports.apps.ReportsConfig", ] # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -690,6 +691,7 @@ def set_logging(debug): # Default: None DJANGO_SU_CUSTOM_LOGIN_ACTION = None +CRISPY_FAIL_SILENTLY = False # STORAGE CONFIGURATION # ------------------------------------------------------------------------------ diff --git a/config/urls.py b/config/urls.py index 5d447d76c..ddb35a209 100644 --- a/config/urls.py +++ b/config/urls.py @@ -56,6 +56,7 @@ def handler500(request, *args, **argv): url(r"^su/", include("django_su.urls")), url(r"^cohorts/", include("cohorts.urls")), url(r"^clinvar-export/", include("clinvar_export.urls")), + url(r"^reports/", include("reports.urls")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) # Augment with URLs for Beacon site. diff --git a/reports/__init__.py b/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/reports/admin.py b/reports/admin.py new file mode 100644 index 000000000..c42be77f1 --- /dev/null +++ b/reports/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +# from .models import HgmdPublicLocus + +# Register your models here. +# admin.site.register(HgmdPublicLocus) diff --git a/reports/apps.py b/reports/apps.py new file mode 100644 index 000000000..f5fbe30b4 --- /dev/null +++ b/reports/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + name = "reports" diff --git a/reports/forms.py b/reports/forms.py new file mode 100644 index 000000000..5c6ce023a --- /dev/null +++ b/reports/forms.py @@ -0,0 +1,80 @@ +import hashlib + +from django import forms +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator + +from .models import ReportTemplate + + +class ReportTemplateForm(forms.ModelForm): + class Meta: + model = ReportTemplate + fields = ("title", "filename") + + title = forms.CharField( + label="Template Title", help_text="Leave empty to use file name", required=False + ) + + payload = forms.FileField( + label="Template File", help_text="Upload a .docx file to use for the template." + ) + + filename = forms.CharField( + required=False, + help_text="Specify the file name to override the file name of the uploaded file", + validators=[ + RegexValidator(r"(^$)|(\.docx$)", message="File name must be blank or end in .docx"), + RegexValidator( + r"^[a-zA-Z0-9\._-]*$", + message="File name must only consist of alphanumeric characters, spaces, dots, hyphens, and underscores", + ), + ], + ) + + def __init__(self, project, *args, **kwargs): + super().__init__(*args, **kwargs) + self.project = project + if self.instance.pk: + self.fields["payload"].required = False + + def clean(self): + cleaned_data = self.cleaned_data + if "payload" in cleaned_data and cleaned_data["payload"]: + import pdb + + pdb.set_trace() + cleaned_data["title"] = cleaned_data["title"] or cleaned_data["payload"].name + cleaned_data["filename"] = cleaned_data["filename"] or cleaned_data["payload"].name + ctx = hashlib.sha256() + if cleaned_data["payload"].multiple_chunks(): + for data in cleaned_data["payload"].chunks(): + ctx.update(data) + else: + ctx.update(cleaned_data["payload"].read()) + cleaned_data["filehash"] = "sha256:%s" % ctx.hexdigest() + cleaned_data["filesize"] = cleaned_data["payload"].size + + if not cleaned_data["filename"].endswith(".docx"): + raise ValidationError("The file name must end in .docx") + + qs = ReportTemplate.objects.filter(project=self.project, filename=cleaned_data["filename"]) + if self.instance.pk: + qs = qs.exclude(pk=self.instance.pk) + if qs: + raise ValidationError( + "There already exists a report template with file name %s in this project!" + % cleaned_data["filename"] + ) + + return cleaned_data + + def save(self, commit=True): + super().save(commit=False) + if "filehash" in self.cleaned_data: + self.instance.filehash = self.cleaned_data["filehash"] + if "filesize" in self.cleaned_data: + self.instance.filesize = self.cleaned_data["filesize"] + self.instance.project = self.project + self.instance.save() + return self.instance diff --git a/reports/migrations/0001_initial.py b/reports/migrations/0001_initial.py new file mode 100644 index 000000000..ad9c0c8ba --- /dev/null +++ b/reports/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2021-04-07 15:27 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projectroles", "0017_project_full_title"), + ] + + operations = [ + migrations.CreateModel( + name="ReportTemplate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "date_created", + models.DateTimeField(auto_now_add=True, help_text="DateTime of creation"), + ), + ( + "date_modified", + models.DateTimeField(auto_now=True, help_text="DateTime of last modification"), + ), + ("title", models.CharField(max_length=512)), + ("filename", models.CharField(max_length=512)), + ("filesize", models.IntegerField(default=0)), + ("filehash", models.CharField(max_length=128)), + ( + "sodar_uuid", + models.UUIDField(default=uuid.uuid4, help_text="Case SODAR UUID", unique=True), + ), + ( + "project", + models.ForeignKey( + help_text="Project in which this objects belongs", + on_delete=django.db.models.deletion.CASCADE, + to="projectroles.Project", + ), + ), + ], + options={"ordering": ("title",),}, + ), + migrations.AlterUniqueTogether( + name="reporttemplate", unique_together=set([("project", "filename")]), + ), + ] diff --git a/reports/migrations/__init__.py b/reports/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/reports/models.py b/reports/models.py new file mode 100644 index 000000000..39f827078 --- /dev/null +++ b/reports/models.py @@ -0,0 +1,46 @@ +import uuid as uuid_object + +from django.db import models +from django.conf import settings +from django.urls import reverse +from projectroles.models import Project + +#: Django user model. +AUTH_USER_MODEL = getattr(settings, "AUTH_USER_MODEL", "auth.User") + + +class ReportTemplate(models.Model): + """A report template.""" + + #: DateTime of creation + date_created = models.DateTimeField(auto_now_add=True, help_text="DateTime of creation") + #: DateTime of last modification + date_modified = models.DateTimeField(auto_now=True, help_text="DateTime of last modification") + + #: UUID used for identification throughout SODAR. + sodar_uuid = models.UUIDField( + default=uuid_object.uuid4, unique=True, help_text="Case SODAR UUID" + ) + + #: The project containing this case. + project = models.ForeignKey(Project, help_text="Project in which this objects belongs") + + #: Title of the report. + title = models.CharField(max_length=512) + #: File name of the report. + filename = models.CharField(max_length=512) + + #: File size in bytes. + filesize = models.IntegerField(default=0) + #: File hash. + filehash = models.CharField(max_length=128) + + class Meta: + unique_together = (("project", "filename"),) + ordering = ("title",) + + def get_absolute_url(self): + return reverse( + "reports:template-view", + kwargs={"project": self.project.sodar_uuid, "template": self.sodar_uuid}, + ) diff --git a/reports/plugins.py b/reports/plugins.py new file mode 100644 index 000000000..5149cc3b4 --- /dev/null +++ b/reports/plugins.py @@ -0,0 +1,40 @@ +from projectroles.plugins import ProjectAppPluginPoint + +from .urls import urlpatterns + + +class ProjectAppPlugin(ProjectAppPluginPoint): + """Plugin for registering app with Projectroles""" + + name = "reports" + title = "Reports" + urls = urlpatterns + # ... + + icon = "file-text-o" + + entry_point_url_id = "reports:template-list" + + description = "Generate reports on word processing templates" + + #: Required permission for accessing the app + app_permission = "reports.view_data" + + #: Enable or disable general search from project title bar + search_enable = False + + #: List of search object types for the app + search_types = [] + + #: No settings for this app. + app_settings = {} + + def get_object_link(self, model_str, uuid): + """ + Return URL for referring to a object used by the app, along with a + label to be shown to the user for linking. + :param model_str: Object class (string) + :param uuid: sodar_uuid of the referred object + :return: Dict or None if not found + """ + return None diff --git a/reports/rules.py b/reports/rules.py new file mode 100644 index 000000000..f701c05df --- /dev/null +++ b/reports/rules.py @@ -0,0 +1,34 @@ +import rules +from projectroles import rules as pr_rules + + +rules.add_perm( + "reports.view_data", + rules.is_superuser + | pr_rules.is_project_owner + | pr_rules.is_project_delegate + | pr_rules.is_project_contributor + | pr_rules.is_project_guest, +) + +rules.add_perm( + "reports.add_data", + rules.is_superuser + | pr_rules.is_project_owner + | pr_rules.is_project_delegate + | pr_rules.is_project_contributor, +) +rules.add_perm( + "reports.delete_data", + rules.is_superuser + | pr_rules.is_project_owner + | pr_rules.is_project_delegate + | pr_rules.is_project_contributor, +) +rules.add_perm( + "reports.update_data", + rules.is_superuser + | pr_rules.is_project_owner + | pr_rules.is_project_delegate + | pr_rules.is_project_contributor, +) diff --git a/reports/templates/reports/_report_template_buttons.html b/reports/templates/reports/_report_template_buttons.html new file mode 100644 index 000000000..ca9387866 --- /dev/null +++ b/reports/templates/reports/_report_template_buttons.html @@ -0,0 +1,29 @@ +{% load rules %} + +{% has_perm 'reports.update_data' request.user as can_update_data %} +{% has_perm 'reports.delete_data' request.user as can_delete_data %} + +
+ + +
diff --git a/reports/templates/reports/reporttemplate_confirm_delete.html b/reports/templates/reports/reporttemplate_confirm_delete.html new file mode 100644 index 000000000..01abae37e --- /dev/null +++ b/reports/templates/reports/reporttemplate_confirm_delete.html @@ -0,0 +1,35 @@ +{% extends 'projectroles/base.html' %} + +{% load rules %} +{% load crispy_forms_filters %} +{% load projectroles_common_tags %} + +{% block title %}Confirm Removal of Report Template{% endblock %} + +{% block projectroles %} + +
+

Confirm Removal of Report Template

+
+ +
+ + +
+ {% csrf_token %} +
+ Cancel + + +
+
+
+ +{% endblock projectroles %} diff --git a/reports/templates/reports/reporttemplate_detail.html b/reports/templates/reports/reporttemplate_detail.html new file mode 100644 index 000000000..766be3d97 --- /dev/null +++ b/reports/templates/reports/reporttemplate_detail.html @@ -0,0 +1,61 @@ +{% extends 'projectroles/base.html' %} + +{% load rules %} + +{% block title %}Report Template{% endblock %} + +{% block projectroles %} + {% has_perm 'beaconsite.update_data' request.user as can_update_data %} + {% has_perm 'beaconsite.delete_data' request.user as can_delete_data %} + +
+

+ + Report Template +

+
+ + Back + + {% if can_update_data %} + + + Update + + {% endif %} + {% if can_delete_data %} + + + Remove + + {% endif %} +
+
+ +
+
+
+
+
UUID
+
{{ object.sodar_uuid }}
+
Title
+
{{ object.title }}
+
File Name
+
{{ object.filename }}
+
+
+ + + Download + +
+
File Size
+
{{ object.filesize|filesizeformat }}
+
File Hash
+
{{ object.filehash }}
+
+
+
+
+ +{% endblock projectroles %} diff --git a/reports/templates/reports/reporttemplate_form.html b/reports/templates/reports/reporttemplate_form.html new file mode 100644 index 000000000..d263cfa19 --- /dev/null +++ b/reports/templates/reports/reporttemplate_form.html @@ -0,0 +1,60 @@ +{% extends 'projectroles/base.html' %} + +{% load rules %} +{% load crispy_forms_filters %} +{% load projectroles_tags %} +{% load projectroles_common_tags %} + +{% block title %} + + {% if object.pk %} + Update + {% else %} + Create + {% endif %} + Report Template +{% endblock title %} + +{% block projectroles %} +
+ +

+ {% if object.pk %} + Update + {% else %} + Create + {% endif %} + Report Template +

+
+ +
+ + {{ form|as_crispy_errors }} + +
+ {% csrf_token %} + {{ form.payload|as_crispy_field }} + {{ form.title|as_crispy_field }} + {{ form.filename|as_crispy_field }} + +
+
+ + Cancel + + +
+
+
+
+ +{% endblock projectroles %} diff --git a/reports/templates/reports/reporttemplate_list.html b/reports/templates/reports/reporttemplate_list.html new file mode 100644 index 000000000..f17250d70 --- /dev/null +++ b/reports/templates/reports/reporttemplate_list.html @@ -0,0 +1,87 @@ +{% extends 'projectroles/project_base.html' %} + +{% load projectroles_common_tags %} +{% load rules %} + +{% block navi_sub_project_extend %} + +{% endblock %} + +{% block projectroles %} + {% get_app_setting 'userprofile' 'enable_project_uuid_copy' user=request.user as enable_uuid_copy %} + + {% has_perm 'reports.add_data' request.user as can_add_data %} + +
+ {# Project menu dropdown, only visible if browser width < X and sidebar is hidden #} + {% include 'projectroles/_project_menu_btn.html' %} + +

+ + Report Template List +

+ + {# Project copy uuid #} + {% if enable_uuid_copy %} + + + + {% endif %} + + {% if can_add_data %} +
+ + + Create + +
+ {% endif %} +
+ +
+ + + + + + + + + + + + + {% for object in object_list %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
#TitleFilenameSizeHashAction
{{ forloop.counter }} + + {{ object.title }} + + + {{ object.filename }} +   + + + + {{ object.filesize }}{{ object.filehash }} + {% include "reports/_report_template_buttons.html" %} +
+ File templates have been uploaded yet. +
+{% endblock %} diff --git a/reports/tests/__init__.py b/reports/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/reports/tests/factories.py b/reports/tests/factories.py new file mode 100644 index 000000000..06712978b --- /dev/null +++ b/reports/tests/factories.py @@ -0,0 +1,16 @@ +import factory + +from variants.tests.factories import ProjectFactory +from ..models import ReportTemplate + + +class ReportTemplateFactory(factory.django.DjangoModelFactory): + class Meta: + model = ReportTemplate + + project = factory.SubFactory(ProjectFactory) + title = factory.Sequence(lambda n: "Template No. %d" % n) + filename = factory.Sequence(lambda n: "template-%d.docx" % n) + filesize = 0 + # SHA256 sum of empty file. + filehash = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" diff --git a/reports/urls.py b/reports/urls.py new file mode 100644 index 000000000..59318bc4b --- /dev/null +++ b/reports/urls.py @@ -0,0 +1,37 @@ +from django.conf.urls import url +from . import views + +app_name = "reports" + +urlpatterns = [ + url( + regex=r"^(?P[0-9a-f-]+)/template/$", + view=views.ReportTemplateListView.as_view(), + name="template-list", + ), + url( + regex=r"^(?P[0-9a-f-]+)/template/create/$", + view=views.ReportTemplateCreateView.as_view(), + name="template-create", + ), + url( + regex=r"^(?P[0-9a-f-]+)/template/view/(?P