diff options
Diffstat (limited to 'alphabetlearning/resources')
41 files changed, 2242 insertions, 0 deletions
diff --git a/alphabetlearning/resources/__init__.py b/alphabetlearning/resources/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/alphabetlearning/resources/__init__.py diff --git a/alphabetlearning/resources/admin.py b/alphabetlearning/resources/admin.py new file mode 100644 index 0000000..b0e08dc --- /dev/null +++ b/alphabetlearning/resources/admin.py @@ -0,0 +1,39 @@ +# Register your models here. +from django.contrib import admin + +from alphabetlearning.resources.models import PDFPageSnapshot +from alphabetlearning.resources.models import PDFResource +from alphabetlearning.resources.models import Resource +from alphabetlearning.resources.models import ResourceCategory +from alphabetlearning.resources.models import ResourceSubcategory +from alphabetlearning.resources.models import ResourceType + + +@admin.register(ResourceType) +class ResourceTypeAdmin(admin.ModelAdmin): + pass + + +@admin.register(ResourceCategory) +class ResourceCategoryAdmin(admin.ModelAdmin): + pass + + +@admin.register(ResourceSubcategory) +class ResourceSubcategoryAdmin(admin.ModelAdmin): + ordering = ["name"] + + +@admin.register(Resource) +class ResourceAdmin(admin.ModelAdmin): + pass + + +@admin.register(PDFResource) +class PDFResourceAdmin(admin.ModelAdmin): + list_display = ["resource", "file_name", "file_size"] + + +@admin.register(PDFPageSnapshot) +class PDFPageSnapshotAdmin(admin.ModelAdmin): + list_display = ["file_name", "pdf_file"] diff --git a/alphabetlearning/resources/apps.py b/alphabetlearning/resources/apps.py new file mode 100644 index 0000000..44ebd69 --- /dev/null +++ b/alphabetlearning/resources/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ResourcesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "alphabetlearning.resources" diff --git a/alphabetlearning/resources/factories.py b/alphabetlearning/resources/factories.py new file mode 100644 index 0000000..4075ab1 --- /dev/null +++ b/alphabetlearning/resources/factories.py @@ -0,0 +1,83 @@ +import itertools + +import factory + +from .models import PDFPageSnapshot +from .models import PDFResource +from .models import Resource +from .models import ResourceCategory +from .models import ResourceType + + +class ResourceTypeModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = ResourceType + django_get_or_create = ("name",) + + name = factory.Sequence(lambda n: f"Default Resource Type {n}") + + +class ResourceCategoryModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = ResourceCategory + django_get_or_create = ("name",) + + name = factory.Sequence(lambda n: f"Default Resource Category {n}") + + +class PDFPageSnapshotModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = PDFPageSnapshot + + name = factory.Sequence(lambda n: f"pdf_page_snapshot_{n}") + file_name = factory.Sequence(lambda n: f"pdf_page_snapshot_{n}.jpg") + pdf_file = factory.SubFactory("alphabetlearning.resources.factories.PDFResourceModelFactory") + + +class ResourceModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = Resource + + name = factory.Sequence(lambda n: f"Default Resource {n}") + price = factory.Faker("pydecimal", left_digits=4, right_digits=2, positive=True) + thumbnail_filenames = factory.List( + [factory.Faker("file_name", extension="jpg") for _ in range(3)] + ) + resource_type = factory.SubFactory(ResourceTypeModelFactory) + main_resource_category = factory.SubFactory(ResourceCategoryModelFactory) + subcategories = factory.RelatedFactoryList(ResourceCategoryModelFactory, size=2) + description = factory.Faker("paragraph") + card_description = factory.Faker("text", max_nb_chars=1000) + age_range = factory.Iterator(["5-7", "7-9", "9-11"]) + curriculum = factory.Iterator(["English", "Scottish", "No curriculum"]) + feature_slot = factory.Iterator(itertools.chain([1, 2, 3], itertools.repeat(None))) + created_at = factory.Faker("date_time_this_year") + updated_at = factory.Faker("date_time_this_month") + + @factory.post_generation + def pdfs(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for pdf in extracted: + self.pdf_resources.add(pdf) + else: + PDFResourceModelFactory.create_batch(3, resource=self) + + +class ResourceSubcategoryModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = ResourceCategory + django_get_or_create = ("name",) + + name = factory.Sequence(lambda n: f"Default Resource Subcategory {n}") + + +class PDFResourceModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = PDFResource + + resource = factory.SubFactory(ResourceModelFactory) + file_name = factory.Sequence(lambda n: f"test_{n}.pdf") + file_size = factory.Faker("random_int", min=0, max=1000) diff --git a/alphabetlearning/resources/forms.py b/alphabetlearning/resources/forms.py new file mode 100644 index 0000000..35632bd --- /dev/null +++ b/alphabetlearning/resources/forms.py @@ -0,0 +1,274 @@ +import logging + +import magic +from crispy_forms.bootstrap import FormActions +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Button +from crispy_forms.layout import Field +from crispy_forms.layout import Layout +from crispy_forms.layout import Submit +from django import forms + +from alphabetlearning.resources.models import Resource +from alphabetlearning.resources.models import ResourceCategory +from alphabetlearning.resources.models import ResourceSubcategory +from alphabetlearning.resources.models import ResourceType + +from .models import AGE_RANGE_CHOICES +from .models import CURRICULUM_CHOICES + +logger = logging.getLogger(__name__) + +ALLOWED_THUMBNAILS = 5 +ALLOWED_PDFS = 20 + + +def _create_choices_tuple() -> list[tuple[str, str]]: + """Returns a list of tuples containing resource subcategory names.""" + vals = sorted(ResourceSubcategory.objects.values_list("name", flat=True), key=str) + out = tuple((x, x) for x in vals) + return out + + +class ResourceCreateForm(forms.Form): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.add_input(Submit("submit", "Submit")) + self.fields["subcategories"].choices = _create_choices_tuple() + pass + + error_css_class = "error" + required_css_class = "required" + + name = forms.CharField( + max_length=255, + help_text="Concisely describe what the resource is, aiming for" + " in 35-45 characters. " + "eg: 'Fractions KS2 Worksheet and Answers.'", + ) + description = forms.CharField( + max_length=5000, + widget=forms.Textarea, + help_text=" You can (and should) use <strong>Markdown</strong> here. " + "This is your opportunity to clearly explain what your resource " + "is all " + "about! It’s worth remembering that you are using the space to " + "communicate to two " + "different audiences. Firstly, think about what fellow teachers " + "would like " + "to know, such as exactly what the resource contains and how it " + "could be used in the classroom. Secondly, the words you include " + "on this page are also talking to internal and external search " + "engines." + " External search engines, like Google, show the first 155 characters " + "of the resource description, so make sure you take advantage " + "of these " + "characters by using lots of relevant keywords as part of an " + "enticing pitch.", + ) + card_description = forms.CharField( + max_length=1000, + widget=forms.Textarea, + help_text=( + "If you enter text here, it will be used in the 'card' description " + "box on the home page. Max 1000 characters." + ), + ) + resource_type = forms.ModelChoiceField(queryset=ResourceType.objects.all()) + age_range = forms.ChoiceField( + choices=AGE_RANGE_CHOICES, + help_text="Try to be accurate in your choice of age range so that your resource " + "shows up in the correct searches. (Although we don't have searches yet!)", + ) + curriculum = forms.ChoiceField( + choices=CURRICULUM_CHOICES, + required=False, + ) + main_resource_category = forms.ModelChoiceField( + queryset=ResourceCategory.objects.all(), + help_text="Categorise your resource by subject so it shows up in the correct " + "searches. It's a good idea to limit the number of subjects you select " + "to one or two to make your resource easier to find.", + ) + subcategories = forms.MultipleChoiceField( + required=False, + ) + + pdf_files = forms.FileField( + widget=forms.TextInput( + attrs={ + "multiple": True, + "type": "File", + "required": True, + }, + ), + required=False, + help_text="You can multi-select up to 20 .pdf files here.", + label="PDF files", + ) + thumbnail_files = forms.FileField( + widget=forms.TextInput( + attrs={ + "multiple": True, + "type": "File", + "required": True, + }, + ), + required=False, + label="Cover images", + help_text="Your cover image will be displayed in the search results and as " + "the first image on your resource page in the preview function. " + "It is important to add an eye catching cover image that gives " + "other teachers an idea about what your resource contains. " + "You can multi-select up to 5 .png or .jpg files here.", + ) + pdf_files.widget.attrs.update({"class": "file_upload", "accept": ".pdf"}) + thumbnail_files.widget.attrs.update({"class": "file_upload", "accept": ".png,.jpg"}) + feature_slot = forms.IntegerField( + min_value=1, + max_value=3, + required=False, + help_text=( + "Please enter either 1, 2 or 3 here. This will dictate where on the page " + "this resource will feature on the main list page." + ), + ) + + def clean_thumbnail_files(self): + thumbnail_files = self.files.getlist("thumbnail_files") + if not thumbnail_files: + raise forms.ValidationError("Please select at least one thumbnail file.") + acceptable = ["image/png", "image/jpeg"] + for f in thumbnail_files: + content_type = magic.from_buffer(f.file.read(), mime=True) + f.file.seek(0) + if content_type not in acceptable: + raise forms.ValidationError("Please select only PNG or JPG files.") + if len(thumbnail_files) > ALLOWED_THUMBNAILS: + raise forms.ValidationError("Please select up to 5 files.") + return thumbnail_files + + def clean_pdf_files(self): + pdf_files = self.files.getlist("pdf_files") + if not pdf_files: + raise forms.ValidationError("Please select at least one PDF file.") + acceptable = ["application/pdf"] + for f in pdf_files: + content_type = magic.from_buffer(f.file.read(), mime=True) + f.file.seek(0) + if content_type not in acceptable: + raise forms.ValidationError("Please select only PDF files.") + if len(pdf_files) > ALLOWED_PDFS: + raise forms.ValidationError("Please select up to 20 PDF files.") + return pdf_files + + +class ResourceUpdateMetadataForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.add_input(Submit("submit", "Submit")) + self.fields["subcategories"].queryset = ResourceSubcategory.objects.all().order_by("name") + + error_css_class = "error" + required_css_class = "required" + + class Meta: + model = Resource + fields = [ + "name", + "description", + "card_description", + "resource_type", + "age_range", + "curriculum", + "main_resource_category", + "subcategories", + "feature_slot", + ] + + +class ResourceUpdatePDFsForm(forms.Form): + def __init__(self, cancel_url: str, *args, **kwargs): + try: + self.resource = kwargs.pop("resource") + except KeyError: + pass + super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.layout = Layout( + Field("pdf_files"), + FormActions( + Submit("submit", "Submit", css_class="btn btn-primary"), + Button("cancel", "Cancel", css_class="btn btn-danger", onclick="location.href=''"), + ), + ) + + pdf_files = forms.FileField( + widget=forms.TextInput( + attrs={ + "multiple": True, + "type": "File", + "required": True, + }, + ), + required=False, + help_text="You can multi-select up to 20 .pdf files here.", + label="PDF files", + ) + + pdf_files.widget.attrs.update({"class": "file_upload", "accept": ".pdf"}) + + def clean_pdf_files(self): + pdf_files = self.files.getlist("pdf_files") + if not pdf_files: + raise forms.ValidationError("Please select at least one PDF file.") + acceptable = ["application/pdf"] + for f in pdf_files: + content_type = magic.from_buffer(f.file.read(), mime=True) + f.file.seek(0) + if content_type not in acceptable: + raise forms.ValidationError("Please select only PDF files.") + if len(pdf_files) > ALLOWED_PDFS: + raise forms.ValidationError("Please select up to 20 PDF files.") + return pdf_files + + +class ResourceUpdateThumbnailsForm(forms.Form): + def __init__(self, *args, **kwargs): + try: + self.resource = kwargs.pop("resource") + except KeyError: + pass + super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.add_input(Submit("submit", "Submit")) + + thumbnail_files = forms.FileField( + widget=forms.TextInput( + attrs={ + "multiple": True, + "type": "File", + "required": True, + }, + ), + required=False, + label="Thumbnail files", + help_text="You can upload 5 files.", + ) + thumbnail_files.widget.attrs.update({"class": "file_upload", "accept": ".png,.jpg"}) + + def clean_thumbnail_files(self): + thumbnail_files = self.files.getlist("thumbnail_files") + if not thumbnail_files: + raise forms.ValidationError("Please select at least one thumbnail file.") + acceptable = ["image/png", "image/jpeg"] + for f in thumbnail_files: + content_type = magic.from_buffer(f.file.read(), mime=True) + f.file.seek(0) + if content_type not in acceptable: + raise forms.ValidationError("Please select only PNG or JPG files.") + if len(thumbnail_files) > ALLOWED_THUMBNAILS: + raise forms.ValidationError("Please select up to 5 files.") + return thumbnail_files diff --git a/alphabetlearning/resources/migrations/0001_initial.py b/alphabetlearning/resources/migrations/0001_initial.py new file mode 100644 index 0000000..812c98f --- /dev/null +++ b/alphabetlearning/resources/migrations/0001_initial.py @@ -0,0 +1,90 @@ +# Generated by Django 5.0.4 on 2024-05-13 21:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='PDFResource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_name', models.CharField(max_length=255)), + ('file_size', models.IntegerField()), + ], + ), + migrations.CreateModel( + name='Resource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('thumbnail_filenames', models.JSONField(default=list, verbose_name='Thumbnail filenames')), + ('description', models.TextField(max_length=1000)), + ('age_range', models.CharField(choices=[('3-5', '3-5'), ('5-7', '5-7'), ('7-11', '7-11'), ('11-14', '11-14'), ('14-16', '14-16'), ('16+', '16+'), ('Age not applicable', 'Age not applicable')], default='5-7', max_length=20)), + ('curriculum', models.CharField(blank=True, choices=[('No curriculum', 'No curriculum'), ('English', 'English'), ('Scottish', 'Scottish')], default='English', max_length=20, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='ResourceCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name_plural': 'Resource Categories', + }, + ), + migrations.CreateModel( + name='ResourceType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='PDFPageSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('file_name', models.CharField(max_length=255)), + ('pdf_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pdf_page_snapshots', to='resources.pdfresource')), + ], + ), + migrations.AddField( + model_name='pdfresource', + name='resource', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pdf_resources', to='resources.resource'), + ), + migrations.AddField( + model_name='resource', + name='additional_resource_category', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='additional_resource_category', to='resources.resourcecategory'), + ), + migrations.AddField( + model_name='resource', + name='main_resource_category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='main_resource_category', to='resources.resourcecategory'), + ), + migrations.AddField( + model_name='resource', + name='resource_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resources.resourcetype'), + ), + migrations.AlterUniqueTogether( + name='pdfresource', + unique_together={('resource', 'file_name')}, + ), + ] diff --git a/alphabetlearning/resources/migrations/0002_alter_resource_additional_resource_category_and_more.py b/alphabetlearning/resources/migrations/0002_alter_resource_additional_resource_category_and_more.py new file mode 100644 index 0000000..3b70450 --- /dev/null +++ b/alphabetlearning/resources/migrations/0002_alter_resource_additional_resource_category_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.4 on 2024-05-15 20:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='resource', + name='additional_resource_category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='additional_resource_category', to='resources.resourcecategory'), + ), + migrations.AlterField( + model_name='resource', + name='curriculum', + field=models.CharField(blank=True, choices=[('No curriculum', 'No curriculum'), ('English', 'English'), ('Scottish', 'Scottish')], default='English', max_length=20), + ), + ] diff --git a/alphabetlearning/resources/migrations/0003_alter_resource_description.py b/alphabetlearning/resources/migrations/0003_alter_resource_description.py new file mode 100644 index 0000000..f0ed99c --- /dev/null +++ b/alphabetlearning/resources/migrations/0003_alter_resource_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-05-16 11:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0002_alter_resource_additional_resource_category_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='resource', + name='description', + field=models.TextField(help_text='\n <strong>Markdown acceptable here!</strong>This is your opportunity to clearly explain what\n your resource is all about! It’s worth remembering that you are using the space to\n communicate to two different audiences. Firstly, think about what fellow teachers\n would like to know, such as exactly what the resource contains and how it could be used in\n the classroom. Secondly, the words you include on this page are also talking to internal and\n external search engines. External search engines, like Google, show the first 155 characters\n of the resource description, so make sure you take advantage\n of these characters by using lots of relevant keywords as part of an enticing pitch.\n', max_length=5000), + ), + ] diff --git a/alphabetlearning/resources/migrations/0004_resource_feature_slot_1_resource_feature_slot_2_and_more.py b/alphabetlearning/resources/migrations/0004_resource_feature_slot_1_resource_feature_slot_2_and_more.py new file mode 100644 index 0000000..d595b62 --- /dev/null +++ b/alphabetlearning/resources/migrations/0004_resource_feature_slot_1_resource_feature_slot_2_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.4 on 2024-05-22 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0003_alter_resource_description'), + ] + + operations = [ + migrations.AddField( + model_name='resource', + name='feature_slot_1', + field=models.IntegerField(choices=[(1, 1), (2, 2), (3, 3)], null=True, unique=True), + ), + migrations.AddField( + model_name='resource', + name='feature_slot_2', + field=models.IntegerField(choices=[(1, 1), (2, 2), (3, 3)], null=True, unique=True), + ), + migrations.AddField( + model_name='resource', + name='feature_slot_3', + field=models.IntegerField(choices=[(1, 1), (2, 2), (3, 3)], null=True, unique=True), + ), + ] diff --git a/alphabetlearning/resources/migrations/0005_rename_feature_slot_1_resource_feature_slot_and_more.py b/alphabetlearning/resources/migrations/0005_rename_feature_slot_1_resource_feature_slot_and_more.py new file mode 100644 index 0000000..2937c50 --- /dev/null +++ b/alphabetlearning/resources/migrations/0005_rename_feature_slot_1_resource_feature_slot_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.4 on 2024-05-22 19:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0004_resource_feature_slot_1_resource_feature_slot_2_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='resource', + old_name='feature_slot_1', + new_name='feature_slot', + ), + migrations.RemoveField( + model_name='resource', + name='feature_slot_2', + ), + migrations.RemoveField( + model_name='resource', + name='feature_slot_3', + ), + ] diff --git a/alphabetlearning/resources/migrations/0006_resource_card_description_and_more.py b/alphabetlearning/resources/migrations/0006_resource_card_description_and_more.py new file mode 100644 index 0000000..d343e76 --- /dev/null +++ b/alphabetlearning/resources/migrations/0006_resource_card_description_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.4 on 2024-05-26 15:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0005_rename_feature_slot_1_resource_feature_slot_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='resource', + name='card_description', + field=models.TextField(blank=True, help_text="If you enter text here, it will be used in the 'card' description box on the home page. Max 1000 characters.", max_length=1000, null=True), + ), + migrations.AlterField( + model_name='resource', + name='feature_slot', + field=models.IntegerField(blank=True, choices=[(1, 1), (2, 2), (3, 3)], null=True, unique=True), + ), + ] diff --git a/alphabetlearning/resources/migrations/0007_alter_resource_feature_slot.py b/alphabetlearning/resources/migrations/0007_alter_resource_feature_slot.py new file mode 100644 index 0000000..c5c17c7 --- /dev/null +++ b/alphabetlearning/resources/migrations/0007_alter_resource_feature_slot.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-05-26 15:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0006_resource_card_description_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='resource', + name='feature_slot', + field=models.IntegerField(blank=True, choices=[(0, 0), (1, 1), (2, 2), (3, 3)], default=0, null=True, unique=True), + ), + ] diff --git a/alphabetlearning/resources/migrations/0008_alter_resource_card_description.py b/alphabetlearning/resources/migrations/0008_alter_resource_card_description.py new file mode 100644 index 0000000..18e5739 --- /dev/null +++ b/alphabetlearning/resources/migrations/0008_alter_resource_card_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-05-26 15:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0007_alter_resource_feature_slot'), + ] + + operations = [ + migrations.AlterField( + model_name='resource', + name='card_description', + field=models.TextField(blank=True, default='', help_text="If you enter text here, it will be used in the 'card' description box on the home page. Max 1000 characters.", max_length=1000), + ), + ] diff --git a/alphabetlearning/resources/migrations/0009_alter_resource_feature_slot.py b/alphabetlearning/resources/migrations/0009_alter_resource_feature_slot.py new file mode 100644 index 0000000..ca082d4 --- /dev/null +++ b/alphabetlearning/resources/migrations/0009_alter_resource_feature_slot.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-05-26 18:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0008_alter_resource_card_description'), + ] + + operations = [ + migrations.AlterField( + model_name='resource', + name='feature_slot', + field=models.IntegerField(blank=True, choices=[(1, 1), (2, 2), (3, 3)], default=0, null=True, unique=True), + ), + ] diff --git a/alphabetlearning/resources/migrations/0010_alter_resource_age_range.py b/alphabetlearning/resources/migrations/0010_alter_resource_age_range.py new file mode 100644 index 0000000..49c7a2c --- /dev/null +++ b/alphabetlearning/resources/migrations/0010_alter_resource_age_range.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-07-11 14:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0009_alter_resource_feature_slot'), + ] + + operations = [ + migrations.AlterField( + model_name='resource', + name='age_range', + field=models.CharField(choices=[('Preschool (3-4yrs)', 'Preschool (3-4yrs)'), ('Nursery (2-5yrs)', 'Nursery (2-5yrs)'), ('Reception (4-5yrs)', 'Reception (4-5yrs)'), ('Year 1 (5-6yrs)', 'Year 1 (5-6yrs)'), ('Year 2 (6-7yrs)', 'Year 2 (6-7yrs)'), ('Early Years (0-5yrs)', 'Early Years (0-5yrs)'), ('Keystage 1 (5-7yrs)', 'Keystage 1 (5-7yrs)'), ('Keystage 2 (7-11yrs)', 'Keystage 2 (7-11yrs)'), ('Age not applicable', 'Age not applicable')], default='5-7', max_length=20), + ), + ] diff --git a/alphabetlearning/resources/migrations/0011_alter_resource_curriculum.py b/alphabetlearning/resources/migrations/0011_alter_resource_curriculum.py new file mode 100644 index 0000000..5e1b193 --- /dev/null +++ b/alphabetlearning/resources/migrations/0011_alter_resource_curriculum.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-07-11 14:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0010_alter_resource_age_range'), + ] + + operations = [ + migrations.AlterField( + model_name='resource', + name='curriculum', + field=models.CharField(blank=True, choices=[('No curriculum', 'No curriculum'), ('English', 'English'), ('Scottish', 'Scottish')], default='English', max_length=20, null=True), + ), + ] diff --git a/alphabetlearning/resources/migrations/0012_resourcecategory_colour_css_class.py b/alphabetlearning/resources/migrations/0012_resourcecategory_colour_css_class.py new file mode 100644 index 0000000..b48ba61 --- /dev/null +++ b/alphabetlearning/resources/migrations/0012_resourcecategory_colour_css_class.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-07-11 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0011_alter_resource_curriculum'), + ] + + operations = [ + migrations.AddField( + model_name='resourcecategory', + name='colour_css_class', + field=models.CharField(blank=True, max_length=56, null=True), + ), + ] diff --git a/alphabetlearning/resources/migrations/0013_resourcecategory_badge_foreground_colour.py b/alphabetlearning/resources/migrations/0013_resourcecategory_badge_foreground_colour.py new file mode 100644 index 0000000..7644a5c --- /dev/null +++ b/alphabetlearning/resources/migrations/0013_resourcecategory_badge_foreground_colour.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-07-28 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resources", "0012_resourcecategory_colour_css_class"), + ] + + operations = [ + migrations.AddField( + model_name="resourcecategory", + name="badge_foreground_colour", + field=models.CharField(blank=True, max_length=56, null=True), + ), + ] diff --git a/alphabetlearning/resources/migrations/0014_remove_resource_additional_resource_category_and_more.py b/alphabetlearning/resources/migrations/0014_remove_resource_additional_resource_category_and_more.py new file mode 100644 index 0000000..7573bf2 --- /dev/null +++ b/alphabetlearning/resources/migrations/0014_remove_resource_additional_resource_category_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.4 on 2024-07-29 14:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resources", "0013_resourcecategory_badge_foreground_colour"), + ] + + operations = [ + migrations.RemoveField( + model_name="resource", + name="additional_resource_category", + ), + migrations.AddField( + model_name="resource", + name="additional_resource_category", + field=models.ManyToManyField( + blank=True, + null=True, + related_name="additional_resource_category", + to="resources.resourcecategory", + ), + ), + ] diff --git a/alphabetlearning/resources/migrations/0015_resourcesubcategory.py b/alphabetlearning/resources/migrations/0015_resourcesubcategory.py new file mode 100644 index 0000000..3eaada4 --- /dev/null +++ b/alphabetlearning/resources/migrations/0015_resourcesubcategory.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.4 on 2024-07-29 14:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resources", "0014_remove_resource_additional_resource_category_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ResourceSubcategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/alphabetlearning/resources/migrations/0016_alter_resourcesubcategory_options_and_more.py b/alphabetlearning/resources/migrations/0016_alter_resourcesubcategory_options_and_more.py new file mode 100644 index 0000000..77a3f27 --- /dev/null +++ b/alphabetlearning/resources/migrations/0016_alter_resourcesubcategory_options_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.4 on 2024-07-29 15:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resources", "0015_resourcesubcategory"), + ] + + operations = [ + migrations.AlterModelOptions( + name="resourcesubcategory", + options={"verbose_name_plural": "Resource Subcategories"}, + ), + migrations.AlterModelOptions( + name="resourcetype", + options={"verbose_name_plural": "Resource Types"}, + ), + migrations.AlterField( + model_name="resource", + name="additional_resource_category", + field=models.ManyToManyField( + blank=True, + null=True, + related_name="additional_resource_category", + to="resources.resourcesubcategory", + ), + ), + ] diff --git a/alphabetlearning/resources/migrations/0017_rename_additional_resource_category_resource_subcategories.py b/alphabetlearning/resources/migrations/0017_rename_additional_resource_category_resource_subcategories.py new file mode 100644 index 0000000..bc4d1e4 --- /dev/null +++ b/alphabetlearning/resources/migrations/0017_rename_additional_resource_category_resource_subcategories.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-08-01 14:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("resources", "0016_alter_resourcesubcategory_options_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="resource", + old_name="additional_resource_category", + new_name="subcategories", + ), + ] diff --git a/alphabetlearning/resources/migrations/0018_alter_resource_subcategories.py b/alphabetlearning/resources/migrations/0018_alter_resource_subcategories.py new file mode 100644 index 0000000..c981512 --- /dev/null +++ b/alphabetlearning/resources/migrations/0018_alter_resource_subcategories.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.4 on 2024-08-01 15:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "resources", + "0017_rename_additional_resource_category_resource_subcategories", + ), + ] + + operations = [ + migrations.AlterField( + model_name="resource", + name="subcategories", + field=models.ManyToManyField( + blank=True, + related_name="additional_resource_category", + to="resources.resourcesubcategory", + ), + ), + ] diff --git a/alphabetlearning/resources/migrations/0019_alter_pdfpagesnapshot_options_and_more.py b/alphabetlearning/resources/migrations/0019_alter_pdfpagesnapshot_options_and_more.py new file mode 100644 index 0000000..511d747 --- /dev/null +++ b/alphabetlearning/resources/migrations/0019_alter_pdfpagesnapshot_options_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.4 on 2024-09-03 19:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resources", "0018_alter_resource_subcategories"), + ] + + operations = [ + migrations.AlterModelOptions( + name="pdfpagesnapshot", + options={"verbose_name_plural": "PDF Page Snapshots"}, + ), + migrations.AlterModelOptions( + name="pdfresource", + options={"verbose_name_plural": "PDF Resources"}, + ), + migrations.AddField( + model_name="resource", + name="price", + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=6), + ), + ] diff --git a/alphabetlearning/resources/migrations/__init__.py b/alphabetlearning/resources/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/alphabetlearning/resources/migrations/__init__.py diff --git a/alphabetlearning/resources/models.py b/alphabetlearning/resources/models.py new file mode 100644 index 0000000..3dfad06 --- /dev/null +++ b/alphabetlearning/resources/models.py @@ -0,0 +1,201 @@ +from django.conf import settings +from django.db import models +from django.urls import reverse + +from .s3 import get_presigned_obj_url + +CURRICULUM_CHOICES = [ + ("No curriculum", "No curriculum"), + ("English", "English"), + ("Scottish", "Scottish"), +] + +AGE_RANGE_CHOICES = [ + ("Preschool (3-4yrs)", "Preschool (3-4yrs)"), + ("Nursery (2-5yrs)", "Nursery (2-5yrs)"), + ("Reception (4-5yrs)", "Reception (4-5yrs)"), + ("Year 1 (5-6yrs)", "Year 1 (5-6yrs)"), + ("Year 2 (6-7yrs)", "Year 2 (6-7yrs)"), + ("Early Years (0-5yrs)", "Early Years (0-5yrs)"), + ("Keystage 1 (5-7yrs)", "Keystage 1 (5-7yrs)"), + ("Keystage 2 (7-11yrs)", "Keystage 2 (7-11yrs)"), + ("Age not applicable", "Age not applicable"), +] + +DESC_HELP_TEXT = """ + <strong>Markdown acceptable here!</strong>This is your opportunity to clearly explain what + your resource is all about! It’s worth remembering that you are using the space to + communicate to two different audiences. Firstly, think about what fellow teachers + would like to know, such as exactly what the resource contains and how it could be used in + the classroom. Secondly, the words you include on this page are also talking to internal and + external search engines. External search engines, like Google, show the first 155 characters + of the resource description, so make sure you take advantage + of these characters by using lots of relevant keywords as part of an enticing pitch. +""" + + +# Create your models here. +class Resource(models.Model): + name = models.CharField(max_length=255, null=False) + price = models.DecimalField( + max_digits=6, + decimal_places=2, + default=0.00, + null=False, + blank=False, + ) + thumbnail_filenames = models.JSONField( + null=False, + verbose_name="Thumbnail filenames", + default=list, + ) + resource_type = models.ForeignKey("ResourceType", on_delete=models.CASCADE) + main_resource_category = models.ForeignKey( + "ResourceCategory", + on_delete=models.CASCADE, + null=False, + related_name="main_resource_category", + ) + subcategories = models.ManyToManyField( + "ResourceSubcategory", + blank=True, + related_name="additional_resource_category", + ) + description = models.TextField( + max_length=5000, + null=False, + blank=False, + help_text=DESC_HELP_TEXT, + ) + card_description = models.TextField( + max_length=1000, + blank=True, + default="", + help_text=( + "If you enter text here, it will be used in the 'card' " + "description box on the home page. Max 1000 characters." + ), + ) + age_range = models.CharField( + max_length=20, + null=False, + default="5-7", + blank=False, + choices=AGE_RANGE_CHOICES, + ) + curriculum = models.CharField( + max_length=20, + default="English", + blank=True, + null=True, + choices=[ + ("No curriculum", "No curriculum"), + ("English", "English"), + ("Scottish", "Scottish"), + ], + ) + feature_slot = models.IntegerField( + choices=((1, 1), (2, 2), (3, 3)), + unique=True, + null=True, + blank=True, + default=0, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + def get_pdf_file_names(self): + return [p.file_name for p in self.pdf_resources.all()] + + def get_pdf_snapshot_file_names(self): + rs = self.pdf_resources.all() + sh = [sh for sh in rs.values_list("pdf_page_snapshots__file_name", flat=True)] + return [s for s in sh if s] + + def get_absolute_url(self): + return reverse("resources:resource_detail", kwargs={"resource_id": self.pk}) + + def thumbnail_urls(self) -> list[str]: + return [ + get_presigned_obj_url(settings.AWS_STORAGE_BUCKET_NAME, f"thumbnails/{f}") + for f in self.thumbnail_filenames + ] + + +class ResourceType(models.Model): + name = models.CharField(max_length=255, null=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name_plural = "Resource Types" + + def __str__(self): + return self.name + + +class ResourceSubcategory(models.Model): + name = models.CharField(max_length=255, null=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name_plural = "Resource Subcategories" + + def __str__(self): + return self.name + + +class ResourceCategory(models.Model): + name = models.CharField(max_length=255, null=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + colour_css_class = models.CharField(max_length=56, blank=True, null=True) + badge_foreground_colour = models.CharField(max_length=56, blank=True, null=True) + + class Meta: + verbose_name_plural = "Resource Categories" + + def __str__(self): + return self.name + + +class PDFResource(models.Model): + resource = models.ForeignKey( + "Resource", + on_delete=models.CASCADE, + null=False, + related_name="pdf_resources", + ) + file_name = models.CharField(max_length=255, null=False) + file_size = models.IntegerField(null=False) + + class Meta: + verbose_name_plural = "PDF Resources" + unique_together = ("resource", "file_name") + + def __str__(self): + return self.resource.name + + def snapshot_file_names(self): + return [f.file_name for f in self.pdf_page_snapshots.all()] + + +class PDFPageSnapshot(models.Model): + name = models.CharField(max_length=255, null=False) + file_name = models.CharField(max_length=255, null=False) + pdf_file = models.ForeignKey( + "PDFResource", + on_delete=models.CASCADE, + null=False, + related_name="pdf_page_snapshots", + ) + + class Meta: + verbose_name_plural = "PDF Page Snapshots" + + def __str__(self): + return self.name diff --git a/alphabetlearning/resources/s3.py b/alphabetlearning/resources/s3.py new file mode 100644 index 0000000..af367e2 --- /dev/null +++ b/alphabetlearning/resources/s3.py @@ -0,0 +1,94 @@ +import logging +from pathlib import Path +from typing import Sequence + +import boto3 +from boto3 import Session +from botocore.exceptions import ClientError +from django.conf import settings + +from alphabetlearning.resources.utils import _get_pdf_collection_type + +logger = logging.getLogger(__name__) + + +def get_presigned_obj_url(bucket_name, obj_name, expiration=3600) -> str | None: + client = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_S3_REGION_NAME, + ) + try: + response = client.generate_presigned_url( + "get_object", + Params={"Bucket": bucket_name, "Key": obj_name}, + ExpiresIn=expiration, + ) + except ClientError as e: + logger.exception("Error generating presigned URL", extra={"error": e}) + return None + return response + + +def get_s3_client() -> Session.client: + return boto3.Session().client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_S3_REGION_NAME, + ) + + +def upload_files_to_s3(files: Sequence, dir_name: str) -> None: + """ + Generic upload function. Pass "thumbnails" or "pdfuploads" as dir_name to + dictate the type of file to upload. + :param files: + :param dir_name: + :return: + """ + s3_client = get_s3_client() + for file in files: + logging.info(f"Uploading {file.name} to S3") + s3_client.upload_fileobj(file, settings.AWS_STORAGE_BUCKET_NAME, f"{dir_name}/{file.name}") + + +def upload_snapshotted_pages_to_s3(snapshotted_pages) -> bool: + s3_client = get_s3_client() + collection_type = _get_pdf_collection_type(snapshotted_pages) + if collection_type in ["SINGLE_PDF_SINGLE_PAGE", "SINGLE_PDF_MULTI_PAGE"]: + for img in snapshotted_pages[0]: + logging.info(f"Uploading {img} to S3") + s3_client.upload_file( + img, settings.AWS_STORAGE_BUCKET_NAME, f"snapshotted_pages/{Path(img).name}" + ) + return True + if collection_type in ["MULTI_PDF_SINGLE_PAGE", "MULTI_PDF_MULTI_PAGE"]: + for pdf in snapshotted_pages: + for img in pdf: + logging.info(f"Uploading {img} to S3") + s3_client.upload_file( + img, settings.AWS_STORAGE_BUCKET_NAME, f"snapshotted_pages/{Path(img).name}" + ) + return True + return False + + +def upload_to_s3(pdf_files, thumbnail_files, snapshotted_pages) -> bool: + """ + + :param pdf_files: a list of PDF files + :param thumbnail_files: a list of thumbnail files + :param snapshotted_pages: a list of snapshotted pages + :return: True if the files was uploaded, False otherwise + """ + try: + upload_files_to_s3(pdf_files, dir_name="pdfuploads") + upload_files_to_s3(thumbnail_files, dir_name="thumbnails") + return upload_snapshotted_pages_to_s3(snapshotted_pages) + except ClientError: + logging.exception("Error uploading files to S3") + return False diff --git a/alphabetlearning/resources/services.py b/alphabetlearning/resources/services.py new file mode 100644 index 0000000..03c53af --- /dev/null +++ b/alphabetlearning/resources/services.py @@ -0,0 +1,55 @@ +import dataclasses +import os.path +import tempfile + +from pdf2image import convert_from_path +from PyPDF2 import PdfReader + + +@dataclasses.dataclass +class PDFMetadata: + file_name: str + file_size: int + n_pages: int + + +def get_pdf_metadata_from_path(file_path: str) -> PDFMetadata: + """ + This function returns the metadata of a PDF file + :param file_path: + :return: PDFMetadata + """ + if not os.path.isfile(file_path): + raise ValueError("file_path must be a file. {file_path} is not a file.") + reader = PdfReader(file_path) + n_pages = len(reader.pages) + file_size = os.path.getsize(file_path) + # pdf.close() + return PDFMetadata(file_name=file_path, file_size=file_size, n_pages=n_pages) + + +def export_pages_as_images(file_path: str) -> list[str]: + """ + This function exports the pages of a PDF file as JPEG images. + :param file_path: + :return: List of paths to the JPEG images + """ + output_dir = tempfile.mkdtemp() # Create a temporary directory + reader = PdfReader(file_path) + n_pages = len(reader.pages) + try: + with tempfile.TemporaryDirectory() as path: + images_from_path = convert_from_path(file_path, 56, size=300, output_folder=path) + # get the file_name of this PDF file at file_path + file_name = os.path.basename(file_path) + image_paths = [] + for i in range(n_pages): + image = images_from_path[i] + image_path = os.path.join(output_dir, f"{file_name}_{i:03d}.jpg") + image.save(image_path) + image_paths.append(image_path) + return image_paths + finally: + # Optionally handle cleanup later or elsewhere in your code + # Remove later with shutil.rmtree(output_dir) + pass diff --git a/alphabetlearning/resources/templatetags/__init__.py b/alphabetlearning/resources/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/alphabetlearning/resources/templatetags/__init__.py diff --git a/alphabetlearning/resources/templatetags/markdown_extras.py b/alphabetlearning/resources/templatetags/markdown_extras.py new file mode 100644 index 0000000..400948a --- /dev/null +++ b/alphabetlearning/resources/templatetags/markdown_extras.py @@ -0,0 +1,11 @@ +import markdown as md +from django import template +from django.template.defaultfilters import stringfilter + +register = template.Library() + + +@register.filter(is_safe=True) +@stringfilter +def markdown(value): + return md.markdown(value, extensions=["markdown.extensions.fenced_code"]) diff --git a/alphabetlearning/resources/tests/__init__.py b/alphabetlearning/resources/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/alphabetlearning/resources/tests/__init__.py diff --git a/alphabetlearning/resources/tests/test_file_processing.py b/alphabetlearning/resources/tests/test_file_processing.py new file mode 100644 index 0000000..e09a135 --- /dev/null +++ b/alphabetlearning/resources/tests/test_file_processing.py @@ -0,0 +1,134 @@ +from pathlib import Path + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.files.uploadedfile import TemporaryUploadedFile +from django.test import TestCase +from django.urls import reverse + +from .. import services +from ..utils import _get_pdf_collection_type + + +def test_detect_snapshotted_pdf_collection(): + single_pdf_single_page = [["toss"]] + single_pdf_multi_page = [["toss2", "toss8"]] + multi_pdf_single_page = [["toss"], ["toss2"]] + multi_pdf_multi_page = [["toss", "toss2"], ["toss", "toss2"]] + + assert _get_pdf_collection_type(single_pdf_single_page) == "SINGLE_PDF_SINGLE_PAGE" + assert _get_pdf_collection_type(single_pdf_multi_page) == "SINGLE_PDF_MULTI_PAGE" + assert _get_pdf_collection_type(multi_pdf_single_page) == "MULTI_PDF_SINGLE_PAGE" + assert _get_pdf_collection_type(multi_pdf_multi_page) == "MULTI_PDF_MULTI_PAGE" + + +class PDFFileUploadTestCase(TestCase): + def setUp(self): + self.url = reverse("resources:create_resource") + self.test_file_path = Path( + settings.BASE_DIR / "alphabetlearning" / "resources/tests/testdata/test_small_file.pdf" + ) + + # Create a test user + self.email = "testuser@example.com" + self.password = "testpassword" + self.user = get_user_model().objects.create_user( + email=self.email, + password=self.password, + ) + + def test_file_upload(self): + """ + Test that a file can be uploaded successfully using our create_resource view. + """ + self.client.login( + email=self.email, + password=self.password, + ) # Log in the test user + + with open(self.test_file_path, "rb") as file: + uploaded_file = SimpleUploadedFile( + "test_file.pdf", + file.read(), + content_type="application/pdf", + ) + + response = self.client.post(self.url, {"pdf_files": [uploaded_file]}) + + # Check if the response is OK + self.assertEqual(response.status_code, 200) + + def test_file_upload_with_upload_handlers(self): + """ + This test does not test my code but the behavior of the Django file upload handlers. + """ + self.client.login( + email=self.email, + password=self.password, + ) # Log in the test user + + with open(self.test_file_path, "rb") as file: + uploaded_file = SimpleUploadedFile( + "test_file.pdf", + file.read(), + content_type="application/pdf", + ) + + response = self.client.post(self.url, {"pdf_files": [uploaded_file]}) + + self.assertEqual(response.status_code, 200) + + # Check if the uploaded file was handled by MemoryFileUploadHandler or TemporaryFileUploadHandler + uploaded_files = response.wsgi_request.FILES.getlist("pdf_files") + self.assertEqual(len(uploaded_files), 1) + + # We should expect an instance of InMemoryUploadedFile here because test_small_file.pdf is less than 2.5 MB + self.assertIsInstance( + uploaded_files[0], + (SimpleUploadedFile, TemporaryUploadedFile, InMemoryUploadedFile), + ) + + def test_uploaded_pdf_file_metadata(self): + """ + This test does not test my application code, but rather tests the + behavior of the Django file upload handlers. + """ + self.client.login( + email=self.email, + password=self.password, + ) # Log in the test user + + with open(self.test_file_path, "rb") as file: + uploaded_file = SimpleUploadedFile( + "test_file.pdf", + file.read(), + content_type="application/pdf", + ) + + response = self.client.post(self.url, {"pdf_files": [uploaded_file]}) + + self.assertEqual( + response.status_code, + 200, + ) + + # Extract metadata from the uploaded file + pdf_metadata_from_path = services.get_pdf_metadata_from_path(self.test_file_path) + + # Get the number of pages in the PDF - is 4 + self.assertEqual(pdf_metadata_from_path.n_pages, 4) + + # Get the file size in bytes + self.assertGreater(pdf_metadata_from_path.file_size, 0) + + self.assertLess( + pdf_metadata_from_path.file_size, + 5 * 1024 * 1024, + ) # Assuming a maximum file size of 5 MB + + # self.assertTrue(services.export_pdf_pages_as_images_temp_dir(self.test_file_path)) + # capture the output of the export_pdf_pages_as_images_temp_dir function coroutine + files = list(services.export_pages_as_images(self.test_file_path)) + self.assertEqual(len(files), 4) diff --git a/alphabetlearning/resources/tests/test_forms.py b/alphabetlearning/resources/tests/test_forms.py new file mode 100644 index 0000000..aebc110 --- /dev/null +++ b/alphabetlearning/resources/tests/test_forms.py @@ -0,0 +1,74 @@ +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import IntegrityError +from django.test import TestCase +from django.utils.datastructures import MultiValueDict + +from alphabetlearning.resources.factories import ResourceModelFactory +from alphabetlearning.resources.forms import ResourceCreateForm +from alphabetlearning.resources.models import ResourceCategory +from alphabetlearning.resources.models import ResourceType + + +class ResourceCreateFormTest(TestCase): + def setUp(self): + self.resource_type = ResourceType.objects.create(name="Test Resource Type") + self.resource_category = ResourceCategory.objects.create(name="Test Resource Category") + self.form_data = { + "name": "Test Resource", + "description": "Test Description", + "card_description": "Test Card Description", + "resource_type": self.resource_type.id, + "age_range": "Reception (4-5yrs)", + "curriculum": "English", + "feature_slot": 1, + "main_resource_category": self.resource_category.id, + } + self.pdf_files = [ + # use the correct PDF file header - this should pass validation + SimpleUploadedFile( + f"file{i}.pdf", + b"\x25\x50\x44\x46\x2d", + content_type="application/pdf", + ) + for i in range(11) + ] + self.thumbnail_files = [ + # use the correct JPG file header - this should pass validation + SimpleUploadedFile( + f"thumbnail{i}.jpg", + b"\xff\xd8\xff\xdb", + content_type="image/jpeg", + ) + # 5 is the max number currently set in ALLOWED_THUMBNAILS + for i in range(5) + ] + # This needs to be a MultiValueDict for the test to mimic production code + # see clean_pdf_files - self.files.getlist() + self.form_files = MultiValueDict( + {"pdf_files": self.pdf_files, "thumbnail_files": self.thumbnail_files}, + ) + + def test_form_valid(self): + form = ResourceCreateForm(data=self.form_data, files=self.form_files) + assert form.is_valid() + + @pytest.mark.django_db() + def test_featured_slots_must_be_unique(self): + r1 = ResourceModelFactory(feature_slot=1) + with pytest.raises(IntegrityError): + ResourceModelFactory(feature_slot=1) + + @pytest.mark.django_db() + def test_featured_slots_allowable(self): + form_data = { + "name": "Test Resource", + "description": "Test Description", + "resource_type": self.resource_type.id, + "age_range": "5-7", + "curriculum": "English", + "feature_slot": 4, + "main_resource_category": self.resource_category.id, + } + form = ResourceCreateForm(data=form_data, files=self.form_files) + assert not form.is_valid() diff --git a/alphabetlearning/resources/tests/test_models.py b/alphabetlearning/resources/tests/test_models.py new file mode 100644 index 0000000..0fd589a --- /dev/null +++ b/alphabetlearning/resources/tests/test_models.py @@ -0,0 +1,120 @@ +import unittest +from unittest.mock import patch + +import pytest +from django.db import IntegrityError +from django.test import TestCase + +from alphabetlearning.resources.factories import PDFPageSnapshotModelFactory +from alphabetlearning.resources.factories import ResourceModelFactory +from alphabetlearning.resources.models import PDFPageSnapshot +from alphabetlearning.resources.models import PDFResource +from alphabetlearning.resources.models import Resource +from alphabetlearning.resources.models import ResourceCategory +from alphabetlearning.resources.models import ResourceType +from alphabetlearning.resources.views import ResourceInfo +from alphabetlearning.resources.views import _extract_metadata_from_resource + + +@pytest.mark.django_db() +def test_resource_model(resources): + r1 = resources[0] + pdf_on_rw = r1.pdf_resources.first() + pdf_page_snapshot = PDFPageSnapshotModelFactory(pdf_file=pdf_on_rw) + + assert Resource.objects.filter(id=r1.id).exists() + assert any("test_" in name for name in r1.get_pdf_file_names()) + assert len(r1.get_pdf_file_names()) == 3 + assert "pdf_page_snapshot_" in r1.get_pdf_snapshot_file_names()[0] + + +@pytest.mark.django_db() +def test_can_add_feature_slots_to_resource(): + r = ResourceModelFactory() + r.feature_slot = 1 + r.save() + assert r.feature_slot == 1 + + +@pytest.mark.django_db() +def test_resource_slot_int_must_be_unique(): + """ + Test that a resource feature slot must be unique. + """ + r1 = ResourceModelFactory() + r2 = ResourceModelFactory() + r1.feature_slot = 1 + r1.save() + r2.feature_slot = 1 + with pytest.raises(IntegrityError): + r2.save() + + +class ResourceModelTest(TestCase): + def test_string_representation(self): + resource = Resource(name="Test Resource") + assert str(resource) == "Test Resource" + + +@unittest.skip("Skipping this test for now as it is broken") +class TestExtractMetadata(TestCase): + @patch("resources.views.get_presigned_obj_url") + def test_extract_metadata_from_resource(self, mock_get_url): + # Create mock instances of ResourceType and ResourceCategory + mock_resource_type = ResourceType.objects.create(name="Test Type") + mock_main_category = ResourceCategory.objects.create(name="Test Main Category") + + mock_resource = Resource( + name="Test Resource", + thumbnail_filenames=["thumb.jpg", "thumb2.jpg"], + created_at="2022-01-01", + updated_at="2022-01-02", + resource_type=mock_resource_type, + main_resource_category=mock_main_category, + age_range="5-7", + ) + mock_get_url.return_value = "https://example.com/url" + result = _extract_metadata_from_resource(mock_resource) + self.assertIsInstance(result, ResourceInfo) + assert result.name == "Test Resource" + assert result.pdf_filenames == "test.pdf" + assert result.thumbnail_filenames in ["thumb.jpg", "thumb2.jpg"] + assert result.created == "2022-01-01" + assert result.updated == "2022-01-02" + + +@unittest.skip("These tests will not run because they rely upon the view to get file size, etc.") +class TestPDFResourceModel(TestCase): + def setUp(self): + self.resource_type = ResourceType.objects.create(name="Test Resource Type") + self.resource_category = ResourceCategory.objects.create(name="Test Resource Category") + self.resource = Resource.objects.create( + name="Test Resource", + resource_type=self.resource_type, + main_resource_category=self.resource_category, + age_range="5-7", + curriculum="English", + description="Test Description", + ) + self.pdf_resource = PDFResource.objects.create( + resource=self.resource, + file_name="resources/tests/testdata/test_small_file.pdf", + ) + self.pdf_page_snapshot = PDFPageSnapshot.objects.create( + name="Test Thumbnail Image", + file_name="test_resource_1.jpg", + pdf_file=self.pdf_resource, + ) + + def test_pdf_resource_string_representation(self): + self.assertEqual(str(self.resource), "Test Resource") + + def test_get_pdf_snapshot_filenames(self): + self.assertEqual(self.pdf_resource.snapshot_file_names(), ["test_resource_1.jpg"]) + + +@pytest.mark.django_db() +def test_get_urls_of_resource_snapshot_images(resource): + assert len(resource.thumbnail_filenames) == 3 + # crude but it does the job; concatenating a list of URLS into one long sting... + assert "https://ams3.digitaloceanspaces.com" in "".join(resource.thumbnail_urls()) diff --git a/alphabetlearning/resources/tests/test_views.py b/alphabetlearning/resources/tests/test_views.py new file mode 100644 index 0000000..b57e79d --- /dev/null +++ b/alphabetlearning/resources/tests/test_views.py @@ -0,0 +1,190 @@ +import unittest +from pathlib import Path + +import pytest +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import RequestFactory +from django.test import TestCase +from django.urls import reverse + +from alphabetlearning.resources import services +from alphabetlearning.resources.forms import ResourceCreateForm +from alphabetlearning.resources.models import ResourceCategory +from alphabetlearning.resources.models import ResourceType +from alphabetlearning.resources.views import create_resource + +pytestmark = pytest.mark.django_db + + +def test_create_featured_resource_view(client): + url = reverse("resources:create_featured") + response = client.get(url) + assert response.status_code == 302 + + +def test_create_resource_view(client): + url = reverse("resources:create_resource") + response = client.get(url) + assert response.status_code == 302 + + +@pytest.mark.django_db +def test_create_resource_has_form(client): + User = get_user_model() + user = User.objects.create_user(email="testuser@example.com", password="12345") + client.login(email="testuser@example.com", password="12345") + + url = reverse("resources:create_resource") + response = client.get(url) + assert response.status_code == 200 + assert "form" in response.context + assert isinstance(response.context["form"], ResourceCreateForm) + + +class PDFFileUploadTestCase(TestCase): + def setUp(self): + self.url = reverse("resources:create_resource") + self.two_page_pdf = Path( + settings.BASE_DIR / "alphabetlearning" / "resources/tests/testdata/two_page.pdf" + ) + self.seven_page_pdf = Path( + settings.BASE_DIR / "alphabetlearning" / "resources/tests/testdata/seven_page.pdf" + ) + + # Create a test user + self.email = "testuser@example.com" + self.password = "testpassword" + self.user = get_user_model().objects.create_user( + email=self.email, + password=self.password, + ) + + # Log in the test user + self.client.login(email=self.email, password=self.password) + + # Open the test files and create SimpleUploadedFile objects + with open(self.two_page_pdf, "rb") as file: + self.uploaded_two_page_pdf = SimpleUploadedFile( + "two_page.pdf", + file.read(), + content_type="application/pdf", + ) + with open(self.seven_page_pdf, "rb") as file: + self.uploaded_seven_page_pdf = SimpleUploadedFile( + "seven_page.pdf", + file.read(), + content_type="application/pdf", + ) + + def tearDown(self): + # Close the SimpleUploadedFile objects + self.uploaded_two_page_pdf.close() + self.uploaded_seven_page_pdf.close() + + def test_file_upload_is_pdf(self): + """ + Test that a file can be uploaded successfully using our create_resource view. + """ + response = self.client.post( + self.url, + {"pdf_files": [self.uploaded_two_page_pdf, self.uploaded_seven_page_pdf]}, + ) + + two_page_metadata = services.get_pdf_metadata_from_path(self.two_page_pdf) + seven_page_metadata = services.get_pdf_metadata_from_path(self.seven_page_pdf) + image_files_two_pager = list(services.export_pages_as_images(self.two_page_pdf)) + image_files_seven_pager = list( + services.export_pages_as_images(self.seven_page_pdf), + ) + + self.assertEqual(two_page_metadata.n_pages, 2) + self.assertEqual(seven_page_metadata.n_pages, 7) + self.assertGreater(two_page_metadata.file_size, 0) + self.assertGreater(seven_page_metadata.file_size, 0) + self.assertEqual(len(image_files_two_pager), 2) + self.assertEqual(len(image_files_seven_pager), 7) + self.assertEqual(response.status_code, 200) + + +@unittest.skip("Currently not able to mock S3 API at this point") +class TestCreateResourceUsingResourceFactory(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.user = User.objects.create_user( + username="testuser", + password="testpassword", + ) + self.two_page_pdf = "resources/tests/testdata/two_page.pdf" + self.seven_page_pdf = "resources/tests/testdata/seven_page.pdf" + + # Create resource type + self.resource_type = ResourceType.objects.create(name="Test Resource Type") + + # Create resource categories + self.main_resource_category = ResourceCategory.objects.create( + name="Test Main Category", + ) + self.additional_resource_category = ResourceCategory.objects.create( + name="Test Additional Category", + ) + + def test_post_pdf(self): + # Open the test files and create SimpleUploadedFile objects + with open(self.two_page_pdf, "rb") as file: + uploaded_two_page_pdf = SimpleUploadedFile( + "two_page.pdf", + file.read(), + content_type="application/pdf", + ) + with open(self.seven_page_pdf, "rb") as file: + uploaded_seven_page_pdf = SimpleUploadedFile( + "seven_page.pdf", + file.read(), + content_type="application/pdf", + ) + + # Create a thumbnail file (you can use a dummy file for testing) + thumbnail_file = SimpleUploadedFile( + "thumbnail.jpg", + b"thumbnail_content", + content_type="image/jpeg", + ) + + # Prepare the form data + form_data = { + "name": "Test Resource", + "description": "Test Description", + "resource_type": self.resource_type.id, + "age_range": "5-7", + "curriculum": "English", + "main_resource_category": self.main_resource_category.id, + "additional_resource_category": self.additional_resource_category.id, + "pdf_files": [uploaded_two_page_pdf, uploaded_seven_page_pdf], + "thumbnail_files": [thumbnail_file], + } + + # Prepare the form files + form_files = { + "pdf_files": [uploaded_two_page_pdf, uploaded_seven_page_pdf], + "thumbnail_files": [thumbnail_file], + } + + # Create the request object with form data and files + request = self.factory.post("/create/", data=form_data) + request.user = self.user + + # TODO mock the call to the Spaces S3 service inside the view + + # Call the create_resource view function + response = create_resource(request) + + # Assert the response status code + self.assertEqual( + response.status_code, + 302, + ) # Assuming a successful form submission redirects (status code 302) + + # Add more assertions as needed diff --git a/alphabetlearning/resources/tests/testdata/seven_page.pdf b/alphabetlearning/resources/tests/testdata/seven_page.pdf Binary files differnew file mode 100644 index 0000000..991a26d --- /dev/null +++ b/alphabetlearning/resources/tests/testdata/seven_page.pdf diff --git a/alphabetlearning/resources/tests/testdata/test_small_file.pdf b/alphabetlearning/resources/tests/testdata/test_small_file.pdf Binary files differnew file mode 100644 index 0000000..9a990c8 --- /dev/null +++ b/alphabetlearning/resources/tests/testdata/test_small_file.pdf diff --git a/alphabetlearning/resources/tests/testdata/two_page.pdf b/alphabetlearning/resources/tests/testdata/two_page.pdf Binary files differnew file mode 100644 index 0000000..9c74944 --- /dev/null +++ b/alphabetlearning/resources/tests/testdata/two_page.pdf diff --git a/alphabetlearning/resources/urls.py b/alphabetlearning/resources/urls.py new file mode 100644 index 0000000..87cd3b0 --- /dev/null +++ b/alphabetlearning/resources/urls.py @@ -0,0 +1,28 @@ +from django.urls import path + +from . import views + +app_name = "resources" +urlpatterns = [ + path("", views.index, name="resource_list"), + path("create/", views.create_resource, name="create_resource"), + path("featured/", views.create_featured, name="create_featured"), + path("resource/<int:resource_id>", views.resource_detail, name="resource_detail"), + path( + "resource/update-metadata/<int:pk>", + views.update_resource_metadata, + name="resource_update_metadata", + ), + path( + "resource/update-thumbnails/<int:pk>", + views.update_resource_thumbnails, + name="resource_update_thumbnails", + ), + path("resource/add-pdfs/<int:pk>", views.add_resource_pdfs, name="resource_update_pdfs"), +] + +htmx_patterns = [ + path("hx-download-btn/", views.hx_download_button, name="hx_download_button"), +] + +urlpatterns += htmx_patterns diff --git a/alphabetlearning/resources/utils.py b/alphabetlearning/resources/utils.py new file mode 100644 index 0000000..d17aa69 --- /dev/null +++ b/alphabetlearning/resources/utils.py @@ -0,0 +1,10 @@ +def _get_pdf_collection_type(coll) -> str: + if len(coll) == 1 and len(coll[0]) == 1: + return "SINGLE_PDF_SINGLE_PAGE" + if len(coll) == 1 and len(coll[0]) > 1: + return "SINGLE_PDF_MULTI_PAGE" + if len(coll) > 1: + for c in coll: + if len(c) > 1: + return "MULTI_PDF_MULTI_PAGE" + return "MULTI_PDF_SINGLE_PAGE" diff --git a/alphabetlearning/resources/views.py b/alphabetlearning/resources/views.py new file mode 100644 index 0000000..3bf3d22 --- /dev/null +++ b/alphabetlearning/resources/views.py @@ -0,0 +1,431 @@ +import logging +import os +import tempfile +from collections.abc import Generator +from dataclasses import dataclass +from typing import Optional + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.paginator import Paginator +from django.db import IntegrityError +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.shortcuts import redirect +from django.shortcuts import render + +from . import services +from .forms import ResourceCreateForm +from .forms import ResourceUpdateMetadataForm +from .forms import ResourceUpdatePDFsForm +from .forms import ResourceUpdateThumbnailsForm +from .models import PDFPageSnapshot +from .models import PDFResource +from .models import Resource +from .models import ResourceCategory +from .models import ResourceSubcategory +from .s3 import get_presigned_obj_url +from .s3 import upload_files_to_s3 +from .s3 import upload_snapshotted_pages_to_s3 +from .s3 import upload_to_s3 + +logger = logging.getLogger(__name__) + + +# I want to create a dataclass here to hold the resource information to pass to the view +@dataclass +class ResourceInfo: + id: int + name: str + description: str + card_description: str + main_resource_category_name: str + main_resource_category_colour_css_class: str + main_resource_badge_foreground_colour: str + subcategories: str | None + age_range: str | None + pdf_filenames: list[str] + pdf_urls: list[Optional[str]] + snapshot_urls: dict[str, list[str]] + thumbnail_filenames: list[str] + thumbnail_urls: list[Optional[str]] + feature_slot: int + created: str + updated: str + + +@login_required +def create_featured(request): + return render(request, "resources/create_featured_resource.html") + + +def _extract_metadata_from_resource(resource_obj) -> ResourceInfo | None: + """ + This function extracts the resource information from the model object and returns it as a + ResourceInfo object + :param resource_obj: + :return: + """ + pdf_resource_filenames = [ + x.file_name for x in PDFResource.objects.filter(resource=resource_obj).all() + ] + pdf_resources = PDFResource.objects.filter(resource=resource_obj).all() + snapshot_dict = {} + for p in pdf_resources: + snapshot_dict[p.file_name] = [ + x.file_name for x in PDFPageSnapshot.objects.filter(pdf_file=p).all() + ] + snapshot_url_dict = {} + # Iterate through the snapshot dict and generate the URLs + for k, v in snapshot_dict.items(): + snapshot_url_dict[k] = [ + get_presigned_obj_url( + settings.AWS_STORAGE_BUCKET_NAME, + f"snapshotted_pages/{f}", + ) + for f in v + ] + pdf_urls = [ + get_presigned_obj_url(settings.AWS_STORAGE_BUCKET_NAME, f"pdfuploads/{f}") + for f in pdf_resource_filenames + ] + thumbnail_urls = [ + get_presigned_obj_url(settings.AWS_STORAGE_BUCKET_NAME, f"thumbnails/{f}") + for f in resource_obj.thumbnail_filenames + ] + try: + arc_name = resource_obj.subcategories.name if resource_obj.subcategories else None + return ResourceInfo( + id=resource_obj.id, + name=resource_obj.name, + description=resource_obj.description, + card_description=resource_obj.card_description, + main_resource_category_name=resource_obj.main_resource_category.name, + main_resource_category_colour_css_class=resource_obj.main_resource_category.colour_css_class, + main_resource_badge_foreground_colour=resource_obj.main_resource_category.badge_foreground_colour, + subcategories=arc_name, + age_range=resource_obj.age_range, + pdf_filenames=pdf_resource_filenames, + pdf_urls=pdf_urls, + snapshot_urls=snapshot_url_dict, + thumbnail_filenames=resource_obj.thumbnail_filenames, + thumbnail_urls=thumbnail_urls, + feature_slot=resource_obj.feature_slot, + created=resource_obj.created_at, + updated=resource_obj.updated_at, + ) + except Exception: + logging.exception("Error extracting resource information: ") + return None + + +@login_required +def index(request): + resource_objs = Resource.objects.all() + categories = ResourceCategory.objects.all() + category = request.GET.get("category", "all") + + resource_list = [_extract_metadata_from_resource(r) for r in resource_objs] + + # Create a separate queryset for Featured resources + featured_resources = [r for r in resource_list if r.feature_slot] + featured_resources = sorted(featured_resources, key=lambda resource: resource.feature_slot) + + if category != "all": + resource_list = [r for r in resource_list if r.main_resource_category_name == category] + + paginator = Paginator(resource_list, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "page_obj": page_obj, + "categories": categories, + "featured_resources": featured_resources, + "selected_category": category, + } + return render(request, "resources/resource_list.html", context) + + +def create_metadata(pdf_files) -> Generator[tuple[services.PDFMetadata, str], None, None]: + """ + Generates PDF metadata and snapshot images for a list of PDF files. + + This function takes a list of PDF file objects, creates temporary files for each one, + and then uses the `services.get_pdf_metadata_from_path` and `services.export_pages_as_images` functions to extract metadata and snapshot images for each PDF. + + The function yields a tuple containing the PDF metadata and a list of snapshot image paths for each PDF file. + + Args: + pdf_files (list[django.core.files.uploadedfile.InMemoryUploadedFile]): A list of PDF file objects. + + Yields: + tuple[servies.PDFMetadata, list[str]]: A tuple containing the PDF metadata and a list of snapshot image paths for each PDF file. + """ + with tempfile.TemporaryDirectory() as temp_dir: + for pdf_file in pdf_files: + file_path = os.path.join(temp_dir, pdf_file.name) + + with open(file_path, "wb") as temp_file: + for chunk in pdf_file.chunks(): + temp_file.write(chunk) + + metadata = services.get_pdf_metadata_from_path(file_path) + snapshot_images = services.export_pages_as_images(file_path) + + yield metadata, snapshot_images + + +@login_required +def create_resource(request): + if request.method == "POST": + form = ResourceCreateForm(request.POST, request.FILES) + + if form.is_valid(): + pdf_files = form.cleaned_data["pdf_files"] + thumbnail_files = form.cleaned_data["thumbnail_files"] + name = form.cleaned_data["name"] + description = form.cleaned_data["description"] + card_description = form.cleaned_data["card_description"] + resource_type = form.cleaned_data["resource_type"] + age_range = form.cleaned_data["age_range"] + curriculum = form.cleaned_data["curriculum"] + main_resource_category = form.cleaned_data["main_resource_category"] + subcategories = form.cleaned_data["subcategories"] + feature_slot = form.cleaned_data["feature_slot"] + + # We use get here because we know these categories exist + subcategories_objs = [ResourceSubcategory.objects.get(name=x) for x in subcategories] + + try: + with transaction.atomic(): + resource = Resource( + name=name, + description=description, + card_description=card_description, + resource_type=resource_type, + age_range=age_range, + curriculum=curriculum, + main_resource_category=main_resource_category, + feature_slot=feature_slot, + ) + resource.save() + resource.subcategories.set(subcategories_objs) + + metadata_generator = create_metadata(pdf_files) + snapshotted_pages = [] + + for metadata, snapshot_images in metadata_generator: + pdf_resource = PDFResource.objects.create( + resource=resource, + file_name=os.path.basename(metadata.file_name), + file_size=metadata.file_size, + ) + + for snapshot_image in snapshot_images: + PDFPageSnapshot.objects.create( + name="test", + file_name=os.path.basename(snapshot_image), + pdf_file=pdf_resource, + ) + + snapshotted_pages.append(snapshot_images) + + resource.thumbnail_filenames = [f.name for f in thumbnail_files] + resource.save() + + # Reset the file pointers for pdf_files + for pdf_file in pdf_files: + pdf_file.seek(0) + + if not upload_to_s3(pdf_files, thumbnail_files, snapshotted_pages): + raise Exception("Error uploading files to S3") + + return redirect("resources:resource_detail", resource_id=resource.id) + except IntegrityError: + slot = form.cleaned_data["feature_slot"] + messages.add_message( + request, + messages.ERROR, + f"Feature slot {slot} is already " + "in use. Quit this form and remove from existing " + "resource.", + ) + except Exception: + logger.exception("Error creating resource") + form.add_error(None, "An error occurred while creating the resource.") + else: + # extract form errors + errors = {} + for field in form: + if field.errors: + errors[field.name] = field.errors + + # add non-field errors + if form.non_field_errors(): + errors["non_field_errors"] = form.non_field_errors() + + # render form with errors + return render( + request, + "resources/resource_create.html", + {"form": form, "errors": errors}, + ) + else: + form = ResourceCreateForm() + + return render(request, "resources/resource_create.html", {"form": form}) + + +@login_required +def resource_detail(request, resource_id): + """ + This function returns the resource detail page. + """ + resource_obj = get_object_or_404(Resource, pk=resource_id) + resource_metadata = _extract_metadata_from_resource(resource_obj) + resource = { + "id": resource_obj.id, + "name": resource_obj.name, + "description": resource_obj.description, + "card_description": resource_obj.card_description, + "resource_type": resource_obj.resource_type.name, + "main_resource_category": resource_obj.main_resource_category.name, + "main_resource_category_colour_css_class": resource_obj.main_resource_category.colour_css_class, + "additional_resource_category": ( + resource_obj.subcategories.name if resource_obj.subcategories else None + ), + "age_range": resource_obj.age_range, + "curriculum": resource_obj.curriculum, + "pdf_filenames": resource_metadata.pdf_filenames, + "pdf_urls": resource_metadata.pdf_urls, + "thumbnails": list( + zip( + resource_metadata.thumbnail_urls, + resource_metadata.thumbnail_filenames, + strict=False, + ), + ), + "thumbnail_filenames": resource_metadata.thumbnail_filenames, + "thumbnail_urls": resource_metadata.thumbnail_urls, + "snapshot_urls": resource_metadata.snapshot_urls, + "created": resource_metadata.created, + "updated": resource_metadata.updated, + } + return render(request, "resources/resource_detail.html", {"resource": resource}) + + +@login_required() +def update_resource_thumbnails(request, pk): + resource = get_object_or_404(Resource, pk=pk) + if request.method == "POST": + form = ResourceUpdateThumbnailsForm(request.POST, request.FILES) + if form.is_valid(): + thumbnail_files = form.cleaned_data["thumbnail_files"] + resource.thumbnail_filenames = [f.name for f in thumbnail_files] + upload_files_to_s3(thumbnail_files, "thumbnails") + resource.save() + return redirect("resources:resource_detail", resource_id=resource.id) + + else: + form = ResourceUpdateThumbnailsForm(resource=pk) + + return render(request, "resources/update_thumbnails.html", {"form": form, "resource": resource}) + + +@login_required +def hx_download_button(request): + """ + This is an HTMX view that is called when the user clicks the download button. + :param: + :return: + """ + pdf = request.GET.get("rn") + res = Resource.objects.get(pdf_filename=pdf) + return render( + request, + "resources/hx_download_button.html", + {"pdf_url": _extract_metadata_from_resource(res).pdf_url}, + ) + + +@login_required +def update_resource_metadata(request, pk): # Change resource_id to pk + resource = get_object_or_404(Resource, pk=pk) + + if request.method == "POST": + form = ResourceUpdateMetadataForm(request.POST, instance=resource) + if form.is_valid(): + form.save() + return redirect( + "resources:resource_detail", + resource_id=resource.pk, + ) # Use pk instead of resource_id + else: + form = ResourceUpdateMetadataForm(instance=resource) + + return render( + request, + "resources/resource_metadata_update.html", + {"form": form, "resource": resource}, + ) + + +@login_required() +def add_resource_pdfs(request, pk): + """ + Adds PDF files to a resource in the system. + + This view handles the process of adding PDF files to an existing resource. + It allows the user to upload one or more PDF files, which are then processed and associated with the resource. + The view creates PDFResource and PDFPageSnapshot objects to represent the uploaded PDFs and their page snapshots, and uploads the files to S3 storage. + + Args: + request (django.http.request.HttpRequest): The HTTP request object. + pk (int): The primary key of the resource to which the PDFs will be added. + + Returns: + django.http.response.HttpResponse: A redirect to the resource detail page upon successful PDF upload. + """ + resource = get_object_or_404(Resource, pk=pk) + if request.method == "POST": + form = ResourceUpdatePDFsForm(resource.get_absolute_url(), request.POST, request.FILES) + if form.is_valid(): + pdf_files = form.cleaned_data["pdf_files"] + + metadata_generator = create_metadata(pdf_files) + + snapshotted_pages = [] + + for metadata, snapshot_images in metadata_generator: + # TODO replace or add? This needs to be decided here + pdf_resource = PDFResource.objects.create( + resource=resource, + file_name=os.path.basename(metadata.file_name), + file_size=metadata.file_size, + ) + + for snapshot_image in snapshot_images: + PDFPageSnapshot.objects.create( + name="test", + file_name=os.path.basename(snapshot_image), + pdf_file=pdf_resource, + ) + + snapshotted_pages.append(snapshot_images) + + # Reset the file pointers for pdf_files + for pdf_file in pdf_files: + pdf_file.seek(0) + + upload_files_to_s3(pdf_files, "pdfuploads") + if not upload_snapshotted_pages_to_s3(snapshotted_pages): + raise Exception("Error uploading snapshotted pages to S3") + + return redirect("resources:resource_detail", resource_id=resource.id) + + else: + form = ResourceUpdatePDFsForm(resource.get_absolute_url(), resource=pk) + + return render(request, "resources/update_pdfs.html", {"form": form, "resource": resource}) |