Initial commit

This commit is contained in:
Joshua Rubingh 2022-02-17 14:35:54 +01:00
commit abbaaab98a
43 changed files with 2079 additions and 0 deletions

137
.gitignore vendored Normal file
View File

@ -0,0 +1,137 @@
# Django #
*.log
*.pot
*.pyc
__pycache__
db.sqlite3
media
# Backup files #
*.bak
# If you are using PyCharm #
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# File-based project format
*.iws
# IntelliJ
out/
# JIRA plugin
atlassian-ide-plugin.xml
# Python #
*.py[cod]
*$py.class
# Distribution / packaging
.Python build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery
celerybeat-schedule.*
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# mkdocs documentation
/site
# mypy
.mypy_cache/
# Sublime Text #
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
*.sublime-workspace
*.sublime-project
# sftp configuration file
sftp-config.json
# Package control specific files Package
Control.last-run
Control.ca-list
Control.ca-bundle
Control.system-ca-bundle
GitHub.sublime-settings
# Visual Studio Code #
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history

3
Enquete/.pep8 Normal file
View File

@ -0,0 +1,3 @@
[pycodestyle]
max_line_length = 120
ignore = E501

18
Enquete/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: Django",
"type": "python",
"request": "launch",
"program": "cd ${workspaceFolder}/enquete && ./manage.py",
"args": [
"runserver"
],
"django": true
}
]
}

View File

@ -0,0 +1 @@
default_app_config = 'apps.vragenlijst.apps.VragenlijstConfig'

View File

@ -0,0 +1,42 @@
from django.contrib import admin
from .models import Questionnaire, QuestionnaireTopic, QuestionnaireQuestion, QuestionnaireResponse, QuestionnaireStorage
# Register your models here.
@admin.register(Questionnaire)
class QuestionnaireAdmin(admin.ModelAdmin):
list_display = ('name', 'id',)
readonly_fields = ('created_at', 'updated_at')
@admin.register(QuestionnaireTopic)
class QuestionnaireTopicAdmin(admin.ModelAdmin):
list_display = ('name', 'order', 'questionnaire',)
ordering = ('order', )
readonly_fields = ('created_at', 'updated_at')
@admin.register(QuestionnaireQuestion)
class QuestionnaireQuestionAdmin(admin.ModelAdmin):
list_display = ('name', 'order', 'type', 'topic', 'questionnaire')
ordering = ('order', )
readonly_fields = ('created_at', 'updated_at')
def questionnaire(self, item):
return item.topic.questionnaire
@admin.register(QuestionnaireResponse)
class QuestionnaireResponseAdmin(admin.ModelAdmin):
list_display = ('id', 'created_at', 'questionnaire')
ordering = ('-created_at', )
readonly_fields = ('questionnaire','response','created_at', 'updated_at')
@admin.register(QuestionnaireStorage)
class QuestionnaireStorageAdmin(admin.ModelAdmin):
list_display = ('name', 'type', 'server')
ordering = ('name', )
readonly_fields = ('created_at', 'updated_at')

View File

@ -0,0 +1,11 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class VragenlijstConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.vragenlijst'
label = 'vragenlijst'
verbose_name = _('Questionnaire')
verbose_name_plural = _('Questionnaire')

View File

@ -0,0 +1,67 @@
# Generated by Django 4.0.2 on 2022-02-03 10:59
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Questionnaire',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='ID')),
('name', models.CharField(help_text='Name of the questionnaire.', max_length=200, verbose_name='Name')),
('description', models.TextField(blank=True, help_text='Enter a short description for this questionnaire.', null=True, verbose_name='Description')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='The date and time this questionnaire has been created', verbose_name='Date created')),
('updated_at', models.DateTimeField(auto_now=True, help_text='The date and time this questionnaire has been updated', verbose_name='Date updated')),
],
options={
'verbose_name': 'Questionnaire',
'verbose_name_plural': 'Questionnaires',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='QuestionnaireTopic',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='ID')),
('name', models.CharField(help_text='Name of the questionnaire topic.', max_length=200, verbose_name='Name')),
('description', models.TextField(blank=True, help_text='Enter a short description for this questionnaire topic.', null=True, verbose_name='Description')),
('order', models.PositiveIntegerField(blank=True, verbose_name='Order')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='The date and time this questionnaire topic has been created', verbose_name='Date created')),
('updated_at', models.DateTimeField(auto_now=True, help_text='The date and time this questionnaire topic has been updated', verbose_name='Date updated')),
('questionnaire', models.ForeignKey(help_text='The questionnaire topic for this questionnaire.', on_delete=django.db.models.deletion.CASCADE, to='vragenlijst.questionnaire', verbose_name='Questionnaire')),
],
options={
'verbose_name': 'Questionnaire topic',
'verbose_name_plural': 'Questionnaire topics',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='QuestionnaireQuestion',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='ID')),
('name', models.CharField(help_text='Name of the questionnaire topic.', max_length=200, verbose_name='Name')),
('type', models.CharField(choices=[('DATE', 'Date field'), ('NUMBER', 'Number field'), ('MULTIPLE', 'Multi options field'), ('SINGLE', 'Single option field'), ('TEXT', 'Single text line')], default='SINGLE', help_text='Question type', max_length=15, verbose_name='Type')),
('description', models.TextField(blank=True, help_text='Enter a short description for this questionnaire topic.', null=True, verbose_name='Description')),
('order', models.PositiveIntegerField(blank=True, verbose_name='Order')),
('choices', models.TextField(blank=True, help_text='Enter a short description for this questionnaire topic.', null=True, verbose_name='Choices')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='The date and time this questionnaire topic has been created', verbose_name='Date created')),
('updated_at', models.DateTimeField(auto_now=True, help_text='The date and time this questionnaire topic has been updated', verbose_name='Date updated')),
('topic', models.ForeignKey(help_text='The questionnaire topic for this questionnaire.', on_delete=django.db.models.deletion.CASCADE, to='vragenlijst.questionnairetopic', verbose_name='Questionnaire topic')),
],
options={
'verbose_name': 'Questionnaire question',
'verbose_name_plural': 'Questionnaire questions',
'ordering': ['name'],
},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-02-03 11:45
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('vragenlijst', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='questionnairetopic',
name='questionnaire',
field=models.ForeignKey(help_text='The questionnaire topic for this questionnaire.', on_delete=django.db.models.deletion.CASCADE, related_name='topics', to='vragenlijst.questionnaire', verbose_name='Questionnaire'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-02-03 12:21
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('vragenlijst', '0002_alter_questionnairetopic_questionnaire'),
]
operations = [
migrations.AlterField(
model_name='questionnairequestion',
name='topic',
field=models.ForeignKey(help_text='The questionnaire topic for this questionnaire.', on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='vragenlijst.questionnairetopic', verbose_name='Questionnaire topic'),
),
]

View File

@ -0,0 +1,49 @@
# Generated by Django 4.0.2 on 2022-02-08 10:01
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('vragenlijst', '0003_alter_questionnairequestion_topic'),
]
operations = [
migrations.CreateModel(
name='QuestionnaireStorage',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='ID')),
('name', models.CharField(help_text='Name of the questionnaire storage.', max_length=200, verbose_name='Name')),
('type', models.CharField(choices=[('WEBDAV', 'WebDAV')], default='WEBDAV', help_text='Storage type', max_length=15, verbose_name='Type')),
('server', models.CharField(help_text='Server url', max_length=200, verbose_name='Server')),
('username', models.CharField(help_text='Username', max_length=200, verbose_name='Username')),
('password', models.CharField(help_text='Password', max_length=200, verbose_name='Password')),
('path', models.CharField(help_text='Location on disk', max_length=200, verbose_name='Path')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='The date and time this questionnaire storage has been created', verbose_name='Date created')),
('updated_at', models.DateTimeField(auto_now=True, help_text='The date and time this questionnaire storage has been updated', verbose_name='Date updated')),
],
options={
'verbose_name': 'Questionnaire storage',
'verbose_name_plural': 'Questionnaire storages',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='QuestionnaireResponse',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='ID')),
('response', models.TextField(help_text='Questionaire response in CSV', verbose_name='Response')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='The date and time this questionnaire response has been created', verbose_name='Date created')),
('updated_at', models.DateTimeField(auto_now=True, help_text='The date and time this questionnaire response has been updated', verbose_name='Date updated')),
('questionnaire', models.ForeignKey(help_text='The questionnaire for this response.', on_delete=django.db.models.deletion.CASCADE, to='vragenlijst.questionnaire', verbose_name='Questionnaire')),
],
options={
'verbose_name': 'Questionnaire response',
'verbose_name_plural': 'Questionnaire responses',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,134 @@
from django.db import models
from django.db.models import Max, Value
from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
import uuid
from encrypted_model_fields.fields import EncryptedCharField
# Create your models here.
class Questionnaire(models.Model):
class Meta:
verbose_name = _('Questionnaire')
verbose_name_plural = _('Questionnaires')
ordering = ['name']
id = models.UUIDField(_('ID'), default=uuid.uuid4, editable=False, unique=True, primary_key=True)
name = models.CharField(_('Name'), max_length=200, help_text=_('Name of the questionnaire.'))
description = models.TextField(_('Description'), blank=True, null=True, help_text=_('Enter a short description for this questionnaire.'))
created_at = models.DateTimeField(_('Date created'), auto_now_add=True, help_text=_('The date and time this questionnaire has been created'))
updated_at = models.DateTimeField(_('Date updated'), auto_now=True, help_text=_('The date and time this questionnaire has been updated'))
@property
def allQuestions(self):
return QuestionnaireQuestion.objects.filter(topic__questionnaire=self.id)
def __str__(self):
"""str: Returns a readable string."""
return f'{self.name}'
class QuestionnaireTopic(models.Model):
class Meta:
verbose_name = _('Questionnaire topic')
verbose_name_plural = _('Questionnaire topics')
ordering = ['name']
id = models.UUIDField(_('ID'), default=uuid.uuid4, editable=False, unique=True, primary_key=True)
name = models.CharField(_('Name'), max_length=200, help_text=_('Name of the questionnaire topic.'))
questionnaire = models.ForeignKey(Questionnaire, verbose_name=Questionnaire._meta.verbose_name, on_delete=models.CASCADE, help_text=_('The questionnaire topic for this questionnaire.'), related_name='topics')
description = models.TextField(_('Description'), blank=True, null=True, help_text=_('Enter a short description for this questionnaire topic.'))
order = models.PositiveIntegerField(_('Order'), blank=True)
created_at = models.DateTimeField(_('Date created'), auto_now_add=True, help_text=_('The date and time this questionnaire topic has been created'))
updated_at = models.DateTimeField(_('Date updated'), auto_now=True, help_text=_('The date and time this questionnaire topic has been updated'))
def save(self, *args, **kwargs):
if self.order is None:
self.order = QuestionnaireTopic.objects.filter(questionnaire=self.questionnaire).aggregate(neworder=Coalesce(Max('order'), Value(0)))['neworder'] + 1
super().save(*args, **kwargs)
def __str__(self):
"""str: Returns a readable string."""
return f'{self.name}'
class QuestionnaireQuestionTypes(models.TextChoices):
DATE = ('DATE', _('Date field'))
NUMBER = ('NUMBER', _('Number field'))
MULTIPLE = ('MULTIPLE', _('Multi options field'))
SINGLE = ('SINGLE', _('Single option field'))
TEXT = ('TEXT', _('Single text line'))
class QuestionnaireQuestion(models.Model):
class Meta:
verbose_name = _('Questionnaire question')
verbose_name_plural = _('Questionnaire questions')
ordering = ['name']
id = models.UUIDField(_('ID'), default=uuid.uuid4, editable=False, unique=True, primary_key=True)
name = models.CharField(_('Name'), max_length=200, help_text=_('Name of the questionnaire topic.'))
type = models.CharField(_('Type'), max_length=15, choices=QuestionnaireQuestionTypes.choices, default=QuestionnaireQuestionTypes.SINGLE, help_text=_('Question type'))
description = models.TextField(_('Description'), blank=True, null=True, help_text=_('Enter a short description for this questionnaire topic.'))
order = models.PositiveIntegerField(_('Order'), blank=True,)
choices = models.TextField(_('Choices'), blank=True, null=True, help_text=_('Enter the choices 1 per line.<br />Use a format like \'= [Text]\' for an \'anders\' option.<br />Use format \'[Text]=[Value]\' for different value for each choice '))
topic = models.ForeignKey(QuestionnaireTopic, verbose_name=QuestionnaireTopic._meta.verbose_name, on_delete=models.CASCADE, help_text=_('The questionnaire topic for this questionnaire.'), related_name='questions')
created_at = models.DateTimeField(_('Date created'), auto_now_add=True, help_text=_('The date and time this questionnaire topic has been created'))
updated_at = models.DateTimeField(_('Date updated'), auto_now=True, help_text=_('The date and time this questionnaire topic has been updated'))
def save(self, *args, **kwargs):
if self.order is None:
self.order = QuestionnaireQuestion.objects.filter(topic__questionnaire=self.topic.questionnaire).aggregate(neworder=Coalesce(Max('order'), Value(0)))['neworder'] + 1
super().save(*args, **kwargs)
def choices_list(self):
return [choice.strip() for choice in self.choices.strip("\n").split('\n')]
def __str__(self):
"""str: Returns a readable string."""
return f'{self.name}'
class QuestionnaireResponse(models.Model):
class Meta:
verbose_name = _('Questionnaire response')
verbose_name_plural = _('Questionnaire responses')
ordering = ['-created_at']
id = models.UUIDField(_('ID'), default=uuid.uuid4, editable=False, unique=True, primary_key=True)
questionnaire = models.ForeignKey(Questionnaire, verbose_name=Questionnaire._meta.verbose_name, on_delete=models.CASCADE, help_text=_('The questionnaire for this response.'))
response = models.TextField(_('Response'), help_text=_('Questionaire response in CSV'))
created_at = models.DateTimeField(_('Date created'), auto_now_add=True, help_text=_('The date and time this questionnaire response has been created'))
updated_at = models.DateTimeField(_('Date updated'), auto_now=True, help_text=_('The date and time this questionnaire response has been updated'))
class QuestionnaireStorageTypes(models.TextChoices):
WEBDAV = ('WEBDAV', _('WebDAV'))
class QuestionnaireStorage(models.Model):
class Meta:
verbose_name = _('Questionnaire storage')
verbose_name_plural = _('Questionnaire storages')
ordering = ['name']
id = models.UUIDField(_('ID'), default=uuid.uuid4, editable=False, unique=True, primary_key=True)
name = models.CharField(_('Name'), max_length=200, help_text=_('Name of the questionnaire storage.'))
type = models.CharField(_('Type'), max_length=15, choices=QuestionnaireStorageTypes.choices, default=QuestionnaireStorageTypes.WEBDAV, help_text=_('Storage type'))
server = models.CharField(_('Server'), max_length=200, help_text=_('Server url'))
username = EncryptedCharField(_('Username'), max_length=200, help_text=_('Username'))
password = EncryptedCharField(_('Password'), max_length=200, help_text=_('Password'))
path = models.CharField(_('Path'), max_length=200, help_text=_('Location on disk'))
created_at = models.DateTimeField(_('Date created'), auto_now_add=True, help_text=_('The date and time this questionnaire storage has been created'))
updated_at = models.DateTimeField(_('Date updated'), auto_now=True, help_text=_('The date and time this questionnaire storage has been updated'))

View File

@ -0,0 +1,382 @@
{% extends 'base.html' %}
<!-- Add this for inheritance -->
{% load replace %}
{% load choice_value %}
{% load i18n %}
{% load static %}
{% block content %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-12">
<div class="card px-0 pt-4 pb-0 mt-3 mb-3">
<h2 id="heading">Fill out the forms</h2>
<p>Fill all form field to go to next step</p>
<form id="msform" method="POST">
{% csrf_token %}
<!-- progressbar -->
<ul id="progressbar">
{% for topic in questionnaire.topics.all|dictsort:"order" %}
<li class="{% if forloop.first %} active {% endif %}"><strong><i class="fas fa-dot-circle"></i>{{ topic.name }}</strong></li>
{% endfor %}
</ul>
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<br> <!-- fieldsets -->
{% for topic in questionnaire.topics.all|dictsort:"order" %}
<fieldset>
<div class="form-card">
<div class="row">
<div class="col-7">
<h2 class="fs-title">{{topic.name}}:</h2>
<p>{{topic.description}}</p>
</div>
<div class="col-5">
<h2 class="steps text-right">Step {{forloop.counter}} - {{ questionnaire.topics.all.count }}</h2>
</div>
</div>
{% for question in topic.questions.all|dictsort:"order" %}
<br /><br />
<label class="fieldlabels" for="{{question.id}}">{{ question.description.strip }}</label><br />
{% if question.type == 'SINGLE' %}
{% for choice in question.choices_list %}
<input type="radio" id="{{question.id}}_{{ forloop.counter }}" name="{{question.id}}" value="{{choice.strip|choice_value}}" required="required">
<label for="{{question.id}}_{{forloop.counter}}">{{choice.strip|replace:"/(^=|=.*)/" }}</label>
{% if choice.strip|slice:"0:1" == "=" %}
<input type="text" name="{{question.id}}" id="{{question.id}}_{{ forloop.counter }}_anders" style="display:none" disabled="disabled">
<script>
$(document).ready(function(){
$('input[type="radio"][name="{{question.id}}"]').on('change',(e) => {
let choice = $(e.target)
let andersTXT = $('input[type="text"][name="{{question.id}}"]')
if (choice.val().slice(0,1) == '=') {
// Show
andersTXT.show()
andersTXT.prop('required',true)
andersTXT.prop('disabled',false)
} else {
// Hide
andersTXT.hide()
andersTXT.prop('required',false)
andersTXT.prop('disabled',true)
}
})
})
</script>
{%endif%}
{% endfor %}
{%elif question.type == 'NUMBER' %}
<input type="{{question.type|lower}}" id="{{question.id}}" name="{{question.id}}" value="" required="required">
{%elif question.type == 'DATE' %}
<input type="{{question.type|lower}}" id="{{question.id}}" name="{{question.id}}" value="" required="required">
{%elif question.type == 'TEXT' %}
<textarea rows="1" id="{{question.id}}" name="{{question.id}}" required="required"></textarea>
{% endif %}
{% endfor %}
</div>
{% if forloop.counter > 1 %}
<input type="button" name="previous" class="previous action-button-previous" value="Previous" />
{% endif %}
{% if forloop.last %}
<input type="submit" name="next" class="next action-button" value="Save" />
{%else %}
<input type="button" name="next" class="next action-button" value="Next" />
{%endif%}
</fieldset>
{% endfor %}
</form>
</div>
</div>
</div>
</div>
<style>
#grad1 {
background-color: #9C27B0;
background-image: linear-gradient(120deg, #FF4081, #81D4FA);
}
#msform {
text-align: center;
position: relative;
margin-top: 20px
}
#msform fieldset .form-card {
background: white;
border: 0 none;
border-radius: 0px;
box-shadow: 0 2px 2px 2px rgba(0, 0, 0, 0.2);
padding: 20px 40px 30px 40px;
box-sizing: border-box;
width: 94%;
margin: 0 3% 20px 3%;
position: relative
}
#msform fieldset {
background: white;
border: 0 none;
border-radius: 0.5rem;
box-sizing: border-box;
width: 100%;
margin: 0;
padding-bottom: 20px;
position: relative
}
#msform fieldset:not(:first-of-type) {
display: none
}
#msform fieldset .form-card {
text-align: left;
color: #000000;
}
#msform .action-button {
width: 100px;
background: skyblue;
font-weight: bold;
color: white;
border: 0 none;
border-radius: 0px;
cursor: pointer;
padding: 10px 5px;
margin: 10px 5px
}
#msform .action-button:hover,
#msform .action-button:focus {
box-shadow: 0 0 0 2px white, 0 0 0 3px skyblue
}
#msform .action-button-previous {
width: 100px;
background: #616161;
font-weight: bold;
color: white;
border: 0 none;
border-radius: 0px;
cursor: pointer;
padding: 10px 5px;
margin: 10px 5px
}
#msform .action-button-previous:hover,
#msform .action-button-previous:focus {
box-shadow: 0 0 0 2px white, 0 0 0 3px #616161
}
select.list-dt {
border: none;
outline: 0;
border-bottom: 1px solid #ccc;
padding: 2px 5px 3px 5px;
margin: 2px
}
select.list-dt:focus {
border-bottom: 2px solid skyblue
}
.card {
z-index: 0;
border: none;
border-radius: 0.5rem;
position: relative
}
.fs-title {
font-size: 25px;
color: #2C3E50;
margin-bottom: 10px;
font-weight: bold;
text-align: left
}
#progressbar {
margin-bottom: 30px;
overflow: hidden;
color: lightgrey
}
#progressbar .active {
color: #000000
}
#progressbar li {
list-style-type: none;
font-size: 12px;
width: {{menu_width}}%;
float: left;
position: relative
}
#progressbar li:before {
width: 50px;
height: 50px;
line-height: 45px;
display: block;
font-size: 18px;
color: #ffffff;
background: lightgray;
border-radius: 50%;
margin: 0 auto 10px auto;
padding: 2px
}
#progressbar li:after {
content: '';
width: 100%;
height: 2px;
background: lightgray;
position: absolute;
left: 0;
top: 25px;
z-index: -1
}
#progressbar li.active:before,
#progressbar li.active:after {
background: skyblue
}
.radio-group {
position: relative;
margin-bottom: 25px
}
.radio {
display: inline-block;
width: 204;
height: 104;
border-radius: 0;
background: lightblue;
box-shadow: 0 2px 2px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
cursor: pointer;
margin: 8px 2px
}
.radio:hover {
box-shadow: 2px 2px 2px 2px rgba(0, 0, 0, 0.3)
}
.radio.selected {
box-shadow: 1px 1px 2px 2px rgba(0, 0, 0, 0.1)
}
.fit-image {
width: 100%;
object-fit: cover
}
.error {
/* border: solid 1px red; */
color:red;
font-weight: bold;
}
</style>
<script>
$(document).ready(function(){
var current_fs, next_fs, previous_fs; //fieldsets
var opacity;
var current = 1;
var steps = $("fieldset").length;
setProgressBar(current);
$(".next").click(function(){
current_fs = $(this).parent();
next_fs = $(this).parent().next();
let errors = false
current_fs.find('input,textarea').each((counter, element) => {
if (element.name && !element.disabled) {
if (!element.checkValidity()) {
jQuery('label[for=' + element.name + ']').addClass('error')
errors = true;
} else {
jQuery('label[for=' + element.name + ']').removeClass('error')
}
}
});
if (errors) {
return
}
//Add Class Active
$("#progressbar li").eq($("fieldset").index(next_fs)).addClass("active");
//show the next fieldset
next_fs.show();
//hide the current fieldset with style
current_fs.animate({opacity: 0}, {
step: function(now) {
// for making fielset appear animation
opacity = 1 - now;
current_fs.css({
'display': 'none',
'position': 'relative'
});
next_fs.css({'opacity': opacity});
},
duration: 500
});
setProgressBar(++current);
});
$(".previous").click(function(){
current_fs = $(this).parent();
previous_fs = $(this).parent().prev();
//Remove class active
$("#progressbar li").eq($("fieldset").index(current_fs)).removeClass("active");
//show the previous fieldset
previous_fs.show();
//hide the current fieldset with style
current_fs.animate({opacity: 0}, {
step: function(now) {
// for making fielset appear animation
opacity = 1 - now;
current_fs.css({
'display': 'none',
'position': 'relative'
});
previous_fs.css({'opacity': opacity});
},
duration: 500
});
setProgressBar(--current);
});
function setProgressBar(curStep){
var percent = parseFloat(100 / steps) * curStep;
percent = percent.toFixed();
$(".progress-bar")
.css("width",percent+"%")
}
// $(".submit").click(function(){
// return false;
// })
});
</script>
{% endblock %}

View File

@ -0,0 +1,39 @@
{% extends 'base.html' %}
<!-- Add this for inheritance -->
{% load i18n %}
{% load static %}
{% block content %}
{% if messages %}
<p>
{% for message in messages %}
{% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}Important: {% endif %}
{{ message }} <br />
{% endfor %}
{% endif %}
</p>
<p>Select the type of questionnaire you want to use</p>
{% if questionnaires %}
{% for questionnaire in questionnaires %}
<a href="{% url 'questionnaire' questionnaire.id %}" class="btn btn-primary btn-lg" role="button" aria-pressed="true">{{ questionnaire.name }}</a>
{% endfor %}
{% else %}
<p>No questionnaire are available.</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,9 @@
from django import template
register = template.Library()
@register.filter
def choice_value ( string ):
if string[0] != '=' and '=' in string:
return string.split('=')[1].strip()
return string.strip()

View File

@ -0,0 +1,11 @@
import re
from django import template
register = template.Library()
@register.filter
def replace ( string, args ):
search = args.split(args[0])[1]
replace = args.split(args[0])[2]
return re.sub( search, replace, string )

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
path('<uuid:questionnaire_id>/', views.questionnaire, name='questionnaire'),
]

View File

@ -0,0 +1,69 @@
from django.http import HttpResponseRedirect
from django.shortcuts import render
from .models import Questionnaire, QuestionnaireResponse, QuestionnaireStorage
from django.conf import settings
from django.contrib import messages
import csv
from uuid import uuid4
from pathlib import Path
from storage.storage import Storage
# Create your views here.
def index(request):
# latest_question_list = Question.objects.order_by('-pub_date')[:5]
questionnaires = Questionnaire.objects.order_by('name')
context = {'questionnaires': questionnaires}
return render(request, 'vragenlijst/index.html', context)
def questionnaire(request, questionnaire_id):
if request.method == 'POST':
# As we should have a Django form, we could use te form validator. For now, everyhing is valid ;)
questionnaire = Questionnaire.objects.get(pk=questionnaire_id)
# Get all the questions in one list with a single query
allQuestions = {}
for question in list(questionnaire.allQuestions.order_by('order').values('id','name')):
allQuestions[str(question['id'])] = question['name']
# Store the response as a ';' seperated CSV file in order of the questionaire questions order
try:
csv_file = Path(f'{settings.HANZE_TEMP_CSV_STORAGE}/{questionnaire.name}-{uuid4()}.csv')
with open(csv_file, 'w') as csvfile:
filewriter = csv.writer(csvfile, delimiter=';', quotechar='"', quoting=csv.QUOTE_MINIMAL)
# Add headers
filewriter.writerow(['vraag', 'antwoord'])
for question in allQuestions:
if request.POST.get(question):
filewriter.writerow([allQuestions[question], request.POST[question]])
# Store the data also in a database
QuestionnaireResponse(questionnaire=questionnaire, response=csv_file.read_text()).save()
storageSettings = QuestionnaireStorage.objects.first()
# Move data to external storage
webdav = Storage(
storage_type=storageSettings.type,
url=storageSettings.server,
username=storageSettings.username,
password=storageSettings.password
)
webdav.upload_file(source=csv_file, destination=f'{storageSettings.path}/{csv_file.name}')
messages.success(request, 'Questionaire is saved to disk.')
except Exception as ex:
messages.error(request, f'Could not save the questionaire. Error: {ex}')
# Redirect to starting point
return HttpResponseRedirect('/vragenlijst/')
questionnaire = Questionnaire.objects.get(pk=questionnaire_id)
context = {'questionnaire': questionnaire, 'menu_width': 100 / questionnaire.topics.count()}
return render(request, 'vragenlijst/form.html', context)

View File

View File

@ -0,0 +1,16 @@
"""
ASGI config for enquete project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'enquete.settings')
application = get_asgi_application()

View File

@ -0,0 +1,135 @@
"""
Django settings for enquete project.
Generated by 'django-admin startproject' using Django 4.0.2.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.0/ref/settings/
"""
from pathlib import Path
from decouple import config
from dj_database_url import parse as db_url
import base64
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config('SECRET_KEY')
FIELD_ENCRYPTION_KEY = config('FIELD_ENCRYPTION_KEY', str(base64.b64encode(SECRET_KEY[0:32].encode("utf-8")), "utf-8"))
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = config('DEBUG', default=False, cast=bool)
TEMPLATE_DEBUG = DEBUG
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'encrypted_model_fields',
'apps.vragenlijst'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'enquete.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'enquete.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = {
'default': config(
'DATABASE_URL',
default='sqlite:///' + f'{BASE_DIR}' + '/db.sqlite3',
cast=db_url
)
}
# Password validation
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'Europe/Amsterdam'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
HANZE_TEMP_CSV_STORAGE = BASE_DIR / '../uitkomsten'
HANZE_TEMP_CSV_STORAGE.mkdir(exist_ok=True)

View File

@ -0,0 +1,22 @@
"""enquete URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('vragenlijst/', include('apps.vragenlijst.urls')),
path('admin/', admin.site.urls),
]

View File

@ -0,0 +1,16 @@
"""
WSGI config for enquete project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'enquete.settings')
application = get_wsgi_application()

22
Enquete/enquete/manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'enquete.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,9 @@
dj-database-url==0.5.0
Django==4.0.2
django-encrypted-model-fields==0.6.1
python-decouple==3.6
python-irodsclient==1.1.1
giteapy==1.0.8
webdavclient3==3.14.6
PyGithub==1.55

View File

@ -0,0 +1 @@
A small questionnaire tool that stores the data trough a WebDAV connection.

View File

@ -0,0 +1,8 @@
import os
import logging
import logging.config
if os.path.isfile('logging.custom.ini'):
logging.config.fileConfig('logging.custom.ini')
elif os.path.isfile('logging.ini'):
logging.config.fileConfig('logging.ini')

View File

@ -0,0 +1,28 @@
import shutil
import os
from storage.storage import BaseStorage
import logging
logger = logging.getLogger(__name__)
class LocalStorage(BaseStorage):
TYPE = 'fs'
def file_exists(self, filepath):
return os.path.exists(filepath) and os.path.isfile(filepath)
def directory_exists(self, filepath):
return os.path.exists(filepath) and os.path.isdir(filepath)
def _make_folder_action(self, path):
os.makedirs(path)
return True
def _upload_file_action(self, source, destination):
shutil.copy(source, destination)
return True
def _download_file_action(self, source, destination):
shutil.copy(source, destination)
return True

View File

@ -0,0 +1,89 @@
from giteapy.rest import ApiException
import giteapy
import base64
from storage.storage import BaseStorage
import logging
logger = logging.getLogger(__name__)
# Gitea Support - https://pypi.org/project/giteapy/
class GiteaStorage(BaseStorage):
TYPE = 'gitea'
def __init__(self, url=None, username=None, password=None, source=None, destination=None, encryption_key=None, sender_name=None, sender_email=None):
# The repository is added to the url parameter. Use a '#' as seperator. The repository needs to be created first.
# Ex: https://git.web.rug.nl/api/v1#RepositoryName
(url, self.repository) = url.split('#')
destination = destination.strip('/')
super().__init__(url, username, password, source, destination, encryption_key, sender_name, sender_email)
# Create a commiter object when the data is uploaded through one of the invited accounts.
self.committer = None
if sender_name is not None or sender_email is not None:
self.committer = giteapy.Identity(name=sender_name, email=sender_email)
def __connect(self):
try:
assert(self.client)
except AttributeError:
# Configuration for the GITEA connection
configuration = giteapy.Configuration()
# Overrule the host url....?
configuration.host = self.url
#configuration.debug = False
configuration.api_key['access_token'] = self.password
# Create the client
self.client = giteapy.RepositoryApi(giteapy.ApiClient(configuration))
logger.info(f'Created Gitea connection to url: {self.url}')
def file_exists(self, filepath):
self.__connect()
try:
self.client.repo_get_contents(self.username, self.repository, filepath)
return True
except ApiException:
return False
def directory_exists(self, filepath):
self.__connect()
return self.file_exists(filepath)
def _make_folder_action(self, path):
# On GitHub you cannot create empty directories. So this actions will always succeed
return True
def _upload_file_action(self, source, destination):
self.__connect()
try:
with open(source, 'rb') as datafile:
# This is a very big issue. Big files will be stored completely in memory :(
body = giteapy.CreateFileOptions(content=base64.b64encode(datafile.read()).decode(),
message=f'Upload from VRE DataDropOff\n Added file: {destination}',
committer=self.committer)
except Exception:
return False
try:
# Create a file in a repository
api_response = self.client.repo_create_file(self.username, self.repository, destination, body)
return True
except ApiException as ex:
logger.exception(f'Exception when calling RepositoryApi->repo_create_file: {ex}')
return True
def _download_file_action(self, source, destination):
self.__connect()
with open(destination, 'wb') as destination_file:
try:
data = self.client.repo_get_contents(self.username, self.repository, source)
destination_file.write(base64.b64decode(data.content))
except ApiException as ex:
logger.exception(f'Exception when calling RepositoryApi->repo_get_contents: {ex}')
return True

View File

@ -0,0 +1,66 @@
from github.GithubException import UnknownObjectException
from github import Github, InputGitAuthor, GithubObject
from storage.storage import BaseStorage
import os
import logging
logger = logging.getLogger(__name__)
# Github Support - https://pypi.org/project/PyGithub/
class GithubStorage(BaseStorage):
TYPE = 'github'
def __init__(self, url=None, username=None, password=None, source=None, destination=None, encryption_key=None, sender_name=None, sender_email=None):
# The repository is added to the url parameter. Use a '#' as seperator. The repository needs to be created first.
# Ex: https://api.github.com/#RepositoryName
(url, self.repository) = url.split('#')
destination = destination.strip('/')
super().__init__(url, username, password, source, destination, encryption_key, sender_name, sender_email)
# Create a commiter object when the data is uploaded through one of the invited accounts.
self.committer = GithubObject.NotSet
if sender_name is not None or sender_email is not None:
self.committer = InputGitAuthor(name=sender_name, email=sender_email)
def __connect(self):
try:
assert(self.repo)
except AttributeError:
client = Github(self.password)
self.repo = client.get_user().get_repo(self.repository)
logger.info('Created Github.com connection')
def file_exists(self, filepath):
self.__connect()
try:
self.repo.get_contents(filepath)
return True
except UnknownObjectException:
return False
def directory_exists(self, filepath):
return True
def _make_folder_action(self, path):
# On GitHub you cannot create empty directories. So this actions will always succeed
return True
def _upload_file_action(self, source, destination):
self.__connect()
# Read the file and post to Github. The library will convert to Base64
with open(source, 'rb') as datafile:
self.repo.create_file(destination.strip('/'), f'Upload from VRE DataDropOff\n Added file: {destination}', datafile.read(), committer=self.committer)
return True
def _download_file_action(self, source, destination):
self.__connect()
download = self.repo.get_contents(source)
with open(destination, 'wb') as destination_file:
destination_file.write(download.decoded_content)
return True

View File

@ -0,0 +1,139 @@
import atexit
from irods.session import iRODSSession
import irods
import storage.exceptions as StorageException
from storage.storage import BaseStorage
import logging
logger = logging.getLogger(__name__)
# iRods support - https://pypi.org/project/python-irodsclient/
class iRODSStorage(BaseStorage):
TYPE = 'irods'
def __init__(self, url=None, username=None, password=None, source=None, destination=None, encryption_key=None, sender_name=None, sender_email=None):
# The iRODS zone is added to the url parameter. Use a '#' as seperator. This needs to be an Existing iRODS zone
# Ex: rdms-prod-icat.data.rug.nl#rug
(url, self.irods_zone) = url.split('#')
if destination:
destination = destination.strip('/')
super().__init__(url, username, password, source, destination, encryption_key, sender_name, sender_email)
# We need to clean up the iRODS session. Using atexit is the easiest way.
atexit.register(self.__close)
def __connect(self):
try:
assert(self.client)
except AttributeError:
# Connect to the iRODS server
self.client = None
try:
self.client = iRODSSession(host=self.url, port=1247, user=self.username, password=self.password, zone=self.irods_zone)
# Need to make a call to validate the authentication. So by checking the version, we know if we can authenticate...
logger.debug(f'iRODS {self.client.server_version} connection through *native* authentication')
except irods.exception.CAT_INVALID_AUTHENTICATION:
# Authentication scheme is not native (default), so we try PAM here
try:
self.client = iRODSSession(host=self.url, port=1247, user=self.username, password=self.password, zone=self.irods_zone, irods_authentication_scheme='pam')
logger.debug(f'iRODS {self.client.server_version} connection through *PAM* authentication')
except irods.exception.CAT_INVALID_AUTHENTICATION:
# Authentication scheme is not PAM either last try: GIS
try:
self.client = iRODSSession(host=self.url, port=1247, user=self.username, password=self.password, zone=self.irods_zone, irods_authentication_scheme='gis')
logger.debug(f'iRODS {self.client.server_version} connection through *GIS* authentication')
except irods.exception.CAT_INVALID_AUTHENTICATION:
pass
if self.client is None:
logger.error('Unable to login to the iRODS instance. Please check username and password combination!')
raise StorageException.InvalidAuthentication(self.username)
logger.info('Created iRODS connection')
def __close(self):
logger.debug('Closing iRODS storage connection and clean up')
self.client.cleanup()
def _file_exists_action(self, path):
self.__connect()
try:
self.client.data_objects.get(f'/{self.irods_zone}/home/{self.username}/{path}')
except irods.exception.DataObjectDoesNotExist:
logger.debug(f'File \'{path}\' does NOT exists on the iRODS server')
return False
except irods.exception.CollectionDoesNotExist:
logger.debug(f'Parent folder of file \'{path}\' does NOT exists on the iRODS server')
return False
return True
def _directory_exists_action(self, path):
self.__connect()
try:
self.client.collections.get(f'/{self.irods_zone}/home/{self.username}/{path}')
logger.debug(f'Folder \'{path}\' exists on the iRODS server')
except irods.exception.CollectionDoesNotExist:
logger.debug(f'Folder \'{path}\' does NOT exists on the iRODS server')
return False
return True
def _make_folder_action(self, path):
self.__connect()
try:
self.client.collections.create(f'/{self.irods_zone}/home/{self.username}/{path}')
except irods.exception.CollectionDoesNotExist:
logger.debug(f'Parent folder of file \'{path}\' does NOT exists on the iRODS server')
return False
return True
def _upload_file_action(self, source, destination):
self.__connect()
# The upload path consists of a zone, username and path
destination = f'/{self.irods_zone}/home/{self.username}/{destination}'
logger.debug(f'Uploading to file: \'{destination}\'')
try:
obj = self.client.data_objects.create(destination)
logger.debug(f'Created file: \'{destination}\'')
# Open 'both' files and copy 4K data each time.
with obj.open('w') as irods_file, open(source, 'rb') as source_file_binary:
while True:
buf = source_file_binary.read(4096)
if buf:
irods_file.write(buf)
else:
break
obj.metadata.add('source', f'Upload from VRE DataDropOff\n Added file: {destination} uploaded by: {self.sender_name}({self.sender_email})')
except irods.exception.OVERWRITE_WITHOUT_FORCE_FLAG:
logger.warning('The uploaded file already exists. So we did NOT upload the new file!')
return False
return True
def _download_file_action(self, source, destination):
self.__connect()
logger.debug(f'Downloading file: \'{source}\' to \'{destination}\'')
try:
obj = self.client.data_objects.get(f'/{self.irods_zone}/home/{self.username}/{source}')
# Open 'both' files and copy 4K data each time.
with obj.open('r') as irods_source_file, open(destination, 'wb') as local_destination_file:
while True:
buf = irods_source_file.read(4096)
if buf:
local_destination_file.write(buf)
else:
break
except irods.exception.DataObjectDoesNotExist:
logger.error(f'File: \'{source}\' does not exists on the iRODS server')
return False
return True

View File

@ -0,0 +1,65 @@
from webdav3.exceptions import WebDavException, ResponseErrorCode
from webdav3.client import Client
import storage.exceptions as StorageException
from storage.utils import human_filesize
from storage.storage import BaseStorage
import logging
logger = logging.getLogger(__name__)
# WebDAV Support - https://pypi.org/project/webdavclient3/
class WebDAVStorage(BaseStorage):
TYPE = 'webdav'
def __connect(self):
# Connect to the external storage. This function can be run multiple times. It will check if it has already a connection to re-use
try:
# When this fails with an Attribute error, that means that the 'client' variable is not set and we need to make a new connection
assert(self.client)
except AttributeError:
# Because the 'client' variable is not known, the WebDAV connections is not created yet. So do it now!
self.client = Client({
'webdav_hostname': self.url,
'webdav_login': self.username,
'webdav_password': self.password,
})
try:
# Here we abuse the .free check to see if the login credentials do work
free_space = self.client.free()
logger.info(f'Created WebDAV connection to url: \'{self.url}\', with space left: {human_filesize(free_space)}')
except ResponseErrorCode as ex:
# Login went wrong, so delete the client variable for next run/try
del(self.client)
# If there was an authentication error, raise exception and quit.
if 401 == ex.code:
raise StorageException.InvalidAuthentication(self.username)
# TODO: More errors.....
def _file_exists_action(self, path):
self.__connect()
return self.client.check(path)
def _directory_exists_action(self, path):
self.__connect()
return self.client.check(path)
def _make_folder_action(self, path):
self.__connect()
self.client.mkdir(path)
return True
def _upload_file_action(self, source, destination):
self.__connect()
self.client.upload(local_path=source, remote_path=destination)
return True
def _download_file_action(self, source, destination):
self.__connect()
self.client.download(source, destination)
return True

View File

@ -0,0 +1,63 @@
class BaseStorageError(Exception):
pass
class StorageActionNotImplemented(Exception):
def __init__(self, storage, action, message='is not implemented'):
self.storage = storage
self.action = action
self.message = message
super().__init__(self.message)
def __str__(self):
return f'{self.action} on class {self.storage} {self.message}'
class FileDoesNotExist(BaseStorageError):
def __init__(self, source, message='File does not exists on disk'):
self.source = source
self.message = message
super().__init__(self.message)
def __str__(self):
return f'{self.source} -> {self.message}'
class InvalidLocation(BaseStorageError):
def __init__(self, location, message='Location does not exists or is not valid'):
self.location = location
self.message = message
super().__init__(self.message)
def __str__(self):
return f'{self.location} -> {self.message}'
class InvalidAuthentication(BaseStorageError):
def __init__(self, user, message='Authentication failed'):
self.user = user
self.message = message
super().__init__(self.message)
def __str__(self):
return f'{self.user} -> {self.message}'
class UnknownStorageEngine(BaseStorageError):
def __init__(self, engine, message='Storage engine is unknown, not available'):
self.engine = engine
self.message = message
super().__init__(self.message)
def __str__(self):
return f'{self.engine} -> {self.message}'
class MissingEncryptionKey(BaseStorageError):
def __init__(self, message='The encryption keys are missing'):
self.message = message
super().__init__(self.message)
def __str__(self):
return f'{self.message}'

View File

@ -0,0 +1,248 @@
import shlex
import subprocess
import storage.exceptions as StorageException
import importlib
from pathlib import Path
import re
import glob
import os
import shutil
from datetime import datetime
import tempfile
import logging
logger = logging.getLogger(__name__)
class Storage():
CLASS_REGEX = re.compile(r'class\s+(?P<class_name>[^\s\(]+)\s*\(\s*BaseStorage\s*\)\s*:')
# This acts like a factory function. It will return a storage object from the requested engine
def __new__(self, storage_type, url=None, username=None, password=None, source=None, destination=None, encryption_key=None, sender_name=None, sender_email=None):
storage_type = storage_type.lower()
engines = Storage.__load_storage_engines()
logger.debug(f'Available storage engines({len(Storage.available_engines())}): {Storage.available_engines()}')
if storage_type not in engines:
raise StorageException.UnknownStorageEngine(storage_type)
return engines[storage_type](url, username, password, source, destination, encryption_key, sender_name, sender_email)
@staticmethod
def __load_storage_engines():
loaded_engines = {}
engines = (Path(__file__)).parent.joinpath('engines')
for engine in [x for x in engines.glob('*.py') if x.is_file()]:
with engine.open() as python_file:
data = python_file.read()
class_name = Storage.CLASS_REGEX.findall(data)
if len(class_name) == 1:
storage_engine_module = importlib.import_module('.{}' . format(engine.stem), package='storage.engines')
storage_engine_class = getattr(storage_engine_module, class_name[0])
loaded_engines[storage_engine_class.TYPE.lower()] = storage_engine_class
return loaded_engines
@staticmethod
def available_engines():
engines = list(Storage.__load_storage_engines().keys())
engines.sort()
return engines
class BaseStorage():
ENCFS_XML = '.encfs6.xml'
ENCRYPT_CMD = '/usr/bin/encfs'
FUSER_MOUNT = '/bin/fusermount'
TYPE = ''
def __init__(self, url=None, username=None, password=None, source=None, destination=None, encryption_key=None, sender_name=None, sender_email=None):
if source is not None and not os.path.exists(source):
logger.error(f'Source file is not available on disk! It has vanished from: {source}')
raise StorageException.FileDoesNotExist(source)
# if destination is None:
# logger.error(f'Destination is not valid: {destination}')
# raise StorageException.InvalidLocation(destination)
self.source = source
self.destination_dir = None if destination is None else os.path.dirname(destination)
self.destination_file = None if destination is None else os.path.basename(destination)
self.encryption_key = encryption_key
self.encrypted = False
self.url = url
self.username = username
self.password = password
self.sender_name = sender_name
self.sender_email = sender_email
def encrypt_source(self):
if self.encryption_key is None:
logger.error(f'Cannot encrypt source file {self.source} due to missing encryption key!')
raise StorageException.MissingEncryptionKey()
if self.encrypted:
logger.warning('File is already encrypted')
return True
start_time = datetime.now()
logger.info(f'Encrypting new uploaded file: {self.source}')
encrypted_dir = tempfile.mkdtemp()
logger.debug(f'Created encrypted source folder: {encrypted_dir}')
decoded_dir = tempfile.mkdtemp()
logger.debug(f'Created decoded folder: {decoded_dir}')
new_encryption_setup = True
existing_encfs_file = os.path.join(self.destination_dir, BaseStorage.ENCFS_XML)
logger.debug(f'Check for existing encryption key file \'{existing_encfs_file}\' on the destination location.')
if self.file_exists(existing_encfs_file):
logger.debug(f'Copying existing \'{BaseStorage.ENCFS_XML}\' file...')
self.download_file(existing_encfs_file, os.path.join(encrypted_dir, BaseStorage.ENCFS_XML))
logger.info(f'Using existing \'{existing_encfs_file}\' from destination location.')
new_encryption_setup = False
# Mounting part between source and encrypted folder
# TODO: Check what happens when there are spaces in the dir names... need some quotes I guess
cmd = f'{BaseStorage.ENCRYPT_CMD} --standard -S {encrypted_dir} {decoded_dir}'
logger.debug(f'Creating an encrypted EncFS mount point with command: {cmd}')
process = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
# # Send encryption password
logger.debug('Mounting in action. Sending encryption key...')
(output, error) = process.communicate(input=self.encryption_key.encode())
if process.wait(timeout=30) != 0:
output = output.decode().strip()
logger.error(f'Error creating an encrypted mount with EncFS. Error: \'{output}\'')
raise RuntimeError(f'Mounting error EncFS: {output}')
logger.debug(f'Mountpoint is ready at path: {decoded_dir}')
if new_encryption_setup:
logger.info(f'We have a new \'{BaseStorage.ENCFS_XML}\' file that needs to be moved to the same destination: {self.destination_dir}')
self.upload_file(os.path.join(encrypted_dir, BaseStorage.ENCFS_XML), existing_encfs_file, True)
# Here we ignore the subdirectories on the destination. This will be fixed during the upload
destination_file = os.path.join(decoded_dir, self.destination_dir, self.destination_file)
logger.debug(f'Moving source file \'{self.source}\' to \'{destination_file}\' for encryption.')
os.makedirs(os.path.dirname(destination_file))
shutil.move(self.source, destination_file)
# Here we umount the decoded directory, so we only have the encypted data left
logger.debug(f'Encrypting is done, un-mounting decoded folder: {decoded_dir}')
cmd = f'{BaseStorage.FUSER_MOUNT} -u {decoded_dir}'
logger.debug(f'Umounting cmd: {cmd}')
process = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
if process.wait() != 0:
# TODO: Better error handling... Add raise exception
logger.error(f'Error un-mounting mount point: {decoded_dir}')
raise RuntimeError(f'Un-mounting error EncFS: {process}')
logger.debug(f'Cleanup temporary decoded dir: {decoded_dir}')
shutil.rmtree(decoded_dir)
# Find the newly created encrypted file and move it back to the original source file
# We use the glob function so we can also support subdirectories in the encrypted storage
logger.debug(f'Finding newly created encrypted file in the encrypted source folder: {encrypted_dir}')
encrypted_listing = glob.glob(f'{encrypted_dir}/**', recursive=True)
logger.debug(f'Found encrypted file: {encrypted_listing[-1]}')
# Source file is been changed to the new encrypted file name. So use that for the file upload process
self.source = os.path.join(os.path.dirname(self.source), os.path.basename(encrypted_listing[-1]))
self.destination_file = os.path.basename(self.source)
logger.debug(f'Moving encrypted file {encrypted_listing[-1]} back to original file: {self.source}')
logger.debug(f'Updated the destination file name based on the encrypted name: {self.destination_file}')
shutil.move(encrypted_listing[-1], self.source)
logger.info(f'Encrypted to \'{self.source}\' in {datetime.now() - start_time} (h:mm:ss.ms)')
self.encrypted = True
return True
def file_exists(self, path):
logger.debug(f'Check if file exists at path: \'{path}\' with engine: \'{self.TYPE}\'')
file_exists = self._file_exists_action(path)
exists = 'exist' if file_exists else 'does not exist'
logger.debug(f'File \'{path}\' {exists} on storage \'{self.TYPE}\'')
return file_exists
def upload_file(self, source=None, destination=None, move=False):
source = self.source if source is None else source
destination = os.path.join(self.destination_dir, self.destination_file) if destination is None else destination
upload_ok = None
if source is None or destination is None:
logger.error(f'Error uploading file. Either source: \'{source}\' or destination: \'{destination}\' is not set!')
start_time = datetime.now()
logger.debug(f'Start uploading file: \'{source}\' to: \'{destination}\' with engine: \'{self.TYPE}\'')
if not self.directory_exists(os.path.dirname(destination)):
self.create_directories(os.path.dirname(destination))
upload_ok = self._upload_file_action(source, destination)
if upload_ok:
logger.info(f'Uploaded \'{source}\' to: \'{destination}\' with engine: \'{self.TYPE}\' in {datetime.now() - start_time} (h:mm:ss.ms)')
if move or self.encrypted:
os.unlink(source)
logger.debug('Removed source file from disk!')
else:
logger.error(f'Error uploading \'{source}\' to: \'{destination}\' with engine: \'{self.TYPE}\' in {datetime.now() - start_time} (h:mm:ss.ms)')
return upload_ok
def directory_exists(self, path):
# logger.debug()
return self._directory_exists_action(path)
def download_file(self, source=None, destination=None, move=False):
source = self.source if source is None else source
#destination = self.destination if destination is None else destination
destination = os.path.join(self.destination_dir, os.path.basename(self.destination_file)) if destination is None else destination
download_ok = None
if source is None or destination is None:
logger.error(f'Error downloading file. Either source: {source} or destination: {destination} is not set!')
start_time = datetime.now()
logger.debug('Downloading file: {source} to: {destination}')
download_ok = self._download_file_action(source, destination)
if download_ok:
logger.info(f'Downloaded \'{source}\' to: \'{destination}\' in {datetime.now() - start_time} (h:mm:ss.ms)')
else:
logger.error(f'Downloading failed for \'{source}\' to: \'{destination}\' in {datetime.now() - start_time} (h:mm:ss.ms)')
return download_ok
def create_directories(self, path):
folders = []
for folder in path.strip('/').split('/'):
# Store travelled path. We need this to make the directories on the remote servers
folders.append(folder)
if not self.directory_exists('/'.join(folders)):
logger.debug(f'Creating folder {folder} with full path: {"/".join(folders)}')
self._make_folder_action('/'.join(folders))
else:
logger.debug(f'Folder \'{folder}\' already exists.')
return True
def _file_exists_action(self, path):
raise StorageException.StorageActionNotImplemented('BaseStorage', 'file_exists')
def _directory_exists_action(self, path):
raise StorageException.StorageActionNotImplemented('BaseStorage', 'directory_exists')
def _upload_file_action(self, source, destination):
raise StorageException.StorageActionNotImplemented('BaseStorage', '_upload_file')
def _download_file_action(self, source, destination):
raise StorageException.StorageActionNotImplemented('BaseStorage', '_download_file')
def _make_folder_action(self, path):
raise StorageException.StorageActionNotImplemented('BaseStorage', '_make_folder_action')

View File

@ -0,0 +1,8 @@
def human_filesize(nbytes):
suffixes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
i = 0
while nbytes >= 1024 and i < len(suffixes) - 1:
nbytes /= 1024.
i += 1
f = ('%.2f' % nbytes).rstrip('0').rstrip('.')
return '%s %s' % (f, suffixes[i])

View File

@ -0,0 +1,19 @@
{% extends 'base.html' %} <!-- Add this for inheritance -->
{% load i18n %}
{% block title %}{% trans "Oops, sorry, page not found (404)" %}{% endblock %}
{% block pagetitle %}<span style="color:red">{% trans "Oops, sorry, page not found (404)" %}</span>{% endblock %}
{% block content %}
<div class="rug-embed__container">
<iframe class="rug-embed__iframe" src="https://www.youtube-nocookie.com/embed/WOdjCb4LwQY?controls=0" frameborder="0" allow="autoplay; clipboard-write; encrypted-media" allowfullscreen></iframe>
</div>
<style>
.rug-breadcrumbs {
display:none
}
.rug-layout__item.rug-width-m-8-24 {
display:none
}
</style>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css" integrity="sha384-zCbKRCUGaJDkqS1kPbPd7TveP5iyJE0EjAuZQTgFLD2ylzuqKfdKlfG/eSrtxUkn" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.0.0/css/fontawesome.min.css" integrity="sha256-scTmoQvbqwHzP/+deIFu5oz5qacx8HZor9VGp5kky4A=" crossorigin="anonymous">
<title>{% block title %}{% trans "Hanze enquete" %}{% endblock %}</title>
<!-- Optional JavaScript -->
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/js/bootstrap.min.js" integrity="sha384-VHvPCCyXqtD5DqJeNxl2dtTyhF78xXNXdkwX1CZeRusQfRKp+tA7hAShOK/B/fQ2" crossorigin="anonymous"></script>
</head>
<body>
<div class="container">
<noscript>
<strong>Javascript must be enabled for the correct page display</strong>
</noscript>
{% block content %} -=PageContent=- {% endblock %}
</div>
</body>
</html>

View File

@ -0,0 +1,20 @@
{% extends 'base.html' %} <!-- Add this for inheritance -->
{% load i18n %}
{% block title %}{% trans "Virtual Research Environment" %}{% endblock %}
{% block pagetitle %}{% trans "Virtual Research Environment" %}{% endblock %}
{% block content %}
<p>
<strong>{% trans "Secure data drops to RUG Phd students" %}</strong>
</p>
<p>{% trans "Here you can securely upload files for Phd students and researchers." %}</p>
<p>
{% trans "The following actions can be done:" %}
<ol>
<li>{% trans "Study overview for grouping uploads for a single research study" %}</li>
<li>{% trans "Create a new study" %}</li>
<li>{% trans "Get a list of all the uploaded files for your studies" %}</li>
<li>{% trans "Logout" %}</li>
</ol>
</p>
{% endblock %}

View File

@ -0,0 +1,44 @@
{% load i18n %}
<li class="rug-nav--secondary__item">
<a class="rug-nav--secondary__link js--togglable-switch" data-toggle-class="rug-nav--secondary__link--selected" data-toggle-group="submenu" data-toggle-id="menu-2427370b-9435-44d9-bca7-b93ec9d03cc0-33.31" data-toggle-mode="togglable">{% trans "VRE" %}</a>
<ul class="rug-nav--secondary__sub rug-nav--secondary__sub--hidden js--togglable-item" data-toggle-class="rug-block" data-toggle-group="submenu" data-toggle-id="menu-2427370b-9435-44d9-bca7-b93ec9d03cc0-33.31">
{% if not user.is_authenticated %}
<li class="rug-nav--secondary__sub__item" data-menu-id="b512aa55-f0cb-4588-9054-302caa5fa951-33.34">
<a class="rug-nav--secondary__sub__link" href="{% url 'login' %}"><span class="rug-nav--secondary__sub__link-text">{% trans "Login" %}</span></a>
</li>
{% endif %}
{% if user.is_authenticated %}
<li class="rug-nav--secondary__sub__item" data-menu-id="b512aa55-f0cb-4588-9054-302caa5fa951-33.34">
<a class="rug-nav--secondary__sub__link" href="{% url 'study:list' %}"><span class="rug-nav--secondary__sub__link-text">{% trans "Studies" %}</span></a>
</li>
<li class="rug-nav--secondary__sub__item" data-menu-id="ea2e3668-6206-4f68-9e29-fea84b472fb4-33.34">
<a class="rug-nav--secondary__sub__link" href="{% url 'study:new' %}"><span class="rug-nav--secondary__sub__link-text" style="margin-left: 50px;">{% trans "New" %}</span></a>
</li>
<li class="rug-nav--secondary__sub__item" data-menu-id="ea2e3668-6206-4f68-9e29-fea84b472fb4-33.34">
<a class="rug-nav--secondary__sub__link" href="{% url 'storage:list' %}"><span class="rug-nav--secondary__sub__link-text">{% trans "Storages" %}</span></a>
</li>
<li class="rug-nav--secondary__sub__item" data-menu-id="ea2e3668-6206-4f68-9e29-fea84b472fb4-33.34">
<a class="rug-nav--secondary__sub__link" href="{% url 'storage:new' %}"><span class="rug-nav--secondary__sub__link-text" style="margin-left: 50px;">{% trans "New" %}</span></a>
</li>
<li class="rug-nav--secondary__sub__item" data-menu-id="ea2e3668-6206-4f68-9e29-fea84b472fb4-33.34">
<a class="rug-nav--secondary__sub__link" href="{% url 'virtual_machine:list' %}"><span class="rug-nav--secondary__sub__link-text">{% trans "Virtual machines" %}</span></a>
</li>
<li class="rug-nav--secondary__sub__item" data-menu-id="ea2e3668-6206-4f68-9e29-fea84b472fb4-33.34">
<a class="rug-nav--secondary__sub__link" href="{% url 'virtual_machine:new' %}"><span class="rug-nav--secondary__sub__link-text" style="margin-left: 50px;">{% trans "New" %}</span></a>
</li>
<li class="rug-nav--secondary__sub__item" data-menu-id="ea2e3668-6206-4f68-9e29-fea84b472fb4-33.34">
<a class="rug-nav--secondary__sub__link" href="{% url 'dropoff:list' %}"><span class="rug-nav--secondary__sub__link-text">{% trans "Activity" %}</span></a>
</li>
<li class="rug-nav--secondary__sub__item" data-menu-id="b512aa55-f0cb-4588-9054-302caa5fa951-33.36">
<a class="rug-nav--secondary__sub__link" href="{% url 'logout' %}"><span class="rug-nav--secondary__sub__link-text">{% trans "Logout" %}</span></a>
</li>
{% endif %}
</ul>
</li>

View File

@ -0,0 +1,12 @@
{% extends 'base.html' %} <!-- Add this for inheritance -->
{% load i18n %}
{% block title %}{% trans "Signup" %}{% endblock %}
{% block pagetitle %}{% trans "Signup" %}{% endblock %}
{% block content %}
<p>
<strong>{% trans "Signup" %}</strong>
<br />
{% blocktrans %}Please contact x@y.z{% endblocktrans %}
</p>
{% endblock %}

2
Enquete/uitkomsten/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!*.gitignore