diff --git a/README-dev.md b/README-dev.md index da2398322..8e16bb4bb 100644 --- a/README-dev.md +++ b/README-dev.md @@ -33,9 +33,9 @@ When it's done, you can continue with this command: Linux and MacOS: ``` +cp docker-compose.override.example.yml docker-compose.override.yml make build-devweb make devweb -cp docker-compose.override.example.yml docker-compose.override.yml ``` In case you don't get some not installed packages, you can run this @@ -54,6 +54,7 @@ repeatable steps: Windows: ``` +copy docker-compose.override.example.yml docker-compose.override.yml make-devbuild.bat make-devweb.bat ``` diff --git a/deployment/docker/Dockerfile b/deployment/docker/Dockerfile index d02260a7e..5e0e5c5cc 100644 --- a/deployment/docker/Dockerfile +++ b/deployment/docker/Dockerfile @@ -6,7 +6,7 @@ MAINTAINER Tim Sutton #RUN ln -s /bin/true /sbin/initctl # Pandoc needed to generate rst dumps, uic compressor needed for django-pipeline -RUN apt-get update -y; apt-get -y --force-yes install yui-compressor gettext +RUN apt-get update -y && apt-get -y --force-yes install yui-compressor gettext RUN wget https://github.com/jgm/pandoc/releases/download/1.17.1/pandoc-1.17.1-2-amd64.deb RUN dpkg -i pandoc-1.17.1-2-amd64.deb && rm pandoc-1.17.1-2-amd64.deb @@ -18,7 +18,7 @@ ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 ADD deployment/docker/REQUIREMENTS.txt /REQUIREMENTS.txt ADD deployment/docker/uwsgi.conf /uwsgi.conf ADD django_project /home/web/django_project -RUN pip install -r /REQUIREMENTS.txt +RUN pip install --upgrade pip && pip install -r /REQUIREMENTS.txt RUN pip install uwsgi @@ -50,7 +50,7 @@ RUN echo "export VISIBLE=now" >> /etc/profile # End of cut & paste section ADD deployment/docker/REQUIREMENTS-dev.txt /REQUIREMENTS-dev.txt -RUN pip install -r /REQUIREMENTS-dev.txt +RUN pip install --upgrade pip && pip install -r /REQUIREMENTS-dev.txt ADD deployment/docker/bashrc /root/.bashrc # -------------------------------------------------------- diff --git a/django_project/certification/admin.py b/django_project/certification/admin.py index e753b304e..5287f084a 100644 --- a/django_project/certification/admin.py +++ b/django_project/certification/admin.py @@ -4,6 +4,7 @@ from django.contrib.gis import admin from simple_history.admin import SimpleHistoryAdmin from certification.models.certificate import Certificate +from certification.models.certificate_type import CertificateType from certification.models.course import Course from certification.models.training_center import TrainingCenter from certification.models.course_convener import CourseConvener @@ -34,6 +35,15 @@ def queryset(self, request): return query_set +class CertificateTypeAdmin(admin.ModelAdmin): + """CertificateType admin model.""" + + list_display = ('name', 'wording', 'order') + list_editable = ('order', ) + search_fields = ('name', 'wording') + ordering = ('order', ) + + class AttendeeAdmin(admin.ModelAdmin): """Attendee admin model.""" list_display = ('firstname', 'surname', 'email', 'certifying_organisation') @@ -163,6 +173,7 @@ class StatusAdmin(admin.ModelAdmin): admin.site.register(Certificate, CertificateAdmin) +admin.site.register(CertificateType, CertificateTypeAdmin) admin.site.register(Attendee, AttendeeAdmin) admin.site.register(Course, CourseAdmin) admin.site.register(CourseType, CourseTypeAdmin) diff --git a/django_project/certification/forms.py b/django_project/certification/forms.py index 96f3d4fb2..b900798b2 100644 --- a/django_project/certification/forms.py +++ b/django_project/certification/forms.py @@ -21,6 +21,7 @@ ) from .models import ( CertifyingOrganisation, + CertificateType, CourseConvener, CourseType, TrainingCenter, @@ -156,6 +157,7 @@ class Meta: 'user', 'degree', 'signature', + 'is_active', ) def __init__(self, *args, **kwargs): @@ -171,6 +173,7 @@ def __init__(self, *args, **kwargs): Field('user', css_class='form-control chosen-select'), Field('degree', css_class='form-control'), Field('signature', css_class='form-control'), + Field('is_active', css_class='checkbox-primary'), ) ) self.helper.layout = layout @@ -303,6 +306,7 @@ class Meta: 'end_date', 'template_certificate', 'certifying_organisation', + 'certificate_type', ) def __init__(self, *args, **kwargs): @@ -322,6 +326,7 @@ def __init__(self, *args, **kwargs): Field('start_date', css_class='form-control'), Field('end_date', css_class='form-control'), Field('template_certificate', css_class='form-control'), + Field('certificate_type', css_class='form-control'), ) ) self.helper.layout = layout @@ -329,7 +334,8 @@ def __init__(self, *args, **kwargs): super(CourseForm, self).__init__(*args, **kwargs) self.fields['course_convener'].queryset = \ CourseConvener.objects.filter( - certifying_organisation=self.certifying_organisation) + certifying_organisation=self.certifying_organisation, + is_active=True) self.fields['course_convener'].label_from_instance = \ lambda obj: "%s <%s>" % (obj.user.get_full_name(), obj) self.fields['course_type'].queryset = \ @@ -342,6 +348,10 @@ def __init__(self, *args, **kwargs): self.certifying_organisation self.fields['certifying_organisation'].widget = forms.HiddenInput() self.helper.add_input(Submit('submit', 'Submit')) + self.fields['certificate_type'].queryset = \ + CertificateType.objects.filter( + projectcertificatetype__project= + self.certifying_organisation.project) def save(self, commit=True): instance = super(CourseForm, self).save(commit=False) diff --git a/django_project/certification/migrations/0007_certificatetype.py b/django_project/certification/migrations/0007_certificatetype.py new file mode 100644 index 000000000..9434d8235 --- /dev/null +++ b/django_project/certification/migrations/0007_certificatetype.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.18 on 2021-12-03 00:56 + +from django.db import migrations, models + + +def add_certification_type_as_existing_value(apps, schema_editor): + CertificateType = apps.get_model('certification', 'CertificateType') + CertificateType.objects.create( + name='attendance and completion', + wording='Has attended and completed the course:', + order=0 + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('certification', '0006_auto_20210730_0615'), + ] + + operations = [ + migrations.CreateModel( + name='CertificateType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Certificate type.', max_length=200, unique=True)), + ('description', models.TextField(blank=True, help_text='Certificate type description - 1000 characters limit.', max_length=1000, null=True)), + ('wording', models.CharField(help_text='Wording that will be placed on certificate. e.g. "Has attended and completed".', max_length=500)), + ('order', models.IntegerField(blank=True, null=True, unique=True)), + ], + ), + + migrations.RunPython(add_certification_type_as_existing_value, reverse_code=migrations.RunPython.noop), + ] diff --git a/django_project/certification/migrations/0007_courseconvener_is_active.py b/django_project/certification/migrations/0007_courseconvener_is_active.py new file mode 100644 index 000000000..427a58442 --- /dev/null +++ b/django_project/certification/migrations/0007_courseconvener_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.18 on 2021-11-26 04:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certification', '0006_auto_20210730_0615'), + ] + + operations = [ + migrations.AddField( + model_name='courseconvener', + name='is_active', + field=models.BooleanField(default=True, help_text='Inactive Convener will not be available in your organisation list.'), + ), + ] diff --git a/django_project/certification/migrations/0008_projectcertificatetype.py b/django_project/certification/migrations/0008_projectcertificatetype.py new file mode 100644 index 000000000..b13a5b41d --- /dev/null +++ b/django_project/certification/migrations/0008_projectcertificatetype.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.18 on 2021-12-10 02:23 + +from django.db import migrations, models +import django.db.models.deletion + +def create_existing_project_certificate_type(apps, schema_editor): + CertificateType = apps.get_model('certification', 'CertificateType') + ProjectCertificateType = apps.get_model('certification', 'ProjectCertificateType') + Project = apps.get_model('base', 'Project') + certificate_type = CertificateType.objects.filter( + name='attendance and completion').first() + projects = Project.objects.all() + + for project in projects: + ProjectCertificateType.objects.create( + project=project, + certificate_type=certificate_type + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0006_auto_20210308_0244'), + ('certification', '0007_certificatetype'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectCertificateType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('certificate_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='certification.CertificateType')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='base.Project')), + ], + ), + + migrations.RunPython(create_existing_project_certificate_type, reverse_code=migrations.RunPython.noop), + ] diff --git a/django_project/certification/migrations/0009_course_certificate_type.py b/django_project/certification/migrations/0009_course_certificate_type.py new file mode 100644 index 000000000..d44f9e233 --- /dev/null +++ b/django_project/certification/migrations/0009_course_certificate_type.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.18 on 2021-12-10 08:31 + +from django.db import migrations, models +import django.db.models.deletion + +def set_existing_certificate_type_value(apps, shcema_editor): + CertificateType = apps.get_model('certification', 'CertificateType') + Course = apps.get_model('certification', 'Course') + certificate_type = CertificateType.objects.filter( + name='attendance and completion').first() + courses = Course.objects.all() + + for course in courses: + course.certificate_type = certificate_type + course.save(update_fields=['certificate_type']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('certification', '0008_projectcertificatetype'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='certificate_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='certification.CertificateType'), + ), + + migrations.RunPython(set_existing_certificate_type_value, reverse_code=migrations.RunPython.noop), + + migrations.AlterField( + model_name='course', + name='certificate_type', + field=models.ForeignKey(null=False, on_delete=django.db.models.deletion.PROTECT, + to='certification.CertificateType'), + preserve_default=False, + ), + + ] diff --git a/django_project/certification/migrations/0010_merge_20220212_0417.py b/django_project/certification/migrations/0010_merge_20220212_0417.py new file mode 100644 index 000000000..3b08d2086 --- /dev/null +++ b/django_project/certification/migrations/0010_merge_20220212_0417.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.18 on 2022-02-12 02:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('certification', '0007_courseconvener_is_active'), + ('certification', '0009_course_certificate_type'), + ] + + operations = [ + ] diff --git a/django_project/certification/models/__init__.py b/django_project/certification/models/__init__.py index 10e9bf2df..79c82dd0c 100644 --- a/django_project/certification/models/__init__.py +++ b/django_project/certification/models/__init__.py @@ -10,5 +10,6 @@ from certification.models.course_attendee import * from certification.models.course_type import * from certification.models.course_convener import * +from certification.models.certificate_type import * from certification.models.certificate import * from certification.models.organisation_certificate import * diff --git a/django_project/certification/models/certificate_type.py b/django_project/certification/models/certificate_type.py new file mode 100644 index 000000000..8647a4014 --- /dev/null +++ b/django_project/certification/models/certificate_type.py @@ -0,0 +1,55 @@ +"""Certificate type model for certification app""" + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from base.models.project import Project + + +class CertificateType(models.Model): + name = models.CharField( + help_text=_('Certificate type.'), + max_length=200, + null=False, + blank=False, + unique=True, + ) + + description = models.TextField( + help_text=_('Certificate type description - 1000 characters limit.'), + max_length=1000, + null=True, + blank=True, + ) + + wording = models.CharField( + help_text=_( + 'Wording that will be placed on certificate. ' + 'e.g. "Has attended and completed".' + ), + max_length=500, + null=False, + blank=False + ) + + order = models.IntegerField( + blank=True, + null=True, + unique=True + ) + + def __str__(self): + return self.name + + +class ProjectCertificateType(models.Model): + """A model to store a certificate type linked to a project""" + + project = models.ForeignKey( + Project, + on_delete=models.CASCADE + ) + certificate_type = models.ForeignKey( + CertificateType, + on_delete=models.CASCADE + ) diff --git a/django_project/certification/models/certifying_organisation.py b/django_project/certification/models/certifying_organisation.py index 8ca634d14..07332a295 100644 --- a/django_project/certification/models/certifying_organisation.py +++ b/django_project/certification/models/certifying_organisation.py @@ -71,10 +71,11 @@ def validate_email_address(value): try: validate_email(value) return True - except ValidationError( + except ValidationError: + raise ValidationError( _('%(value)s is not a valid email address'), - params={'value': value},): - return False + params={'value': value}, + ) class CertifyingOrganisation(models.Model): diff --git a/django_project/certification/models/course.py b/django_project/certification/models/course.py index a845538e3..339aa3306 100644 --- a/django_project/certification/models/course.py +++ b/django_project/certification/models/course.py @@ -20,6 +20,7 @@ from .course_type import CourseType from certification.utilities import check_slug from .training_center import TrainingCenter +from certification.models.certificate_type import CertificateType logger = logging.getLogger(__name__) @@ -86,6 +87,8 @@ class Course(models.Model): on_delete=models.CASCADE) certifying_organisation = models.ForeignKey(CertifyingOrganisation, on_delete=models.CASCADE) + certificate_type = models.ForeignKey( + CertificateType, on_delete=models.PROTECT, null=True) author = models.ForeignKey(User, on_delete=models.CASCADE) objects = models.Manager() diff --git a/django_project/certification/models/course_convener.py b/django_project/certification/models/course_convener.py index 0a7c07eed..6dc2c0750 100644 --- a/django_project/certification/models/course_convener.py +++ b/django_project/certification/models/course_convener.py @@ -52,6 +52,12 @@ class CourseConvener(models.Model): null=True ) + is_active = models.BooleanField( + help_text=_('Inactive Convener will not be available in your ' + 'organisation list.'), + default=True + ) + class Meta: ordering = ['user'] diff --git a/django_project/certification/templates/certificate_type/list.html b/django_project/certification/templates/certificate_type/list.html new file mode 100644 index 000000000..d742cbea9 --- /dev/null +++ b/django_project/certification/templates/certificate_type/list.html @@ -0,0 +1,43 @@ +{% extends "project_base.html" %} + +{% block extra_js %} +{% endblock %} + +{% block content %} + + + + + + + + + + + + + + + {% csrf_token %} + {% for cer_type in certificate_types %} + + + + + + {% endfor %} + +
Certificate TypeWordingApply
{{ cer_type.name }}{{ cer_type.wording }} + +
+ + +{% endblock %} diff --git a/django_project/certification/templates/certifying_organisation/detail.html b/django_project/certification/templates/certifying_organisation/detail.html index a9343cad3..b565bccc0 100644 --- a/django_project/certification/templates/certifying_organisation/detail.html +++ b/django_project/certification/templates/certifying_organisation/detail.html @@ -85,6 +85,9 @@

Certifying Organisation (all)

color: darkblue !important; text-decoration: none !important; } + .btn-disabled, .btn-disabled:hover { + color: #d3d3d3 + } {% if messages %} @@ -536,34 +539,7 @@
No course convener are defined, but you can {% for courseconvener in courseconveners %} - - -
- {% if courseconvener.user.first_name %} - {{ courseconvener.user.first_name }} {{ courseconvener.user.last_name }} - {% else %} - {{ courseconvener.user }} - {% endif %} -
- - - {% if user_can_delete %} - -
- - {% endif %} - + {% include 'certifying_organisation/includes/convenor_list.html' %} {% endfor %} diff --git a/django_project/certification/templates/certifying_organisation/includes/convenor_list.html b/django_project/certification/templates/certifying_organisation/includes/convenor_list.html new file mode 100644 index 000000000..05ce9008f --- /dev/null +++ b/django_project/certification/templates/certifying_organisation/includes/convenor_list.html @@ -0,0 +1,50 @@ + + +
+ {% if courseconvener.is_active %} + {% if courseconvener.user.first_name %} + {{ courseconvener.user.first_name }} {{ courseconvener.user.last_name }} + {% else %} + {{ courseconvener.user }} + {% endif %} + {% elif user_can_delete %} + + {% if courseconvener.user.first_name %} + {{ courseconvener.user.first_name }} {{ courseconvener.user.last_name }} + {% else %} + {{ courseconvener.user }} + {% endif %} + + [inactive] + {% endif %} +
+ + {% if user_can_delete %} + +
+ {% if courseconvener.course_set.exists %} + + + + {% else %} + + + + {% endif %} + + + +
+ + {% endif %} + \ No newline at end of file diff --git a/django_project/certification/templates/course/create.html b/django_project/certification/templates/course/create.html index bc5db0f90..0fb35efda 100644 --- a/django_project/certification/templates/course/create.html +++ b/django_project/certification/templates/course/create.html @@ -99,6 +99,7 @@

New Course for {{ organisation.name }}

+

@@ -130,6 +131,9 @@

New Course for {{ organisation.name }}

}else if($('input[id=id_end_date]').val() === ''){ $('#error-submit').html('Please choose end date.'); return false + }else if($('select[id=id_certificate_type]').val() === ''){ + $('#error-submit').html('Please choose certificate type.'); + return false } $('#preview-form input[name=course_convener]').val($('select[name=course_convener]').val()); @@ -138,6 +142,7 @@

New Course for {{ organisation.name }}

$('#preview-form input[name=start_date]').val($('input[id=id_start_date]').val()); $('#preview-form input[name=end_date]').val($('input[id=id_end_date]').val()); $('#preview-form input[name=trained_competence]').val($('input[id=id_trained_competence]').val()); + $('#preview-form input[name=certificate_type]').val($('select[id=id_certificate_type]').val()); } //check if browser supports file api and filereader features diff --git a/django_project/certification/templates/course/update.html b/django_project/certification/templates/course/update.html index b83e1ecb4..5cc923f05 100644 --- a/django_project/certification/templates/course/update.html +++ b/django_project/certification/templates/course/update.html @@ -106,6 +106,7 @@

Update Course for {{ organisation.name }}

+

@@ -138,6 +139,9 @@

Update Course for {{ organisation.name }}

}else if($('input[id=id_end_date]').val() === ''){ $('#error-submit').html('Please choose end date.'); return false + }else if($('select[id=id_certificate_type]').val() === ''){ + $('#error-submit').html('Please choose certificate type.'); + return false } $('#preview-form input[name=course_convener]').val($('select[name=course_convener]').val()); @@ -146,6 +150,7 @@

Update Course for {{ organisation.name }}

$('#preview-form input[name=start_date]').val($('input[id=id_start_date]').val()); $('#preview-form input[name=end_date]').val($('input[id=id_end_date]').val()); $('#preview-form input[name=trained_competence]').val($('input[id=id_trained_competence]').val()); + $('#preview-form input[name=certificate_type]').val($('select[id=id_certificate_type]').val()); } //check if browser supports file api and filereader features diff --git a/django_project/certification/tests/model_factories.py b/django_project/certification/tests/model_factories.py index 557b61797..96a480403 100644 --- a/django_project/certification/tests/model_factories.py +++ b/django_project/certification/tests/model_factories.py @@ -5,6 +5,8 @@ from certification.models import ( Certificate, + CertificateType, + ProjectCertificateType, Attendee, Course, CourseType, @@ -81,6 +83,19 @@ class Meta: author = factory.SubFactory(UserF) +class CertificateTypeF(factory.django.DjangoModelFactory): + """CertificateType model factory.""" + + class Meta: + model = CertificateType + + name = factory.sequence(lambda n: 'Test certificate type name %s' % n) + description = factory.sequence( + lambda n: 'Description certificate type %s' % n) + wording = factory.sequence( + lambda n: 'Wording certificate type %s' % n) + + class CourseF(factory.django.DjangoModelFactory): """Course model factory.""" @@ -97,6 +112,7 @@ class Meta: course_type = factory.SubFactory(CourseTypeF) training_center = factory.SubFactory(TrainingCenterF) author = factory.SubFactory(UserF) + certificate_type = factory.SubFactory(CertificateTypeF) class AttendeeF(factory.django.DjangoModelFactory): @@ -124,6 +140,16 @@ class Meta: attendee = factory.SubFactory(AttendeeF) +class ProjectCertificateTypeF(factory.django.DjangoModelFactory): + """ProjectCertificateType model factory.""" + + class Meta: + model = ProjectCertificateType + + project = factory.SubFactory(ProjectF) + certificate_type = factory.SubFactory(CertificateTypeF) + + class CertificateF(factory.django.DjangoModelFactory): """Certificate model factory.""" diff --git a/django_project/certification/tests/test_models.py b/django_project/certification/tests/test_models.py index c7e723990..f01f3d4e9 100644 --- a/django_project/certification/tests/test_models.py +++ b/django_project/certification/tests/test_models.py @@ -1,9 +1,12 @@ # coding=utf-8 """Test for models.""" +from django.db.utils import IntegrityError +from django.core.exceptions import ValidationError from django.test import TestCase from certification.tests.model_factories import ( CertificateF, + CertificateTypeF, AttendeeF, CourseF, CourseTypeF, @@ -13,6 +16,14 @@ CourseAttendeeF, StatusF ) +from certification.models.certificate_type import CertificateType + + +class SetUpMixin: + def setUp(self): + """Set up before each test.""" + # Delete CertificateType created from migration 0007_certificate_type + CertificateType.objects.all().delete() class TestCertifyingOrganisation(TestCase): @@ -94,17 +105,11 @@ def test_Certifying_Organisation_update(self): self.assertEqual(model.__dict__.get(key), val) -class TestCertificate(TestCase): +class CertificateSetUp(SetUpMixin, TestCase): """Test certificate model.""" - def setUp(self): - """Set up before test.""" - - pass - def test_Certificate_create(self): """Test certificate model creation.""" - model = CertificateF.create() # check if PK exists. @@ -120,6 +125,65 @@ def test_Certificate_delete(self): self.assertTrue(model.pk is None) + +class CertificateTypeSetUp(SetUpMixin, TestCase): + """Test Certificate models.""" + + def test_CRUD_CertificateType(self): + # initial + self.assertEqual(CertificateType.objects.all().count(), 0) + + # create model + model = CertificateTypeF.create() + self.assertEqual(CertificateType.objects.all().count(), 1) + + # read model + self.assertIsNotNone(model.id) + self.assertIn('Test certificate type name', model.name) + self.assertIn('Description certificate type', model.description) + self.assertIn('Wording certificate type', model.wording) + self.assertEqual(model.__str__(), model.name) + + # + model.name = 'Update certificate type name' + model.save() + self.assertEqual(model.name, 'Update certificate type name') + + model.delete() + self.assertIsNone(model.id) + self.assertEqual(CertificateType.objects.all().count(), 0) + + def test_name_field_must_be_unique(self): + CertificateTypeF.create(name="We are twin") + msg = ('duplicate key value violates unique constraint ' + '"certification_certificatetype_name_key"') + with self.assertRaisesMessage(IntegrityError, msg): + CertificateTypeF.create(name="We are twin") + + def test_order_field_must_be_unique(self): + CertificateTypeF.create(order=1) + msg = ('duplicate key value violates unique constraint ' + '"certification_certificatetype_order_key"') + with self.assertRaisesMessage(IntegrityError, msg): + CertificateTypeF.create(order=1) + + def test_order_field_can_be_null(self): + model_1 = CertificateTypeF.create(order=1) + model_2 = CertificateTypeF.create(order=2) + + self.assertEqual(model_1.order, 1) + self.assertEqual(model_2.order, 2) + + model_1.order = None + model_1.save() + + model_2.order = 1 + model_2.save() + + self.assertEqual(model_1.order, None) + self.assertEqual(model_2.order, 1) + + class TestAttendee(TestCase): """Test attendee model.""" @@ -336,6 +400,7 @@ def test_Course_Convener_create(self): self.assertEqual(model.__dict__.get(key), val) self.assertTrue(model.title == 'new Course Convener Title') self.assertTrue(model.degree == 'new Course Convener Degree') + self.assertTrue(model.is_active) # check if PK exists. self.assertTrue(model.pk is not None) @@ -415,3 +480,14 @@ def test_Status_update(self): for key, val in new_model_data.items(): self.assertEqual(model.__dict__.get(key), val) self.assertTrue(model.name == 'new Status name') + + +class TestValidateEmailAddress(TestCase): + """Test validate_email_address function.""" + + def test_validation_failed_must_raise_ValidationError(self): + from certification.models import validate_email_address + email = 'email@wrongdomain' + msg = f'{email} is not a valid email address' + with self.assertRaisesMessage(ValidationError, msg): + validate_email_address(email) diff --git a/django_project/certification/tests/views/test_certificate_previews.py b/django_project/certification/tests/views/test_certificate_previews.py index 7673b5acd..3faab7345 100644 --- a/django_project/certification/tests/views/test_certificate_previews.py +++ b/django_project/certification/tests/views/test_certificate_previews.py @@ -1,4 +1,5 @@ # coding=utf-8 +from unittest.mock import patch from django.urls import reverse from django.test import TestCase, override_settings from django.test.client import Client @@ -9,7 +10,8 @@ CertifyingOrganisationF, CourseConvenerF, TrainingCenterF, - CourseTypeF + CourseTypeF, + CertificateTypeF ) @@ -46,6 +48,7 @@ def setUp(self): self.convener = CourseConvenerF.create() self.training_center = TrainingCenterF.create() self.course_type = CourseTypeF.create() + self.certificate_type = CertificateTypeF.create() @override_settings(VALID_DOMAIN=['testserver', ]) def tearDown(self): @@ -80,7 +83,8 @@ def test_preview_certificate_no_data_posted(self): self.assertEqual(response.status_code, 200) @override_settings(VALID_DOMAIN=['testserver', ]) - def test_preview_certificate_with_posted_data(self): + @patch('certification.views.certificate.generate_pdf') + def test_preview_certificate_with_posted_data(self, mock_gen_pdf): client = Client(HTTP_HOST='testserver') client.login(username='anita', password='password') post_data = { @@ -96,3 +100,61 @@ def test_preview_certificate_with_posted_data(self): 'organisation_slug': self.test_certifying_organisation.slug }), post_data) self.assertEqual(response.status_code, 200) + # Only 6 args in generate_pdf call, + # Since there's no CertificateType id in POST body + self.assertEqual(len(mock_gen_pdf.call_args[0]), 6) + self.assertIn(self.test_project, mock_gen_pdf.call_args[0]) + self.assertNotIn( + self.certificate_type.wording, mock_gen_pdf.call_args[0]) + + @override_settings(VALID_DOMAIN=['testserver', ]) + @patch('certification.views.certificate.generate_pdf') + def test_preview_certificate_with_posted_data_and_certificate_type( + self, mock_gen_pdf): + client = Client(HTTP_HOST='testserver') + client.login(username='anita', password='password') + post_data = { + 'course_convener': self.convener.pk, + 'training_center': self.training_center.pk, + 'course_type': self.course_type.pk, + 'start_date': '2018-01-01', + 'end_date': '2018-02-01', + 'template_certificate': '', + 'certificate_type': self.certificate_type.id + } + response = client.post(reverse('preview-certificate', kwargs={ + 'project_slug': self.test_project.slug, + 'organisation_slug': self.test_certifying_organisation.slug + }), post_data) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mock_gen_pdf.call_args[0]), 7) + self.assertIn(self.test_project, mock_gen_pdf.call_args[0]) + self.assertIn(self.certificate_type.wording, mock_gen_pdf.call_args[0]) + + + @override_settings(VALID_DOMAIN=['testserver', ]) + @patch('certification.views.certificate.generate_pdf') + def test_preview_certificate_with_posted_data_cert_type_not_found( + self, mock_gen_pdf): + client = Client(HTTP_HOST='testserver') + client.login(username='anita', password='password') + post_data = { + 'course_convener': self.convener.pk, + 'training_center': self.training_center.pk, + 'course_type': self.course_type.pk, + 'start_date': '2018-01-01', + 'end_date': '2018-02-01', + 'template_certificate': '', + 'certificate_type': 99999 + } + response = client.post(reverse('preview-certificate', kwargs={ + 'project_slug': self.test_project.slug, + 'organisation_slug': self.test_certifying_organisation.slug + }), post_data) + self.assertEqual(response.status_code, 200) + # Only 6 args in generate_pdf call, + # Since there's the CertificateType id doesn't exist + self.assertEqual(len(mock_gen_pdf.call_args[0]), 6) + self.assertIn(self.test_project, mock_gen_pdf.call_args[0]) + self.assertNotIn( + self.certificate_type.wording, mock_gen_pdf.call_args[0]) diff --git a/django_project/certification/tests/views/test_certificate_type_view.py b/django_project/certification/tests/views/test_certificate_type_view.py new file mode 100644 index 000000000..61a32f6d4 --- /dev/null +++ b/django_project/certification/tests/views/test_certificate_type_view.py @@ -0,0 +1,70 @@ +from bs4 import BeautifulSoup as Soup +from django.shortcuts import reverse +from django.test import TestCase, override_settings + +from base.tests.model_factories import ProjectF +from core.model_factories import UserF +from certification.tests.model_factories import ( + CertificateTypeF, + ProjectCertificateTypeF +) + + +class TestCertificateTypesView(TestCase): + + def setUp(self): + self.project = ProjectF.create() + another_project = ProjectF.create() + self.certificate_type_1 = CertificateTypeF.create(name='type-1') + self.certificate_type_2 = CertificateTypeF.create(name='type-2') + ProjectCertificateTypeF.create( + project=self.project, certificate_type=self.certificate_type_1 + ) + ProjectCertificateTypeF.create( + project=another_project, certificate_type=self.certificate_type_2 + ) + self.user = UserF.create(**{ + 'username': 'tester', + 'password': 'password', + 'is_staff': True, + }) + self.user.set_password('password') + self.user.save() + + @override_settings(VALID_DOMAIN=['testserver', ]) + def test_certificate_type_view_contains_course_type(self): + """Test CertificateType list page.""" + + self.client.post('/set_language/', data={'language': 'en'}) + self.client.login(username='tester', password='password') + url = reverse('certificate-type-list', kwargs={ + 'project_slug': self.project.slug + }) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # all certificate types should be displayed + self.assertContains(response, self.certificate_type_1.name) + self.assertContains(response, self.certificate_type_2.name) + + # only certificate types related to project in context_object ListView + self.assertEqual(len(response.context_data['object_list']), 1) + self.assertEqual( + response.context_data['object_list'].last().certificate_type, + self.certificate_type_1 + ) + + @override_settings(VALID_DOMAIN=['testserver', ]) + def test_update_project_certificate_view(self): + self.client.post('/set_language/', data={'language': 'en'}) + self.client.login(username='tester', password='password') + url = reverse('certificate-type-update', kwargs={ + 'project_slug': self.project.slug + }) + # choose certificate type-2 only + post_data = {'certificate_types': 'type-2'} + response = self.client.post(url, data=post_data, follow=True) + self.assertEqual(response.status_code, 200) + soup = Soup(response.content, "html5lib") + self.assertTrue(len(soup.find_all('input', checked=True)) == 1) + self.assertEqual(soup.find('input', checked=True)["value"], "type-2") diff --git a/django_project/certification/tests/views/test_course_view.py b/django_project/certification/tests/views/test_course_view.py index 3a1a25693..8c27da104 100644 --- a/django_project/certification/tests/views/test_course_view.py +++ b/django_project/certification/tests/views/test_course_view.py @@ -1,5 +1,7 @@ # coding=utf-8 import logging +from bs4 import BeautifulSoup as Soup + from django.test import TestCase, override_settings from django.test.client import Client from django.urls import reverse @@ -7,7 +9,10 @@ ProjectF, UserF, CertifyingOrganisationF, - CourseF + CertificateTypeF, + ProjectCertificateTypeF, + CourseF, + CourseConvenerF ) @@ -41,6 +46,11 @@ def setUp(self): self.course = CourseF.create( certifying_organisation=self.certifying_organisation ) + self.certificate_type = CertificateTypeF.create() + self.project_cert_type = ProjectCertificateTypeF.create( + project=self.project, + certificate_type=self.certificate_type + ) @override_settings(VALID_DOMAIN=['testserver', ]) def tearDown(self): @@ -55,6 +65,24 @@ def tearDown(self): self.project.delete() self.user.delete() + @override_settings(VALID_DOMAIN=['testserver', ]) + def test_create_course_must_showing_CertificateTypes(self): + self.client.login(username='anita', password='password') + response = self.client.get(reverse('course-create', kwargs={ + 'project_slug': self.project.slug, + 'organisation_slug': self.certifying_organisation.slug, + })) + self.assertEqual(response.status_code, 200) + soup = Soup(response.content, "html5lib") + cert_type_option = soup.find( + 'select', + {'id': 'id_certificate_type'} + ).find_all('option') + self.assertIn( + self.certificate_type.name, + [cert_type.text for cert_type in cert_type_option] + ) + @override_settings(VALID_DOMAIN=['testserver', ]) def test_detail_view(self): client = Client() @@ -162,3 +190,108 @@ def test_delete_with_duplicates(self): 'slug': self.certifying_organisation.slug, }) self.assertRedirects(response, expected_url) + + @override_settings(VALID_DOMAIN=['testserver', ]) + def test_inactive_convener_should_not_be_in_the_course_convener_list(self): + convener = UserF.create(**{ + 'username': 'convener', + 'password': 'password', + 'first_name': 'Pretty', + 'last_name': 'Smart', + 'is_staff': True + }) + convener_inactive = UserF.create(**{ + 'username': 'inactive_convener', + 'password': 'password', + 'first_name': 'Wonder', + 'last_name': 'Woman', + 'is_staff': True + }) + CourseConvenerF.create( + user=convener, + certifying_organisation=self.certifying_organisation + ) + CourseConvenerF.create( + user=convener_inactive, + certifying_organisation=self.certifying_organisation, + is_active=False + ) + self.client.login(username='anita', password='password') + response = self.client.get(reverse('course-create', kwargs={ + 'project_slug': self.project.slug, + 'organisation_slug': self.certifying_organisation.slug, + })) + self.assertContains(response, 'Pretty Smart') + self.assertNotContains(response, 'Wonder Woman') + self.assertNotContains(response, 'inactive_convener') + + @override_settings(VALID_DOMAIN=['testserver', ]) + def test_inactive_convener_should_not_be_in_normal_user_detail_page(self): + convener = UserF.create(**{ + 'username': 'convener', + 'password': 'password', + 'first_name': 'Pretty', + 'last_name': 'Smart', + 'is_staff': True + }) + convener_inactive = UserF.create(**{ + 'username': 'inactive_convener', + 'password': 'password', + 'first_name': 'Wonder', + 'last_name': 'Woman', + 'is_staff': True + }) + CourseConvenerF.create( + user=convener, + certifying_organisation=self.certifying_organisation + ) + CourseConvenerF.create( + user=convener_inactive, + certifying_organisation=self.certifying_organisation, + is_active=False + ) + response = self.client.get( + reverse('certifyingorganisation-detail', kwargs={ + 'project_slug': self.project.slug, + 'slug': self.certifying_organisation.slug, + }) + ) + self.assertContains(response, 'Pretty Smart') + self.assertNotContains(response, 'Wonder Woman') + self.assertNotContains(response, '[inactive]') + + @override_settings(VALID_DOMAIN=['testserver', ]) + def test_inactive_convener_should_be_in_normal_user_detail_page(self): + convener = UserF.create(**{ + 'username': 'convener', + 'password': 'password', + 'first_name': 'Pretty', + 'last_name': 'Smart', + 'is_staff': True + }) + convener_inactive = UserF.create(**{ + 'username': 'inactive_convener', + 'password': 'password', + 'first_name': 'Wonder', + 'last_name': 'Woman', + 'is_staff': True + }) + CourseConvenerF.create( + user=convener, + certifying_organisation=self.certifying_organisation + ) + CourseConvenerF.create( + user=convener_inactive, + certifying_organisation=self.certifying_organisation, + is_active=False + ) + self.client.login(username='anita', password='password') + response = self.client.get( + reverse('certifyingorganisation-detail', kwargs={ + 'project_slug': self.project.slug, + 'slug': self.certifying_organisation.slug, + }) + ) + self.assertContains(response, 'Pretty Smart') + self.assertContains(response, 'Wonder Woman') + self.assertContains(response, '[inactive]') diff --git a/django_project/certification/urls.py b/django_project/certification/urls.py index 13d912eb4..f3427961c 100644 --- a/django_project/certification/urls.py +++ b/django_project/certification/urls.py @@ -31,6 +31,10 @@ CourseDeleteView, CourseDetailView, + # CourseType + ProjectCertificateTypeView, + updateProjectCertificateView, + # Training Center. TrainingCenterCreateView, TrainingCenterDetailView, @@ -235,6 +239,15 @@ view=OrganisationCertificateDetailView.as_view(), name='detail-certificate-organisation'), + # Certificate Type. + url(regex='^(?P[\w-]+)/certificate-types/$', + view=ProjectCertificateTypeView.as_view(), + name='certificate-type-list'), + url(regex='^(?P[\w-]+)/certificate-types/update/$', + view=updateProjectCertificateView, + name='certificate-type-update'), + + # Certificate. url(regex='^(?P[\w-]+)/certifyingorganisation/' '(?P[\w-]+)/course/' diff --git a/django_project/certification/views/__init__.py b/django_project/certification/views/__init__.py index 032b91f9c..9c8acf31c 100644 --- a/django_project/certification/views/__init__.py +++ b/django_project/certification/views/__init__.py @@ -8,4 +8,5 @@ from .course_attendee import * from .validate import * from .certificate import * +from .certificate_type import * from .certificate_organisation import * diff --git a/django_project/certification/views/certificate.py b/django_project/certification/views/certificate.py index 2c610d5b4..a73cdc1ec 100644 --- a/django_project/certification/views/certificate.py +++ b/django_project/certification/views/certificate.py @@ -32,6 +32,7 @@ import djstripe.settings from ..models import ( Certificate, + CertificateType, Course, Attendee, CertifyingOrganisation, @@ -234,7 +235,8 @@ def get_object(self, queryset=None): def generate_pdf( - pathname, project, course, attendee, certificate, current_site): + pathname, project, course, attendee, certificate, current_site, + wording='Has attended and completed the course:'): """Create the PDF object, using the response object as its file.""" # Register new font @@ -348,7 +350,7 @@ def generate_pdf( attendee.surname)) page.setFont('Noto-Regular', 16) page.drawCentredString( - center, 370, 'Has attended and completed the course:') + center, 370, wording) page.setFont('Noto-Bold', 20) page.drawCentredString( center, 335, course.course_type.name) @@ -456,7 +458,9 @@ def certificate_pdf_view(request, **kwargs): os.makedirs(makepath) generate_pdf( - pathname, project, course, attendee, certificate, current_site) + pathname, project, course, attendee, certificate, current_site, + course.certificate_type.wording + ) try: return FileResponse(open(pathname, 'rb'), content_type='application/pdf') @@ -691,7 +695,9 @@ def regenerate_certificate(request, **kwargs): current_site = request.META['HTTP_HOST'] generate_pdf( - pathname, project, course, attendee, certificate, current_site) + pathname, project, course, attendee, certificate, current_site, + course.certificate_type.wording + ) try: return FileResponse(open(pathname, 'rb'), content_type='application/pdf') @@ -843,7 +849,8 @@ def regenerate_all_certificate(request, **kwargs): '/home/web/media', 'pdf/{}/{}'.format(project_folder, filename)) generate_pdf( - pathname, project, course, key, value, current_site) + pathname, project, course, key, value, current_site, + course.certificate_type.wording) messages.success(request, 'All certificates are updated', 'regenerate') return HttpResponseRedirect(url) @@ -914,6 +921,7 @@ def preview_certificate(request, **kwargs): organisation_slug = kwargs.pop('organisation_slug') convener_id = request.POST.get('course_convener', None) + certificate_type_id = request.POST.get('certificate_type', None) if convener_id is not None: # Get all posted data. course_convener = CourseConvener.objects.get(id=convener_id) @@ -948,8 +956,21 @@ def preview_certificate(request, **kwargs): current_site = request.META['HTTP_HOST'] - generate_pdf( - response, project, course, attendee, certificate, current_site) + if certificate_type_id: + try: + certificate_type = CertificateType.objects.get( + id=certificate_type_id) + generate_pdf( + response, project, course, attendee, certificate, + current_site, certificate_type.wording + ) + except CertificateType.DoesNotExist: + generate_pdf( + response, project, course, attendee, certificate, + current_site) + else: + generate_pdf( + response, project, course, attendee, certificate, current_site) else: # When preview page is refreshed, the data is gone so user needs to diff --git a/django_project/certification/views/certificate_type.py b/django_project/certification/views/certificate_type.py new file mode 100644 index 000000000..ce5bf9955 --- /dev/null +++ b/django_project/certification/views/certificate_type.py @@ -0,0 +1,68 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.views.generic import ListView + +from base.models.project import Project +from certification.models.certificate_type import ( + CertificateType, ProjectCertificateType +) + + +class ProjectCertificateTypeView(LoginRequiredMixin, ListView): + context_object_name = 'project_certificate_types' + template_name = 'certificate_type/list.html' + model = ProjectCertificateType + + def get_context_data(self, **kwargs): + """Get the context data which is passed to a template.""" + + # Navbar data + self.project_slug = self.kwargs.get('project_slug', None) + context = super( + ProjectCertificateTypeView, self).get_context_data(*kwargs) + context['project_slug'] = self.project_slug + if self.project_slug: + context['the_project'] = \ + Project.objects.get(slug=self.project_slug) + context['project'] = context['the_project'] + + # certificate types + context['certificate_types'] = CertificateType.objects.all().order_by( + 'order' + ) + project = get_object_or_404(Project, slug=self.kwargs['project_slug']) + context['certificate_types_applied'] = ProjectCertificateType.\ + objects.filter(project=project).values_list( + 'certificate_type', flat=True) + return context + + def get_queryset(self): + """Return certificate_types for a project.""" + + project = get_object_or_404(Project, slug=self.kwargs['project_slug']) + return ProjectCertificateType.objects.filter(project=project) + + +def updateProjectCertificateView(request, project_slug): + project = get_object_or_404(Project, slug=project_slug) + manager = project.certification_managers.all() + if request.user.is_staff or request.user in manager: + certificate_types = request.POST.getlist('certificate_types', []) + for cer in certificate_types: + certificate_type = get_object_or_404(CertificateType, name=cer) + obj, created = ProjectCertificateType.objects.get_or_create( + certificate_type=certificate_type, project=project + ) + # remove certificate_type that is not in the list + old_certificate_type = ProjectCertificateType.objects.filter( + project=project).select_related('certificate_type').all() + for cer in old_certificate_type: + if cer.certificate_type.name not in certificate_types: + ProjectCertificateType.objects.get( + certificate_type=cer.certificate_type, project=project + ).delete() + return HttpResponseRedirect( + reverse('certificate-type-list', kwargs={'project_slug': project_slug}) + ) diff --git a/django_project/certification/views/certifying_organisation.py b/django_project/certification/views/certifying_organisation.py index 3a8899ebb..be274029f 100644 --- a/django_project/certification/views/certifying_organisation.py +++ b/django_project/certification/views/certifying_organisation.py @@ -213,7 +213,8 @@ def get_context_data(self, **kwargs): certifying_organisation=certifying_organisation) context['num_coursetype'] = context['coursetypes'].count() context['courseconveners'] = CourseConvener.objects.filter( - certifying_organisation=certifying_organisation) + certifying_organisation=certifying_organisation + ).prefetch_related('course_set') context['num_courseconvener'] = context['courseconveners'].count() context['courses'] = Course.objects.filter( certifying_organisation=certifying_organisation).order_by( diff --git a/django_project/changes/models/sponsor.py b/django_project/changes/models/sponsor.py index d7c9d87b2..88b715138 100644 --- a/django_project/changes/models/sponsor.py +++ b/django_project/changes/models/sponsor.py @@ -38,10 +38,11 @@ def validate_email_address(value): try: validate_email(value) return True - except ValidationError( + except ValidationError: + raise ValidationError( _('%(value)s is not a valid email address'), - params={'value': value},): - return False + params={'value': value}, + ) class ApprovedSponsorManager(models.Manager): diff --git a/django_project/changes/tests/test_models.py b/django_project/changes/tests/test_models.py index 6584f4bc6..24a338a53 100644 --- a/django_project/changes/tests/test_models.py +++ b/django_project/changes/tests/test_models.py @@ -1,7 +1,10 @@ # coding=utf-8 """Tests for models.""" from datetime import datetime + +from django.core.exceptions import ValidationError from django.test import TestCase + from changes.tests.model_factories import ( CategoryF, EntryF, @@ -460,3 +463,14 @@ def test_SponsorshipPeriod_delete(self): # check if deleted self.assertTrue(model.pk is None) + + +class TestValidateEmailAddress(TestCase): + """Test validate_email_address function.""" + + def test_validation_failed_must_raise_ValidationError(self): + from changes.models import validate_email_address + email = 'email@wrongdomain' + msg = f'{email} is not a valid email address' + with self.assertRaisesMessage(ValidationError, msg): + validate_email_address(email) diff --git a/django_project/core/base_templates/includes/base-auth-nav-left.html b/django_project/core/base_templates/includes/base-auth-nav-left.html index 5df1d4d2b..2bd328cbd 100644 --- a/django_project/core/base_templates/includes/base-auth-nav-left.html +++ b/django_project/core/base_templates/includes/base-auth-nav-left.html @@ -187,6 +187,9 @@
  • Rejected Organisations
  • Verify certificate for Certifying Organisation
  • Verify certificate for Attendee
  • + {% if user.is_staff or user in the_project.certification_managers.all %} +
  • Manage Certificate Type
  • + {% endif %} diff --git a/django_project/lesson/templates/worksheet/detail.html b/django_project/lesson/templates/worksheet/detail.html index 205c72007..0d961fb8c 100644 --- a/django_project/lesson/templates/worksheet/detail.html +++ b/django_project/lesson/templates/worksheet/detail.html @@ -580,7 +580,7 @@

    {% if worksheet.external_data %}

    - {% blocktrans with url=worksheet.external_data.url %}Download the sample data for the lesson.{% endblocktrans %} + {% blocktrans %}Download the sample data for the lesson.{% endblocktrans %}

    {% elif not worksheet.published %}

    diff --git a/django_project/lesson/tests/test_worksheet_views.py b/django_project/lesson/tests/test_worksheet_views.py index da7f13322..d28f7ebf9 100644 --- a/django_project/lesson/tests/test_worksheet_views.py +++ b/django_project/lesson/tests/test_worksheet_views.py @@ -73,6 +73,38 @@ def setUp(self): ) self.image_uploaded = SimpleUploadedFile( 'gif.gif', gif_byte, content_type='image/gif') + # Create zipfile in zipfile + # inside zipfile : test_1.txt and test_inside_zip.zip + # inside test_inside_zip.zip: test_inside_zip.txt + zip_byte = ( + b'PK\x03\x04\x14\x00\x08\x00\x08\x00H]\xb1R\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x02\x00\x00\x00\n\x00 \x00test_1.txtUT\r\x00\x07' + b'\x19\xe6\xa1`\x19\xe6\xa1`\x19\xe6\xa1`ux\x0b\x00\x01\x04\xe8' + b'\x03\x00\x00\x04\xe8\x03\x00\x003\xe4\x02\x00PK\x07\x08S\xfcQg' + b'\x04\x00\x00\x00\x02\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08' + b'\x00Y]\xb1R\x00\x00\x00\x00\x00\x00\x00\x00\xdc\x00\x00\x00\x13' + b'\x00 \x00test_inside_zip.zipUT\r\x00\x07;\xe6\xa1`;\xe6\xa1`;' + b'\xe6\xa1`ux\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00' + b'\x0b\xf0ff\x11a\xe0\x00\xc2\x90\xd8\x8dA\x0cP\xc0\x04\xc4\xc2' + b'\x0c\n\x0c%\xa9\xc5%\xf1\x99y\xc5\x99)\xa9\xf1U\x99\x05z%\x15%' + b'\xa1!\xbc\x0c\xec\x86\xcf\x16&\xc0pi\x057\x03#\xcb\x0bf\x06' + b'\x060a\xfc\x88\x89!\xc0\x9b\x9dc\xc2\xfa\x1a\x1f\x16\xa8Q\x01' + b'\xde\x8cL"\xcc\x08k\x90\xe5@\xd6\xc0\xc0\x96F\x10I\x86\xa5\x01' + b'\xde\xacl \xad\x8c@\x98\x08\xa4S\xc1\xc6\x01\x00PK\x07\x08h\x06' + b'\x07\x1as\x00\x00\x00\xdc\x00\x00\x00PK\x01\x02\x14\x03\x14\x00' + b'\x08\x00\x08\x00H]\xb1RS\xfcQg\x04\x00\x00\x00\x02\x00\x00\x00' + b'\n\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xb4\x81\x00\x00\x00' + b'\x00test_1.txtUT\r\x00\x07\x19\xe6\xa1`\x19\xe6\xa1`\x19\xe6' + b'\xa1`ux\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00' + b'PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00Y]\xb1Rh\x06\x07\x1as' + b'\x00\x00\x00\xdc\x00\x00\x00\x13\x00 \x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\xb4\x81\\\x00\x00\x00test_inside_zip.zipUT\r\x00' + b'\x07;\xe6\xa1`;\xe6\xa1`;\xe6\xa1`ux\x0b\x00\x01\x04\xe8\x03\x00' + b'\x00\x04\xe8\x03\x00\x00PK\x05\x06\x00\x00\x00\x00\x02\x00\x02' + b'\x00\xb9\x00\x00\x000\x01\x00\x00\x00\x00' + ) + self.zip_uploaded = SimpleUploadedFile( + 'ziptest.zip', zip_byte, content_type='application/zip') @override_settings(VALID_DOMAIN = ['testserver', ]) def test_WorksheetCreateView(self): @@ -248,6 +280,38 @@ def test_WorksheetPDFZipView(self): zip_file.close() + @override_settings(VALID_DOMAIN=['testserver']) + def test_WorksheetDownloadSampledataView(self): + self.test_section.name = 'Test section zip' + self.test_section.save() + self.test_worksheet.summary_image = self.image_uploaded + self.test_worksheet.more_about_image = self.image_uploaded + self.test_worksheet.module = 'Test module zip' + self.test_worksheet.save() + + response = self.client.get(reverse( + 'worksheet-sampledata', kwargs=self.kwargs_worksheet_full)) + self.assertEqual(response.status_code, 404) + + self.test_worksheet.external_data = self.zip_uploaded + self.test_worksheet.save() + response = self.client.get(reverse( + 'worksheet-sampledata', kwargs=self.kwargs_worksheet_full)) + self.assertEqual(response.status_code, 200) + self.assertEquals( + response.get('Content-Disposition'), + 'attachment; filename=Test section zip-Test module zip.zip' + ) + with io.BytesIO(response.content) as file: + zip_file = zipfile.ZipFile(file, 'r') + self.assertIsNone(zip_file.testzip()) + # zipfile must not contain any zipfile + self.assertEqual( + ['test_1.txt', 'test_inside_zip.txt'], + zip_file.namelist()) + zip_file.close() + + @override_settings(VALID_DOMAIN=['testserver']) def test_download_multiple_worksheets(self): self.test_project.name = 'Test project name multiple zip' diff --git a/django_project/lesson/urls.py b/django_project/lesson/urls.py index 17a03b8ce..357846388 100644 --- a/django_project/lesson/urls.py +++ b/django_project/lesson/urls.py @@ -40,6 +40,7 @@ WorksheetUpdateView, WorksheetDeleteView, WorksheetDetailView, + WorksheetDownloadSampleDataView, WorksheetPrintView, WorksheetOrderView, WorksheetOrderSubmitView, @@ -222,6 +223,12 @@ view=AnswerDeleteView.as_view(), name='answer-delete'), + # download sample data + url(regex='^(?P[\w-]+)/lesson/' + '(?P[\w-]+)/sample-data/(?P[\w-]+)/$', + view=WorksheetDownloadSampleDataView.as_view(), + name='worksheet-sampledata'), + # Json invalid Further reading Link url(regex='(?P[\w-]+)/lessons/_further_reading_links/$', view=get_FurtherReading_links, diff --git a/django_project/lesson/views/worksheet.py b/django_project/lesson/views/worksheet.py index 3eedb0be4..a006c31a8 100644 --- a/django_project/lesson/views/worksheet.py +++ b/django_project/lesson/views/worksheet.py @@ -8,7 +8,7 @@ from collections import OrderedDict from django.conf import settings from django.urls import reverse -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect, Http404 from django.views.generic import ( DetailView, CreateView, @@ -218,6 +218,56 @@ def render_to_response(self, context, **response_kwargs): return zip_response +class WorksheetDownloadSampleDataView(WorksheetDetailView): + def render_to_response(self, context, **response_kwargs): + context = self.get_context_data() + file_title = (f"{context['worksheet'].section.name}" + f"-{context['worksheet'].module}") + s = BytesIO() + zf = zipfile.ZipFile(s, "w") + + if context['worksheet'].external_data: + data_path = context['worksheet'].external_data.url + zip_data_path = settings.MEDIA_ROOT + data_path[6:] + + try: + external_file_zf = zipfile.ZipFile(zip_data_path) + for name in external_file_zf.namelist(): + if name.endswith('.zip'): + try: + filebytes = BytesIO(external_file_zf.read(name)) + subzip = zipfile.ZipFile(filebytes) + for name_subzip in subzip.namelist(): + if name_subzip.endswith('/'): + continue + if name_subzip.startswith('__MACOSX'): + continue + f_subzip = subzip.read(name_subzip) + zf.writestr(name_subzip, f_subzip) + except Exception: + pass + else: + if name.endswith('/'): + continue + if name.startswith('__MACOSX'): + continue + f = external_file_zf.read(name) + zf.writestr(name, f) + + except Exception: + zf.write(zip_data_path, file_title) + + zf.close() + + zip_response = HttpResponse( + s.getvalue(), content_type="application/x-zip-compressed") + zip_response['Content-Disposition'] = \ + 'attachment; filename={}.zip'.format(file_title) + return zip_response + else: + raise Http404("Sample data does not exist") + + class WorksheetCreateView(LoginRequiredMixin, WorksheetMixin, CreateView): """Create view for Section."""