diff --git a/django/.pa11yci b/django/.pa11yci index 4dc6cc0..5dd1407 100644 --- a/django/.pa11yci +++ b/django/.pa11yci @@ -6,6 +6,13 @@ } }, "urls": [ + "http://127.0.0.1:8000/", + { + "url": "http://127.0.0.1:8000/", + "actions": [ + "click element #cookie-message-popup-accept" + ] + }, "http://127.0.0.1:8000/cookies/" ] } diff --git a/django/core/local_settings.example.py b/django/core/local_settings.example.py index 2143154..7ce00e9 100644 --- a/django/core/local_settings.example.py +++ b/django/core/local_settings.example.py @@ -16,6 +16,9 @@ # Set to True if in development, or False is in production DEBUG = True/False +# Used by Django Debug Toolbar (comment out to disable DDT) +INTERNAL_IPS = ["127.0.0.1"] if DEBUG else [] + # Set to ['*'] if in development, or specific IP addresses and domains if in production ALLOWED_HOSTS = ['*']/['cristero-war.bham.ac.uk'] diff --git a/django/core/local_settings.test.py b/django/core/local_settings.test.py index 0f6f1f2..74e3390 100644 --- a/django/core/local_settings.test.py +++ b/django/core/local_settings.test.py @@ -13,6 +13,9 @@ DEBUG = True +# Used by Django Debug Toolbar (comment out to disable DDT) +# INTERNAL_IPS = ["127.0.0.1"] if DEBUG else [] + ALLOWED_HOSTS = ['*'] ADMIN_EMAIL = 'bear-rsg@contacts.bham.ac.uk' diff --git a/django/core/settings.py b/django/core/settings.py index 67880e7..662ec75 100644 --- a/django/core/settings.py +++ b/django/core/settings.py @@ -18,12 +18,18 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', + # 3rd Party + 'debug_toolbar', + 'ckeditor', + 'ckeditor_uploader', # Custom apps 'account', 'general', + 'pages' ] MIDDLEWARE = [ + 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -117,6 +123,79 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# CKEditor +# Image File uploads via CKEditor +CKEDITOR_UPLOAD_PATH = 'cke_uploads/' # will be based within MEDIA dir +CKEDITOR_ALLOW_NONIMAGE_FILES = False # only allow images to be uploaded +CKEDITOR_IMAGE_BACKEND = 'ckeditor_uploader.backends.PillowBackend' +CKEDITOR_THUMBNAIL_SIZE = (100, 100) +CKEDITOR_FORCE_JPEG_COMPRESSION = True +CKEDITOR_IMAGE_QUALITY = 90 +SILENCED_SYSTEM_CHECKS = ["ckeditor.W001"] +# Configuration +# For full list of configurations, see: https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html +# For full list of toolbar buttons, see: https://ckeditor.com/latest/samples/toolbarconfigurator/index.html#advanced +CKEDITOR_CONFIGS = { + 'default': { + 'toolbar': [ + { + 'name': 'views', + 'items': ['Maximize', 'Source'] + }, + { + 'name': 'styles', + 'items': ['Format', '-', 'TextColor', 'BGColor', 'Bold', 'Italic', 'Underline', 'Strike', '-', 'Subscript', 'Superscript', '-', 'RemoveFormat'] + }, + { + 'name': 'clipboard', + 'items': ['Cut', 'Copy', '-', 'Undo', 'Redo'] + }, + { + 'name': 'paragraph', + 'items': ['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent', '-', 'JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock', '-', 'BidiLtr', 'BidiRtl'] + }, + { + 'name': 'links', + 'items': ['Link', 'Unlink', 'Anchor'] + }, + { + 'name': 'insert', + 'items': ['Image', 'Table', 'HorizontalRule', 'SpecialChar'] + }, + { + 'name': 'editing', + 'items': ['Find', '-', 'Scayt'] + }, + ], + 'format_tags': 'h2;h3;h4;h5;p', + 'tabSpaces': 4, + 'height': '80vh', + 'width': '100%', + 'allowedContent': True, + 'entities_greek': False, + 'entities_latin': False, + 'scayt_autoStartup': True, + 'scayt_sLang': 'en_GB', + 'uiColor': '#FFFFFF', + 'language': 'en', + 'defaultLanguage': 'en', + 'editorplaceholder': 'Create the content of this page...', + 'removePlugins': ','.join([ + 'language', + 'elementspath', + ]), + 'versionCheck': False, + 'contentsCss': [ + '/static/css/reset.css', + '/static/css/custom.css', + '/static/css/custom_small.css', + '/static/css/custom_large.css', + '/static/css/custom_ckeditor.css', + ], + } +} + + # Import local_settings.py SECRET_KEY = None try: diff --git a/django/core/static/css/custom.css b/django/core/static/css/custom.css index 9cfdf20..21ea35a 100755 --- a/django/core/static/css/custom.css +++ b/django/core/static/css/custom.css @@ -2,19 +2,20 @@ /* ---------- General ---------- */ html, body { - font-family: 'Inter', 'Helvetica', sans-serif; - background: #101010; - color: #EEE; - margin: 0; - font-weight: 300; - - /* Variables */ + /* Color Variables */ --color-primary: #f36112; --color-primary-dark: #b03f02; --color-primary-light: #ff975f; + --color-black: #101010; --color-red: #c23616; --color-green: #27ae60; --color-purple: #8e44ad; + + font-family: 'Inter', 'Helvetica', sans-serif; + background: var(--color-black); + color: #EEE; + margin: 0; + font-weight: 300; } .display-small, @@ -402,7 +403,7 @@ main section { #welcome-banner { color: white; - background-color: var(--color-primary); + background-color: var(--color-black); background-size: 2em 2em; background-image: url('../images/welcome-banner-bg.jpg'); position: relative; @@ -414,7 +415,9 @@ main section { width: fit-content; } -#welcome-banner-content-title { +#welcome-banner-content-title, +#welcome-banner-content-tagline { + background: var(--color-black); line-height: 1em; } @@ -424,7 +427,7 @@ main section { #welcome-banner-content-links a { border-radius: 2em; - background-color: white; + background-color: var(--color-black); color: var(--color-primary); padding: 0.6em 1.3em; width: fit-content; @@ -465,6 +468,62 @@ main section { color: var(--color-primary); } +/* ---------- Pages ---------- */ + +#pages-editinadmin { + position: fixed; + bottom: 1rem; + right: 1rem; + padding: 0.8em; + background: var(--color-primary); + color: black; + font-weight: 600; + font-size: 1.1em; + border-radius: 0.5em; +} + +#pages-editinadmin:hover { + text-decoration: none; + background: var(--color-primary-dark); + color: black; +} + +#pages-editinadmin i { + margin-right: 0.4em; +} + +#pages-mediaviewer { + position: fixed; + bottom: -61vh; + left: 50%; + width: 0; + min-height: 5em; + max-height: 60vh; + background: var(--color-primary); + border-radius: 1em; + transition: all 0.4s ease; + overflow: auto; +} + +#pages-mediaviewer video { + max-width: 80%; + display: block; + margin: 0 auto 2em auto; +} + +#pages-mediaviewer audio { + display: block; + margin: 0 auto 2em auto; +} + +.pages-mediaviewer-close { + color: white; + font-size: 1.5em; + margin: 0.5em; + cursor: pointer; + width: fit-content; +} + /* ---------- Footer ---------- */ footer { diff --git a/django/core/templates/base.html b/django/core/templates/base.html index 5b03957..ac69cc5 100644 --- a/django/core/templates/base.html +++ b/django/core/templates/base.html @@ -1,4 +1,5 @@ {% load static settings_value i18n %} +{% get_current_language as LANGUAGE_CODE %} @@ -43,7 +44,7 @@
+© University of Birmingham {% now "Y" %}
diff --git a/django/core/urls.py b/django/core/urls.py index a078651..26f86ca 100644 --- a/django/core/urls.py +++ b/django/core/urls.py @@ -1,14 +1,18 @@ +from django.contrib import admin from django.urls import path, include from django.conf.urls.static import static from django.conf.urls.i18n import i18n_patterns from django.conf import settings urlpatterns = i18n_patterns( - - # General app's urls + # General app path('', include('general.urls')), - - # Don't show the default language in the URL - prefix_default_language=False - + # CKEditor file uploads + path('ckeditor/', include('ckeditor_uploader.urls')), + # Django admin + path('dashboard/', admin.site.urls), + # Debug Toolbar + path('__debug__/', include('debug_toolbar.urls')), + # Pages urls (must appear at bottom) + path('', include('pages.urls')), ) + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/django/pages/__init__.py b/django/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django/pages/admin.py b/django/pages/admin.py new file mode 100644 index 0000000..70d09d4 --- /dev/null +++ b/django/pages/admin.py @@ -0,0 +1,165 @@ +from django.contrib import admin +from django.db.models import ManyToManyField, ForeignKey +from django.utils.text import slugify +from django.utils import timezone +from . import models + + +# Sections: +# 1. Reusable Code +# 2. Actions +# 3. Admin Views + + +# +# 1. Reusable Code +# + +def get_manytomany_fields(model, exclude=[]): + """ + Returns a list of strings containing the field names of many to many fields of a model + To ignore certain fields, provide a list of such fields using the exclude parameter + """ + return list(f.name for f in model._meta.get_fields() if type(f) is ManyToManyField and f.name not in exclude) + + +def get_foreignkey_fields(model, exclude=[]): + """ + Returns a list of strings containing the field names of foreign key fields of a model + To ignore certain fields, provide a list of such field names (as strings) using the exclude parameter + """ + return list(f.name for f in model._meta.get_fields() if type(f) is ForeignKey and f.name not in exclude) + + +# +# 2. Actions +# + + +def publish(modeladmin, request, queryset): + """ + Sets all selected objects in queryset to published + """ + for object in queryset: + object.admin_published = True + # Set first published datetime, if applicable + try: + if object.meta_firstpublished_datetime is None: + object.meta_firstpublished_datetime = timezone.now() + except Exception: + pass + object.save() + + +publish.short_description = "Publish selected objects (will appear on main site)" + + +def unpublish(modeladmin, request, queryset): + """ + Sets all selected objects in queryset to not published + """ + for object in queryset: + object.admin_published = False + object.save() + + +unpublish.short_description = "Unpublish selected objects (will not appear on main site)" + + +class GenericAdminView(admin.ModelAdmin): + """ + This is a generic class that can be applied to most models to customise their inclusion in the Django admin. + + This class can either be inherited from to customise, e.g.: + class [ModelName]AdminView(GenericAdminView): + + Or if you don't need to customise it just register a model, e.g.: + admin.site.register([model name], GenericAdminView) + """ + + list_per_page = 50 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Set all many to many fields to display the filter_horizontal widget + self.filter_horizontal = get_manytomany_fields(self.model) + # Set all foreign key fields to display the autocomplete widget + self.autocomplete_fields = get_foreignkey_fields(self.model) + + class Media: + css = {'all': ('/static/css/custom_admin.css',)} + + +# +# 3. Admin Views +# + + +@admin.register(models.Page) +class PageAdminView(GenericAdminView): + """ + Customise the admin interface for Page model + """ + + list_display = ('name', + 'view_public_page', + 'published', + 'meta_created_by', + 'meta_created_datetime', + 'meta_lastupdated_by', + 'meta_lastupdated_datetime') + list_select_related = ('meta_created_by', + 'meta_lastupdated_by') + list_display_links = ('name',) + list_filter = ('published',) + search_fields = ('name', + 'meta_slug', + 'content_es', + 'content_en', + 'notes') + readonly_fields = ('meta_slug', + 'meta_created_by', + 'meta_created_datetime', + 'meta_lastupdated_by', + 'meta_lastupdated_datetime', + 'meta_firstpublished_datetime', 'list_of_other_pages_that_link_to_this_page') + actions = (publish, unpublish) + list_per_page = 200 + + def save_model(self, request, obj, form, change): + # Meta: created (if not yet set) or last updated by (if created already set) + if obj.meta_created_by is None: + obj.meta_created_by = request.user + # meta_created_datetime default value set in model so not needed here + else: + obj.meta_lastupdated_by = request.user + obj.meta_lastupdated_datetime = timezone.now() + # Meta: first published datetime + if obj.published and obj.meta_firstpublished_datetime is None: + obj.meta_firstpublished_datetime = timezone.now() + + # If slug has changed (will know by change in name) then update + # links in content field of other Pages that point to this Page + if 'name' in form.changed_data: + new_meta_slug = slugify(obj.name) + for page in obj.other_pages_that_link_to_this_page: + # Spanish content + page.content_es = page.content_es.replace(f'href="/{obj.meta_slug}"', f'href="/{new_meta_slug}"') + # English content + page.content_en = page.content_en.replace(f'href="/{obj.meta_slug}"', f'href="/{new_meta_slug}"') + page.save() + + obj.save() + + +@admin.register(models.FileUpload) +class FileUploadAdminView(GenericAdminView): + """ + Customise the admin interface for FileUpload model + """ + + list_display = ('file_name', 'view_file', 'meta_created_datetime') + list_display_links = ('file_name',) + search_fields = ('file',) + fields = ('file',) + list_per_page = 200 diff --git a/django/pages/apps.py b/django/pages/apps.py new file mode 100644 index 0000000..72ce535 --- /dev/null +++ b/django/pages/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + +app_name = "pages" + + +class ManagedConfig(AppConfig): + name = app_name diff --git a/django/pages/migrations/0001_initial.py b/django/pages/migrations/0001_initial.py new file mode 100644 index 0000000..8a3a63b --- /dev/null +++ b/django/pages/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.10 on 2024-02-21 14:53 + +import ckeditor_uploader.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import pages.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FileUpload', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(help_text="\n Ensure the name of the file you upload is unique and descriptive. Look at existing uploaded files to see the recommended naming convention.\n+ Here's some content +
+ +""".strip() + models.Page.objects.create( + name='Welcome', + content_es=content, + content_en=content, + published=True + ) + + # About + content = """ + ++ Here's some content +
+ +""".strip() + models.Page.objects.create( + name='About', + content_es=content, + content_en=content, + published=True + ) + + # Updates + content = """ + ++ Here's some content +
+ +""".strip() + models.Page.objects.create( + name='Updates', + content_es=content, + content_en=content, + published=True + ) + + # Get Involved + content = """ + ++ Here's some content +
+ +""".strip() + models.Page.objects.create( + name='Get Involved', + content_es=content, + content_en=content, + published=True + ) + + +def create_fileuploads(apps, schema_editor): + """ + Create new FileUpload objects with media files + """ + + # file_obj = models.FileUpload.objects.create(file=File(file_binary, name=file_name)) + + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0001_initial') + ] + + operations = [ + migrations.RunPython(create_pages), + migrations.RunPython(create_fileuploads), + ] diff --git a/django/pages/migrations/__init__.py b/django/pages/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django/pages/models.py b/django/pages/models.py new file mode 100644 index 0000000..6cbce75 --- /dev/null +++ b/django/pages/models.py @@ -0,0 +1,137 @@ +from django.db import models +from django.conf import settings +from django.urls import reverse +from account.models import User +from django.db.models import Q +from django.utils import timezone +from django.utils.text import slugify +from django.utils.safestring import mark_safe +from ckeditor_uploader.fields import RichTextUploadingField +import os +import re + + +CLEANR = re.compile('<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});') + + +def clean_html(raw_html): + """ + Removes HTML tags from provided raw_html, e.g. Example --> Example + """ + return re.sub(CLEANR, '', raw_html) if raw_html else None + + +class Page(models.Model): + """ + A single web page object, consisting of its HTML content and metadata + """ + + related_name = 'pages' + + name = models.CharField(max_length=255, unique=True) + published = models.BooleanField(default=False, verbose_name='published') + content_es = RichTextUploadingField(blank=True, null=True, verbose_name='Content (Spanish)') + content_en = RichTextUploadingField(blank=True, null=True, verbose_name='Content (English)') + notes = models.TextField(blank=True, null=True) + + # Metadata + meta_slug = models.SlugField(unique=True, max_length=500, verbose_name='slug') + meta_created_by = models.ForeignKey(User, related_name=f'{related_name}_created_by', on_delete=models.PROTECT, blank=True, null=True, verbose_name="created by") + meta_created_datetime = models.DateTimeField(default=timezone.now, verbose_name="created") + meta_lastupdated_by = models.ForeignKey(User, related_name=f'{related_name}_lastupdated_by', on_delete=models.PROTECT, blank=True, null=True, verbose_name="last updated by") + meta_lastupdated_datetime = models.DateTimeField(blank=True, null=True, verbose_name="last updated") + meta_firstpublished_datetime = models.DateTimeField(blank=True, null=True, verbose_name="first published") + + @property + def other_pages_that_link_to_this_page(self): + """ + Return a filtered queryset of Page objects that have an anchor tag to this Page + """ + return Page.objects.filter( + Q(content_es__icontains=f'href="/{self.meta_slug}"') + | + Q(content_en__icontains=f'href="/{self.meta_slug}"') + ).exclude(id=self.id) + + @property + def list_of_other_pages_that_link_to_this_page(self): + """ + Returns HTML that shows list of anchor tags for each Page that links to this Page + """ + links = [] + for page in self.other_pages_that_link_to_this_page: + links.append(f'{page.meta_slug}') + if len(links): + return mark_safe('