diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b30aa47..fc533fec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,8 +6,10 @@ repos: hooks: # Run the linter. - id: ruff + args: [ --diff ] # Run the formatter. - id: ruff-format + args: [ --diff ] # Automatically sort python imports - repo: https://github.com/PyCQA/isort diff --git a/Makefile b/Makefile index 2d2f9bd8..f222f525 100644 --- a/Makefile +++ b/Makefile @@ -109,7 +109,7 @@ check: @docker compose run --rm web python manage.py check make-messages: - @docker compose run --rm web python manage.py makemessages --all + @docker compose run --rm web python manage.py makemessages --all -e html,txt,py,js compile-messages: @docker compose run --rm web python manage.py compilemessages diff --git a/app/app/settings.py b/app/app/settings.py index d071fb8a..adfddeff 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -50,6 +50,7 @@ "simple_history", "accounts", "django_filters", + "django_htmx", ] if DEBUG: INSTALLED_APPS += [ @@ -75,9 +76,9 @@ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "simple_history.middleware.HistoryRequestMiddleware", + "django_htmx.middleware.HtmxMiddleware", ] -# Add debug toolbar middleware if DEBUG and DEBUG_TOOLBAR: MIDDLEWARE.insert(2, "debug_toolbar.middleware.DebugToolbarMiddleware") @@ -96,6 +97,7 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "general.context_processors.template_vars", ], }, }, @@ -121,6 +123,11 @@ } if DEBUG: + DEBUG_TOOLBAR_CONFIG = { + "ROOT_TAG_EXTRA_ATTRS": "hx-preserve", + "UPDATE_ON_FETCH": True, + } + # Some things rely on the setting INTERNAL_IPS: # - debug_toolbar.middleware.show_toolbar # - django.template.context_processors.debug diff --git a/app/app/views.py b/app/app/views.py index 24d521d8..c1af91a7 100644 --- a/app/app/views.py +++ b/app/app/views.py @@ -1,5 +1,5 @@ -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import Count, Prefetch +from django.core.paginator import Paginator +from django.db.models import Count, F, Func, OuterRef, Prefetch, Subquery from django.http import HttpResponse from django.shortcuts import get_object_or_404, render from django.utils.http import urlencode @@ -41,13 +41,14 @@ def get_date_range(project): start_date = project.start_date end_date = project.end_date - if (start_date is not None) and (end_date is not None) and (start_date.year != end_date.year): - date = f"{start_date.year} – {end_date.year}" - elif (start_date is not None) and (end_date is not None) and (start_date.year == end_date.year): - date = start_date.year - elif start_date is not None: + if start_date and end_date: + if start_date.year != end_date.year: + date = f"{start_date.year} – {end_date.year}" + else: + date = start_date.year + elif start_date: date = _("Since {year}").format(year=start_date.year) - elif end_date is not None: + elif end_date: date = _("Until {year}").format(year=end_date.year) else: date = None @@ -83,21 +84,17 @@ def get_subjects(subjects): def projects(request): template = "app/projects.html" - subject_id = request.GET.get("subject") - language_id = request.GET.get("language") - institution_id = request.GET.get("institution") - projects = ( Project.objects.select_related("institution") .prefetch_related("subjects", "languages") .order_by("name") ) - if subject_id: + if subject_id := request.GET.get("subject"): projects = projects.filter(subjects__id=subject_id) - if language_id: + if language_id := request.GET.get("language"): projects = projects.filter(languages__id=language_id) - if institution_id: + if institution_id := request.GET.get("institution"): projects = projects.filter(institution__id=institution_id) subjects = Subject.objects.order_by("name") @@ -108,24 +105,14 @@ def projects(request): for project in projects: project_subjects = project.subjects.all() project_languages = project.languages.all() - - languages_data = get_languages(project_languages) - subjects_data = get_subjects(project_subjects) - - logo = get_logo(project) - - institution_name = project.institution.name - - date = get_date_range(project) - project_data.append( { "project": project, - "logo": logo, - "subjects": subjects_data, - "languages": languages_data, - "date": date, - "institution_name": institution_name, + "logo": get_logo(project), + "subjects": get_subjects(project_subjects), + "languages": get_languages(project_languages), + "date": get_date_range(project), + "institution_name": project.institution.name, "description": project.description, } ) @@ -145,16 +132,17 @@ def project_detail(request, project_id): template = "app/project_detail.html" project = get_object_or_404( - Project.objects.select_related("institution").prefetch_related("subjects", "languages"), + Project.objects.select_related("institution").prefetch_related( + Prefetch("subjects", queryset=Subject.objects.order_by("name")), + Prefetch("languages", queryset=Language.objects.order_by("name")), + ), id=project_id, ) - logo = get_logo(project) - context = { "current_page": "project_detail", "project": project, - "logo": logo, + "logo": get_logo(project), "subjects": project.subjects.all(), "languages": project.languages.all(), } @@ -168,14 +156,12 @@ def institution_detail(request, institution_id): projects = Project.objects.filter(institution=institution).order_by("name") documents = DocumentFile.objects.filter(institution=institution).order_by("title") - logo = institution.logo - context = { "current_page": "institution_detail", "institution": institution, "projects": projects, "documents": documents, - "logo": logo, + "logo": institution.logo, } return render(request, template_name=template, context=context) @@ -184,29 +170,24 @@ def institution_detail(request, institution_id): def documents(request): template = "app/documents.html" - url_params = {} - subject_id = request.GET.get("subject") - language_id = request.GET.get("language") - institution_id = request.GET.get("institution") - documents = ( DocumentFile.objects.select_related("institution") .prefetch_related("subjects", "languages") .order_by("title") ) - if subject_id: + url_params = {} + if subject_id := request.GET.get("subject"): documents = documents.filter(subjects__id=subject_id) url_params["subject"] = subject_id - if language_id: + if language_id := request.GET.get("language_id"): documents = documents.filter(languages__id=language_id) url_params["language"] = language_id - if institution_id: + if institution_id := request.GET.get("institution"): documents = documents.filter(institution__id=institution_id) url_params["institution"] = institution_id paginator = Paginator(documents, 10) - page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) @@ -218,15 +199,11 @@ def documents(request): for document in page_obj: document_subjects = document.subjects.all() document_languages = document.languages.all() - - languages_data = get_languages(document_languages) - subjects_data = get_subjects(document_subjects) - document_data.append( { "document": document, - "subjects": subjects_data, - "languages": languages_data, + "subjects": get_subjects(document_subjects), + "languages": get_languages(document_languages), "institution_name": document.institution.name, "description": document.description, "url": document.url, @@ -283,7 +260,6 @@ def languages(request): ) language_data = [] - for language in languages: documents = language.documentfile_set.all() projects = language.project_set.all() @@ -351,7 +327,17 @@ def institutions(request): template = "app/institutions.html" context = {} - institutions = Institution.objects.annotate(project_count=Count("project")).order_by("name") + # https://docs.djangoproject.com/en/stable/topics/db/aggregation/#combining-multiple-aggregations + subquery = ( + DocumentFile.objects.filter(institution=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + ) + + institutions = Institution.objects.annotate( + project_count=Count("project"), + document_count=Subquery(subquery.values("count")), + ).order_by("name") institutions_array = [] @@ -375,6 +361,7 @@ def institutions(request): rating = (completed_fields_count / len(institution_dict)) * 100 institution_dict["rating"] = round(rating) institution_dict["project_count"] = institution.project_count + institution_dict["document_count"] = institution.document_count institution_dict["id"] = institution.id institutions_array.append(institution_dict) @@ -384,28 +371,25 @@ def institutions(request): def search(request): - page_number = request.GET.get("page", "1") - if not page_number.isdigit(): - page_number = "1" - f = DocumentFileFilter(request.GET, queryset=DocumentFile.objects.all()) template = "app/search.html" paginator = Paginator(f.qs, 5) # 5 documents per page + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) - try: - page_obj = paginator.page(page_number) - except PageNotAnInteger: - page_obj = paginator.page(1) - except EmptyPage: - page_obj = paginator.page(paginator.num_pages) + for result in page_obj: + # Remove headline that is identical to description + if headline := result.get("search_headline", ""): + if result["extra"].startswith(headline): + del result["search_headline"] context = { - "search_results": paginator.page(page_obj.number), + "current_page": "search", "filter": f, - "documents": page_obj, - "search_params": pagination_url(request), + "page_obj": page_obj, + "url_params": pagination_url(request), } return render(request, template_name=template, context=context) @@ -420,4 +404,4 @@ def pagination_url(request): "languages": request.GET.getlist("languages", []), } - return "?" + urlencode(url_params, doseq=True) + return urlencode(url_params, doseq=True) diff --git a/app/general/context_processors.py b/app/general/context_processors.py new file mode 100644 index 00000000..9f6ef1ce --- /dev/null +++ b/app/general/context_processors.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def template_vars(request): + return { + "debug_toolbar": settings.DEBUG and settings.DEBUG_TOOLBAR, + "BASE_TEMPLATE": "base_htmx.html" if request.htmx else "base.html", + } diff --git a/app/general/filters.py b/app/general/filters.py index 72fe7ef9..26e44d9c 100644 --- a/app/general/filters.py +++ b/app/general/filters.py @@ -8,22 +8,30 @@ ) from django.db.models import F, Value from django.db.models.functions import Greatest, Left +from django.db.models.query import EmptyQuerySet +from django.utils.translation import gettext_lazy as _ from django_filters import ModelMultipleChoiceFilter, MultipleChoiceFilter from general.models import DocumentFile, Institution, Language, Project, Subject class DocumentFileFilter(django_filters.FilterSet): - search = django_filters.CharFilter(method="ignore", label="Search") + search = django_filters.CharFilter(method="ignore", label=_("Search")) institution = ModelMultipleChoiceFilter( - queryset=Institution.objects.all(), widget=forms.CheckboxSelectMultiple + label=_("Institution"), + queryset=Institution.objects.all().order_by("name"), + widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check-input"}), ) subjects = ModelMultipleChoiceFilter( - queryset=Subject.objects.all(), widget=forms.CheckboxSelectMultiple + label=_("Subjects"), + queryset=Subject.objects.all().order_by("name"), + widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check-input"}), ) languages = ModelMultipleChoiceFilter( - queryset=Language.objects.all(), widget=forms.CheckboxSelectMultiple + label=_("Languages"), + queryset=Language.objects.all().order_by("name"), + widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check-input"}), ) class Meta: @@ -42,30 +50,59 @@ def filter_queryset(self, queryset): query = SearchQuery(search) # A fixed list of identical fields are required to join queries of - # different classes with `.union()`: - fields = ( + # different classes with `.union()`. + # XXX: Django doesn't order these fields correctly when there is a + # mixture of model fields and annotations that are not the same for + # each model. This causes a mismatch of column types, which breaks the + # `.union()` (or worse, just combines the values of compatible + # columns). See this bug: https://code.djangoproject.com/ticket/35011 + # We therefore use `F()` even in cases where it shouldn't be needed. + # An annotation can't share a name with a model field, so anything that + # occurs on a model needs to be aliased with a different name. + fields = [ "id", "heading", - "description", + "extra", "rank", - "search_headline", "view", "logo_url", "associated_url", - ) + ] # In the queries below, any differences between models must be fixed # through e.g. `Value` or `F` annotations. + institution_search_vector = SearchVector("name", weight="A") + SearchVector( + "abbreviation", weight="A" + ) + institution_query = Institution.objects.annotate( + heading=F("name"), + extra=F("abbreviation"), + view=Value("institution_detail"), + logo_url=F("logo"), + associated_url=F("url"), + boost=Value(0.02), + rank=F("boost"), + ) + + for _filter in ("institution", "languages", "subjects"): + if not isinstance(self.form.cleaned_data[_filter], EmptyQuerySet): + # We exclude institutions if any filter is active. Not sure + # what it would mean to filter the institutions by subject, + # unless our schema changes. + institution_query = institution_query.none() + break + project_search_vector = SearchVector("name", weight="A") + SearchVector( "description", weight="B" ) project_query = Project.objects.annotate( heading=F("name"), + extra=F("description"), view=Value("project_detail"), logo_url=F("logo"), associated_url=F("url"), - search_headline=SearchHeadline("description", query, max_words=15, min_words=10), - rank=SearchRank(project_search_vector, query, normalization=16), + boost=Value(0.05), + rank=F("boost"), ) queryset = super().filter_queryset(queryset) @@ -76,25 +113,52 @@ def filter_queryset(self, queryset): search_headline = SearchHeadline( Left("document_data", 20_000), query, max_words=15, min_words=10 ) - search_rank = SearchRank(F("search_vector"), query, normalization=16) queryset = queryset.annotate( heading=F("title"), + extra=F("description"), view=Value("document_detail"), logo_url=Value(""), associated_url=F("url"), - rank=search_rank, - search_headline=search_headline, + boost=Value(0.01), + rank=F("boost"), ) if search: - # An empty search on Project filters out everything. - queryset = queryset.filter(search_vector=query) - project_query = project_query.annotate(search=project_search_vector).filter( - search=query + # We only annotate with search related things if needed. The ranking + # with `SearchRank` and the headling only makes sense if we + # performed a full-text search. Additionally, an empty search on + # Project filters out everything, so this is needed for + # correctness. + fields.extend(["search_headline"]) + + search_rank = SearchRank(F("search_vector"), query, normalization=16) + queryset = queryset.filter(search_vector=query).annotate( + rank=search_rank + F("boost"), + search_headline=search_headline, + ) + project_query = ( + project_query.annotate(search=project_search_vector) + .filter(search=query) + .annotate( + search_headline=SearchHeadline( + "description", query, max_words=15, min_words=10 + ), + rank=SearchRank(project_search_vector, query, normalization=16) + F("boost"), + ) + ) + institution_query = ( + institution_query.annotate(search=institution_search_vector) + .filter(search=query) + .annotate( + search_headline=Value(""), + rank=SearchRank(institution_search_vector, query, normalization=16) + + F("boost"), + ) ) queryset = queryset.values(*fields) project_query = project_query.values(*fields) - return queryset.union(project_query, all=True).order_by("-rank") + institution_query = institution_query.values(*fields) + return queryset.union(project_query, institution_query, all=True).order_by("-rank") def ignore(self, queryset, name, value): # All fields are handled in `.filter_queryset()` diff --git a/app/general/management/commands/dev_pdf_mass_upload.py b/app/general/management/commands/dev_pdf_mass_upload.py index cb81cf5d..32543e5c 100644 --- a/app/general/management/commands/dev_pdf_mass_upload.py +++ b/app/general/management/commands/dev_pdf_mass_upload.py @@ -20,7 +20,6 @@ def __init__(self, *args, **kwargs): self.dir_error = "/pdf_upload_completed/error/" def handle(self, *args, **options): - os.system("clear") print("Mass file uploader for testing purposes.") self.create_directory(self.dir_completed) @@ -72,11 +71,9 @@ def move_file(self, file_path, file, directory): ) def print_pdf_file(self, file): - print("\n") print("\033[92m" + file + "\033[0m") def print_error(self): - print("\n") print("\033[91m" + "Only PDF files are allowed" + "\033[0m") def save_data(self, data): diff --git a/app/general/management/commands/dev_update_vector_search.py b/app/general/management/commands/dev_update_vector_search.py index 8fb0d096..56298789 100644 --- a/app/general/management/commands/dev_update_vector_search.py +++ b/app/general/management/commands/dev_update_vector_search.py @@ -1,5 +1,3 @@ -import os - from django.core.management.base import BaseCommand from general.models import DocumentFile @@ -9,12 +7,6 @@ class Command(BaseCommand): help = "Updating the Vector Search index on document_file." def handle(self, *args, **options): - os.system("clear") - print("Querying the Vector Search index and Updating.") - - all_document_files = DocumentFile.objects.all() - - for document_file in all_document_files: + for document_file in DocumentFile.objects.all(): document_file.save() # This line updates the vector search for the document file print(f"Updated {document_file.title}.") - print() diff --git a/app/general/templatetags/bs_icons.py b/app/general/templatetags/bs_icons.py new file mode 100644 index 00000000..fe459d98 --- /dev/null +++ b/app/general/templatetags/bs_icons.py @@ -0,0 +1,47 @@ +import re + +from django import template +from django.utils.safestring import mark_safe + +"""A simple template tag to add a single Bootstrap icon. + +If our needs grow, we should probably switch to something like +django-bootstrap-icons: +https://pypi.org/project/django-bootstrap-icons/ +""" + + +register = template.Library() +icon_name_re = re.compile(r"[a-z0-9\-]+") + + +def _bs_icon(name): + assert icon_name_re.fullmatch(name) + return mark_safe(f' ') + # The trailing space is intentional: Since this is an inline element + # usually followed by text, the absence/presence of a space is significant, + # and usually wanted for layout. That's too hard to remember, so we always + # add it. Multiple spaces are equal to one. That way the exact layout of + # code in the templates doesn't matter. Beware of using {% spaceless %} + # which will negate this. A pure CSS solution escaped me thus far, since a + # space will take additional space in addition to a margin. + + +# a mapping from project types to Bootstrap icon names: +_icons = { + "date": "calendar3", + "document": "file-earmark", + "download": "file-earmark-arrow-down-fill", + "language": "vector-pen", + "project": "clipboard2", + "subject": "book", +} + + +@register.simple_tag +def icon(name): + """An "official" project-specific icon for the common cases""" + if not (bs_name := _icons.get(name)): + raise template.TemplateSyntaxError(f"'icon' requires a registered icon name (got {name!r})") + + return _bs_icon(bs_name) diff --git a/app/general/tests/test_document_detail.py b/app/general/tests/test_document_detail.py index f02687fa..b6051b21 100644 --- a/app/general/tests/test_document_detail.py +++ b/app/general/tests/test_document_detail.py @@ -35,6 +35,7 @@ def test_document_detail_num_queries(self): with self.assertNumQueries(3): response = self.client.get(url) self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') self.assertContains(response, self.document.title) self.assertContains(response, self.institution.name) self.assertContains(response, self.subject1.name) diff --git a/app/general/tests/test_documents.py b/app/general/tests/test_documents.py new file mode 100644 index 00000000..dd2d2fe0 --- /dev/null +++ b/app/general/tests/test_documents.py @@ -0,0 +1,42 @@ +from django.test import TestCase +from django.urls import reverse + +from general.models import DocumentFile, Institution, Language, Subject + + +class DocumentViewTests(TestCase): + def setUp(self): + self.subject1 = Subject.objects.create(name="Subject 1") + self.subject2 = Subject.objects.create(name="Subject 2") + self.language1 = Language.objects.create(name="Language 1", iso_code="lang1") + self.language2 = Language.objects.create(name="Language 2", iso_code="lang2") + self.language3 = Language.objects.create(name="Language 3", iso_code="lang3") + self.language4 = Language.objects.create(name="Language 4", iso_code="lang4") + self.institution = Institution.objects.create( + name="Institution", logo="institution_logo.png" + ) + + self.document5 = DocumentFile.objects.create( + title="Document 5", + institution=self.institution, + ) + self.document5.subjects.add(self.subject1) + self.document5.languages.add(self.language1) + + self.document6 = DocumentFile.objects.create( + title="Document 6", + institution=self.institution, + ) + self.document6.subjects.add(self.subject1) + self.document6.languages.add(self.language1) + self.document6.languages.add(self.language2) + self.document6.languages.add(self.language3) + self.document6.languages.add(self.language4) + + def test_view_basics(self): + with self.assertNumQueries(7): + response = self.client.get(reverse("documents")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') + self.assertIn("documents", response.context) diff --git a/app/general/tests/test_filter.py b/app/general/tests/test_filter.py index bc328769..579f8348 100644 --- a/app/general/tests/test_filter.py +++ b/app/general/tests/test_filter.py @@ -52,7 +52,7 @@ def test_institution_filter(self): qs = filter.qs self.assertEqual(len(qs), 2) # TODO: ordering between documents and projects are not yet defined - self.assertEqual(qs[0]["id"], self.doc1.id) + self.assertEqual(qs[0]["id"], self.project1.id) def test_subjects_filter(self): data = {"subjects": [self.subject1.id]} @@ -102,9 +102,10 @@ def test_search_filter_projects(self): def test_search_filter_combined(self): data = {"search": "1"} filter = DocumentFileFilter(data=data) - qs = filter.qs - self.assertEqual(len(qs), 2) - self.assertCountEqual([qs[0]["id"], qs[1]["id"]], [self.doc1.id, self.project1.id]) + qs_ids = [x["id"] for x in filter.qs] + expected_ids = [self.doc1.id, self.project1.id, self.institution1.id] + self.assertCountEqual(qs_ids, expected_ids) + # TODO: test properly instead of relying on randomly agreeing IDs if __name__ == "__main__": diff --git a/app/general/tests/test_institution_detail.py b/app/general/tests/test_institution_detail.py index 71da4cb7..ae9a0015 100644 --- a/app/general/tests/test_institution_detail.py +++ b/app/general/tests/test_institution_detail.py @@ -16,15 +16,13 @@ def setUp(self): ) def test_institution_detail_view_with_valid_id(self): - response = self.client.get(reverse("institution_detail", args=[self.institution.id])) + with self.assertNumQueries(3): + response = self.client.get(reverse("institution_detail", args=[self.institution.id])) self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') self.assertContains(response, self.institution.name) def test_institution_detail_view_with_invalid_id(self): invalid_id = self.institution.id + 1 response = self.client.get(reverse("institution_detail", args=[invalid_id])) self.assertEqual(response.status_code, 404) - - def test_project_detail_view_num_queries(self): - with self.assertNumQueries(1): - response = self.client.get(reverse("project_detail", args=[self.institution.id])) diff --git a/app/general/tests/test_languages.py b/app/general/tests/test_languages.py index 8029af05..0701c1d9 100644 --- a/app/general/tests/test_languages.py +++ b/app/general/tests/test_languages.py @@ -32,7 +32,8 @@ def setUp(self): self.project2 = Project.objects.create(name="Project 2", institution=self.institution) self.project2.languages.add(self.language2) - def test_languages_view_num_queries(self): + def test_view_basics(self): with self.assertNumQueries(3): response = self.client.get(reverse("languages")) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') diff --git a/app/general/tests/test_project_detail.py b/app/general/tests/test_project_detail.py index bce7593d..5991b237 100644 --- a/app/general/tests/test_project_detail.py +++ b/app/general/tests/test_project_detail.py @@ -30,6 +30,7 @@ def setUp(self): def test_project_detail_view(self): response = self.client.get(reverse("project_detail", args=[self.project.id])) self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') self.assertContains(response, self.project.name) self.assertContains(response, self.project.url) self.assertContains(response, self.institution.name) diff --git a/app/general/tests/test_projects.py b/app/general/tests/test_projects.py index 77ffea78..239016b2 100644 --- a/app/general/tests/test_projects.py +++ b/app/general/tests/test_projects.py @@ -45,8 +45,10 @@ def setUp(self): self.url = reverse("projects") def test_projects_view(self): - response = self.client.get(reverse("projects")) + with self.assertNumQueries(6): + response = self.client.get(reverse("projects")) self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') self.assertIn("projects", response.context) self.assertEqual(len(response.context["projects"]), 2) self.assertEqual(response.context["projects"][0]["project"].name, "Project 1") @@ -64,12 +66,6 @@ def test_projects_view_multilingual(self): projects = response.context["projects"] self.assertEqual(projects[1]["languages"], "Multilingual") - def test_projects_view_queries(self): - response = self.client.get(self.url) - - with self.assertNumQueries(6): - response = self.client.get(self.url) - class GetDateRangeTests(TestCase): def setUp(self): diff --git a/app/general/tests/test_view_search.py b/app/general/tests/test_view_search.py index 21ab0c42..3db28d7c 100644 --- a/app/general/tests/test_view_search.py +++ b/app/general/tests/test_view_search.py @@ -31,34 +31,36 @@ def setUp(self): doc.subjects.add(self.subject1 if i % 2 == 0 else self.subject2) doc.languages.add(self.language1 if i % 2 == 0 else self.language2) + def test_view_basics(self): + with self.assertNumQueries(5): + response = self.client.get(reverse("search")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') + def test_search_pagination(self): - client = Client() - response = client.get(reverse("search"), {"page": "1"}) + response = self.client.get(reverse("search"), {"page": "1"}) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context["documents"]), 5) + self.assertEqual(len(response.context["page_obj"]), 5) - response = client.get(reverse("search"), {"page": "2"}) + response = self.client.get(reverse("search"), {"page": "2"}) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context["documents"]), 5) + self.assertEqual(len(response.context["page_obj"]), 5) - response = client.get(reverse("search"), {"page": "3"}) + response = self.client.get(reverse("search"), {"page": "3"}) self.assertEqual(response.status_code, 200) def test_search_filtering(self): - client = Client() - response = client.get(reverse("search"), {"search": "Document 1"}) + response = self.client.get(reverse("search"), {"search": "Document 1"}) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context["documents"][0]["heading"], "Document 1") + self.assertEqual(response.context["page_obj"][0]["heading"], "Document 1") def test_invalid_page_number(self): - client = Client() - response = client.get(reverse("search"), {"page": "invalid"}) + response = self.client.get(reverse("search"), {"page": "invalid"}) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context["documents"]), 5) + self.assertEqual(len(response.context["page_obj"]), 5) def test_combined_filters(self): - client = Client() - response = client.get( + response = self.client.get( reverse("search"), { "institution": self.institution1.id, @@ -68,7 +70,7 @@ def test_combined_filters(self): }, ) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context["documents"]), 5) + self.assertEqual(len(response.context["page_obj"]), 5) if __name__ == "__main__": diff --git a/app/general/tests/test_views_institution.py b/app/general/tests/test_views_institution.py index a42dcc96..3b623f18 100644 --- a/app/general/tests/test_views_institution.py +++ b/app/general/tests/test_views_institution.py @@ -1,12 +1,12 @@ from django.test import Client, TestCase from django.urls import reverse -from general.models import Institution, Project +from general.models import DocumentFile, Institution, Project class InstitutionsViewTestCase(TestCase): def setUp(self): - Institution.objects.create( + inst1 = Institution.objects.create( name="Institution Banana", abbreviation="UniB", url="unibanana.co.za", @@ -17,15 +17,18 @@ def setUp(self): name="Institution Apple", abbreviation="UniA", url="uniapple.co.za" ) - inst1 = Institution.objects.get(name="Institution Banana") Project.objects.create(name="Test Project 1", institution=inst1) Project.objects.create(name="Test Project 2", institution=inst1) + DocumentFile.objects.create(title="Test document 1", institution=inst1) + DocumentFile.objects.create(title="Test document 2", institution=inst1) self.url = reverse("institutions") - def test_institutions_view_correct_template_used(self): - response = self.client.get(self.url) - + def test_view_basics(self): + with self.assertNumQueries(1): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') self.assertTemplateUsed(response, "app/institutions.html") def test_institutions_view_correct_context_returned(self): @@ -40,19 +43,12 @@ def test_institutions_view_correct_projects_returned(self): institutions = response.context["institutions"] - self.assertEqual(response.status_code, 200) self.assertEqual(len(institutions), 2) self.assertTrue(all("project_count" in inst for inst in institutions)) self.assertEqual(institutions[0]["project_count"], 0) self.assertEqual(institutions[1]["project_count"], 2) - - def test_institutions_view_queries(self): - response = self.client.get(self.url) - - with self.assertNumQueries(1): - response = self.client.get(self.url) - - self.assertEqual(response.status_code, 200) + self.assertEqual(institutions[0]["document_count"], 0) + self.assertEqual(institutions[1]["document_count"], 2) def test_institution_ratings(self): response = self.client.get(self.url) diff --git a/app/general/tests/tests_subject.py b/app/general/tests/tests_subject.py index 4e7666f2..9f61bd97 100644 --- a/app/general/tests/tests_subject.py +++ b/app/general/tests/tests_subject.py @@ -49,6 +49,7 @@ def test_subjects_view_num_queries(self): response = self.client.get(reverse("subjects")) self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') self.assertTemplateUsed(response, "app/subjects.html") self.assertTrue("subject_data" in response.context) self.assertTrue("page_obj" in response.context) diff --git a/app/static/css/styles.css b/app/static/css/styles.css deleted file mode 100755 index f3dcc15c..00000000 --- a/app/static/css/styles.css +++ /dev/null @@ -1,737 +0,0 @@ -/* Remember to sync colours with admin CSS. */ -:root { - --primary: #1a2f69; - --primary-light: #8288bc; - --primary-red: #d11f26; - --primary-fg: #ffffff; - --body-quiet-color: #485d95; - --text-color: #000000; - --link-text: #2c6bce; -} - - -/*General*/ -body { - padding: 0; - margin: 0; -} - -html { - position: relative; - min-height: 100%; -} - -input[type="checkbox"] { - width: 1.5em; - height: 1.5em; -} - -.content { - overflow: hidden; - padding-bottom: 130px; -} - -.footer { - text-align: center; - padding: 1rem; - background: var(--primary); - color: white; - width: 100%; - overflow: hidden; - - position: absolute; - bottom: 0; -} -.header-container { - display: inline-block; -} -.nav-menu { - float: left; - padding: 5px; - margin: 10px 10px 10px 10px; -} -.nav-logo { - margin: 3px 10px 10px 10px; - float: left; -} -.nav { - background: var(--primary-fg); - width: 100%; -} -.nav > li > a{ - margin-top: 15px; - color: var(--text-color); -} -.nav > li > .active{ - margin-top: 15px; - background-color: var(--primary) !important; - color: var(--primary-fg); -} -.nav > li > a:hover{ - margin-top: 15px; - background-color: var(--body-quiet-color); - color: var(--primary-fg); -} -.nav > li > a:active{ - margin-top: 15px; - background-color: var(--primary); - color: var(--primary-fg); -} -.btn-primary{ - margin-top:20px; - outline-color: var(--primary); - background-color: var(--primary); - border-color: var(--primary); -} -.btn-secondary{ - margin-top:20px; -} -.btn-outline-primary{ - margin-top:20px; - background: #2D4481; - outline-color: var(--primary); -} -.btn-primary:hover{ - margin-top:20px; - background: var(--body-quiet-color); - outline-color: var(--primary); -} -.app-footer .footer-title { - color: var(--primary); -} -.footer > a { - color: var(--primary-fg); -} -.section{ - margin-left: 10px; - margin-right: 10px; - margin-bottom: 10px; -} -.main-logo { - width: 140px; - height: 75px; - margin: 20px 10px 10px 10px; -} -.page-link { - background-color: var(--primary) !important; - color: var(--primary-fg) !important; -} -.page-link:hover{ - background-color: var(--body-quiet-color) !important; - color: var(--primary-fg) !important; -} -.footer-class { - display: flex; - justify-content: center; - align-items: center; - position: relative; - flex-direction: column; -} -.language-toggle { - margin-top: 1rem; -} -.language-toggle form { - display: flex; - flex-direction: column; - align-items: center; -} -.language-toggle select, -.language-toggle .btn-language { - margin-top: 0.5rem; -} -.btn-language{ - color: var(--text-color); - outline-color: var(--primary-fg); - background-color: var(--primary-fg); - border-color: var(--primary-fg); -} -.pagination-wrapper { - display: flex; - justify-content: center; - margin-top: 20px; - overflow-x: auto; -} -.pagination { - display: flex; - list-style: none; - padding: 0; - border: 1px solid #000; - border-radius: 5px; -} -.pagination .page-item { - margin: 0; - background-color: var(--primary); -} -.pagination .page-item:first-child { - border-left: none; -} -.pagination .page-link { - padding: 5px 10px; - text-decoration: none; - color: var(--text-color); - height: 100%; -} -.pagination .page-item.active .page-link { - background-color: var(--primary); - color: var(--primary-fg); -} -.pagination .page-item.disabled .page-link { - color: #6c757d; -} -.button-spacing { - margin-right: 10px; -} - -/*Home page*/ -.content-card { - border-color: var(--primary-red); - margin: 0 10px 0 10px; - width: 100%; -} - -/*Institutions page*/ -.uni-card { - border-color: var(--primary-red); - margin: 0 10px 0 10px; -} -.uni-logo { - width: auto; - height: 75px; - margin: 10px 0 10px 0; - padding: 0 10px 10px 0; -} -.institution-row { - margin: 10px; - padding: 10px; - display: flex; - justify-content: space-between; - align-items: flex-start; - flex-wrap: wrap; -} -.left-col, .right-col { - flex: 1; - min-width: 200px; - display: flex; - flex-direction: column; - justify-content: center; -} - -/*Projects page*/ -.filter-class label { - font-size: 1.15rem; -} -.filter-class { - margin: 30px; - font-size: 0.875rem; -} -.filter-form { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - justify-content: flex-start; - width: 100%; - box-sizing: border-box; -} -.filter-label { - width: 100%; - margin-bottom: 10px; -} -.filter-form label { - margin-right: 10px; -} -.filter-form .form-group { - margin: 0 10px 10px 0; -} -.filter-form .form-control { - border: 1px solid #000; -} -.button-group { - display: flex; - align-items: center; -} -.btn-apply, .btn-reset{ - color: var(--primary-fg); - outline-color: var(--primary); - background-color: var(--primary); - border-color: var(--primary); - padding: 10px 20px; - margin-right: 10px; - white-space: nowrap; -} -.btn-apply:hover, .btn-reset:hover{ - color: var(--primary-fg); - outline-color: var(--primary); - background-color: var(--primary); - border-color: var(--primary); -} - -.project-list { - margin: 20px; - padding: 0 30px 0 30px; -} - -.project-name { - text-decoration: none; -} - -.project-name:hover { - text-decoration: underline; -} - -.card-text-project { - font-size: 0.875rem; - margin: 3px 0 3px 0; -} -.project-logo { - height: 100px; - width: auto; - -} -.project-border { - margin: 0 0 5px 0; - overflow: hidden; - padding: 0 5px 0 0; - word-wrap: break-word; -} -.project-header { - display: flex; - justify-content: space-between; - align-items: center; - max-width: 800px; -} -.project-body { - width: 100%; - max-width: 800px; - -} -.card-text-description { - word-wrap: break-word; - overflow: hidden; - font-size: 0.875rem; - margin: 3px 0 3px 0; -} -.project-row { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 10px; - box-sizing: border-box; - width: 100%; -} -.project-text { - font-size: 1.25rem; -} -.icon-text { - font-size: 0.95rem; -} - -/*Projects & Institution detail pages*/ -.detail { - width: 80%; - margin: 0 auto; - font-size: 0.875rem; -} -.detail h1 { - font-size: 2em; -} -.detail a { - text-decoration: none; - color: var(--link-text); -} -.project-detail a:hover, -.detail-col ul li a:hover { - text-decoration: underline; -} - -.detail { - width: 80%; - margin-left: 40px; -} - -.detail-row { - flex-wrap: wrap; - margin-bottom: 20px; -} - -.project-date-left-col { - flex: 1; - padding-right: 10px; -} - -.project-date-right-col { - flex: 1; - display: flex; - justify-content: left; -} - -.detail-col { - border-radius: 5px; -} - -.detail-col h2 { - font-size: 1.5em; - margin-bottom: 10px; -} - -.detail-col ul { - list-style: none; - padding: 0; -} - -.detail-col ul li { - margin-bottom: 5px; -} - -.detail-col ul li a { - text-decoration: none; - color: var(--link-text); -} -.project-date-row { - font-size: 0.875rem; -} -.gap-2 > * { - margin-right: 0.5rem; - margin-bottom: 0.5rem; -} - -/*Accounts*/ -.user-account-body { - margin: 30px; -} - -.login-form-group .form-control { - width: 100%; - max-width: 500px; -} - -/*Search page*/ -.filter-label { - font-weight: bold; -} -.filter-checkbox { - display: flex; - align-items: flex-start; - gap: 10px; -} -.single-checkbox { - display: inline-block; - vertical-align: top; - white-space: nowrap; - word-break: keep-all; - width: 20px; -} - -.checkbox-container { - max-height: 90px; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - border: 1px solid; - box-sizing: border-box; - border-radius: 0.3rem; - border-color: #ccc; - padding: 1rem; -} - -/* Ensures checkboxes are styled properly */ -.checkbox-container input[type="checkbox"] { - margin-right: 10px; - cursor: pointer; -} - - -/*Error pages*/ -.body-card { - border-color: var(--primary-red); - margin: 10px; - width: 100%; -} - -/*bootstrap icons*/ -.bi { - font-size: 50px; -} -.detail-icon { - font-size: 20px; - margin-right: 3px; -} - -/* Extra small devices (phones, 600px and down) */ -@media only screen and (max-width: 600px) { - /*General*/ - .header-container { - width: 100%; - } - .section { - padding-right: 30px; - } - .pagination .page-link { - padding: 5px; - font-size: 12px; - } - - .pagination-wrapper { - padding: 0 10px; - } - - /*Institutions page*/ - .uni-card { - width: 100%; - font-size: smaller; - } - .all-cards { - width: 98%; - padding-right: 30px; - } - .institution-row { - flex-direction: column; - align-items: flex-start; - } - .left-col, .right-col { - width: 100%; - } - - /*Home page*/ - .content-card { - font-size: smaller; - padding-right: 20px; - margin-right: 10px; - } - .form-group.col-md-4 { - flex: 0 0 100%; - max-width: 100%; - } - .gap-2 > * { - margin-right: 0; - } - .content { - padding-bottom: 200px; - } -} - -/*small devices (tablets, 600px and up) */ -@media screen and (min-width: 600px) { - /*General*/ - .header-container { - width: 100%; - } - .section { - padding-right: 20px; - } - .pagination .page-link { - padding: 5px; - font-size: 12px; - } - - .pagination-wrapper { - padding: 0 10px; - } - - /*Institutions page*/ - .all-cards { - width: 100%; - padding-right: 10px; - } - .left-col { - width: 70%; - } - .right-col { - width: 30%; - } - .uni-card { - width: 98%; - height: 200px; - padding-right: 20px; - } - - /*Home page*/ - .content-card { - font-size: smaller; - } - .form-group.col-md-4 { - flex: 0 0 100%; - max-width: 100%; - } - .gap-2 > * { - margin-right: 0; - } - .content { - padding-bottom: 200px; - } -} - -/*Medium screens (tablets, between 768px and 1001px)*/ -@media screen and (min-width: 768px) and (max-width: 1001px) { - /*General*/ - .header-container { - width: 100%; - } - .section { - padding-right: 20px; - } - .footer-class { - flex-direction: row; - justify-content: center; - } - - .language-toggle { - margin-top: 0; - position: absolute; - right: 1rem; - } - - .language-toggle form { - display: flex; - flex-direction: row; - align-items: center; - } - - .language-toggle select, - .language-toggle .btn-language { - margin-left: 0.5rem; - margin-top: 0; - } - - /*Institutions page*/ - .uni-card { - width: 98%; - font-size: medium; - padding-right: 20px; - height: 200px; - } - .all-cards { - width: 100%; - padding-right: 10px; - } - .left-col { - width: 70%; - } - .right-col { - width: 30%; - } - - /*Projects page*/ - .filter-form select { - padding: 0 10px; - width: 200px; - } - .project-row { - display: flex; - justify-content: space-between; - } - .project-left-col { - width: 70%; - flex: 1; - min-width: 200px; - padding-right: 15px; - } - .project-right-col { - width: 30%; - flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: center; - } - - /*Projects & Institution detail pages*/ - .detail-left-col, .detail-right-col { - flex: 1; - padding: 10px; - } - .project-date-row { - display: flex; - font-size: 0.875rem; - } - .filter-form { - display: flex; - } -} - - /*Larger screens (desktops, 1001px and up)*/ -@media screen and (min-width: 1001px){ - /*General*/ - .section { - padding-right: 20px; - } - .footer-class { - flex-direction: row; - justify-content: center; - } - - .language-toggle { - margin-top: 0; - position: absolute; - right: 1rem; - } - - .language-toggle form { - display: flex; - flex-direction: row; - align-items: center; - } - - .language-toggle select, - .language-toggle .btn-language { - margin-left: 0.5rem; - margin-top: 0; - } - /*Institutions page*/ - .institution-card{ - overflow: hidden; - } - .all-cards { - width: 48%; - margin: 10px 10px 10px 10px; - float: left; - padding-right: 10px; - } - .left-col { - width: 70%; - } - .right-col { - width: 30%; - } - .uni-card { - margin: 10px; - width: 100%; - height: 200px; - font-size: small; - } - - /*Projects page*/ - .filter-form select { - padding: 0 10px; - width: 230px; - } - .project-row { - display: flex; - justify-content: space-between; - } - .project-left-col { - width: 70%; - flex: 1; - min-width: 200px; - } - .project-right-col { - width: 30%; - flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: center; - } - - /*Projects & Institution detail pages*/ - .detail-right-col { - flex: 0 0 auto; - display: flex; - justify-content: left; - } - .project-date-row { - display: flex; - font-size: 0.875rem; - } - .filter-form - { - display: flex; - } diff --git a/app/static/img/favicon.png b/app/static/img/favicon.png new file mode 100644 index 00000000..5fc30ea7 Binary files /dev/null and b/app/static/img/favicon.png differ diff --git a/app/templates/400.html b/app/templates/400.html index f0becc31..da81bca7 100644 --- a/app/templates/400.html +++ b/app/templates/400.html @@ -1,12 +1,5 @@ {% extends "base_error.html" %} {% load i18n %} -{% block error_content %} - -
{% trans "The server cannot process the request due to client error." %}"
- {% trans "Go to home" %} -{% trans "You do not have permission to access on this server." %}
- {% trans "Go to home" %} -{% trans "The requested resource was not found on this server." %}
- {% trans "Go to home" %} -{% trans "The server encountered an unexpected condition that prevented it from fulfilling the request." %}
- {% trans "Go to home" %} -
+ {% blocktrans trimmed %}
+ Juan Steyn
+ South African Centre for Digital Language Resources (SADiLaR)
+ North-West University
+ South Africa
+ {% endblocktrans %}
+
+27 18 285 2750
+ +{% blocktrans %}Juan Steyn
- South African Centre for Digital Language Resources (SADiLaR)
- North-West University
- South Africa{% endblocktrans %}
+27 18 285 2750
- -- Directions -
-{% trans "Documents" %} > {{ document.title }}
+{% block title %}{{ document.title }}{% endblock %} -{% trans "Documents" %} > {{ document.title }}
- - {{ document.url }} - +{{ document.description }}
-- - {{ document.institution.name }} - -
++ + {{ document.institution.name }} + +
-{% trans "License:" %} {{ document.license }}
-{% trans "Category:" %} {{ document.document_type }}
-{% trans "License:" %} {{ document.license }}
+{% trans "Category:" %} {{ document.document_type }}
++ {{ item.description|truncatewords:30 }} + {% trans "Read more" %} +
-+ {% blocktrans trimmed %} + The South African Centre for Digital Language Resources (SADiLaR) is a national research + infrastructure that aims to ensure a digital future for our official languages. + It supports researchers in the fields of digital humanities and social sciences and also + assists institutions in their language implementation plans. + {% endblocktrans %} + {% blocktrans %}Read more about SADiLaR.{% endblocktrans %} +
++ {% blocktrans trimmed %} + SADiLaR conducted an audit at South African public higher education institutions that highlighted + areas that require attention. + {% endblocktrans %} + {% blocktrans %}Read the report.{% endblocktrans %} +
++ {% blocktrans trimmed %} + This platform highlights previous and current work at institutions to encourage collaboration and + disseminate resources. + {% endblocktrans %} +
+- {% blocktrans %}The South African Centre for Digital Language Resources (SADiLaR) is a national research - infrastructure that aims to ensure a digital future for our official languages. - It supports researchers in the fields of digital humanities and social sciences and also assists - institutions in their language implementation plans.{% endblocktrans %} - {% blocktrans %}Read more about SADiLaR.{% endblocktrans %} -
-- {% blocktrans %}SADiLaR conducted an audit at South African public higher education institutions that highlighted - areas that require attention.{% endblocktrans %} - {% blocktrans %}Read the report.{% endblocktrans %} -
-- {% blocktrans %}This platform highlights previous and current work at institutions to encourage collaboration and - disseminate resources.{% endblocktrans %} -
-+ {% blocktrans trimmed %} + This platforms hosts terminology and other useful language resources. + {% endblocktrans %} +
++
+ +- {% blocktrans %}This platforms hosts terminology and other useful language resources.{% endblocktrans %} -
--
- -- {% trans "Browse information by institution." %} -
- - {% trans "View" %} ++ {% trans "Browse through the available information by one of the following categories:" %} +
+{% trans "Institutions" %} > {{ institution.name }}
-{% trans "No projects available for this institution." %}
- {% endif %}{% trans "No documents available for this institution." %}
- {% endif %}- {{ institution.abbreviation }} +
{{ institution.abbreviation }}
+ {% if institution.project_count %} ++ {% icon "project" %} + {% blocktrans count project_count=institution.project_count trimmed %} + {{ project_count }} project + {% plural %} + {{ project_count }} projects + {% endblocktrans %}
-- {% if institution.project_count == 0 %}{% blocktrans %}no applicable project{% endblocktrans %} - {% else %} - {% blocktrans count project_count=institution.project_count %}{{ project_count }} applicable project{% plural %}{{ project_count }} applicable projects{% endblocktrans %} - {% endif %} + {% endif %} + {% if institution.document_count %} +
+ {% icon "document" %} + {% blocktrans count document_count=institution.document_count trimmed %} + {{ document_count }} document + {% plural %} + {{ document_count }} documents + {% endblocktrans %}
+ {% endif %}{% trans "Profile completion:" %}
- -{% trans "No documents available for this language." %}
+ {# Only show this in two-column mode to ensure consistent layout and placement #} + {% endif %} -{% trans "No projects available for this language." %}
- {% endif %} -- {% trans "The terms of use statement for the SADiLaR website and services" %} -
-- {% blocktrans %}Intellectual property rights concerning the materials shared via this website and the website - as such belong to the original creator or to SADiLaR. Where exceptions apply, the license - agreement governing a particular resource will be explicitly listed. - Content included in the website is shared under the Creative Commons License Attribution - 2.0, unless indicated otherwise for specific materials. This license agreement does not allow - the inclusion of any elements contained in the website (such as images, logos, videos and - other resources) in a frame-set or an in-line link to another web page without giving due - credit to the origin of the material.{% endblocktrans %} -
-- {% blocktrans %}SADiLaR and its partners make a conscious effort to obtain copyright clearance for any - materials shared on or used in creation of the website before publication. Should you notice - any potential infringement on your own or anyone else’s intellectual property rights, we ask - that you inform us directly so that we may remove the material and/or clear any outstanding - copyright matters with the rightful owner immediately.{% endblocktrans %} -
-- {% blocktrans %}Please find the privacy policy and privacy statement for the SADiLaR website and services - here.{% endblocktrans %} -
-- {% blocktrans %}The purpose of the South African Protection of Personal Information Act 4 of 2013 (POPIA) - is to ensure responsible handling of an individual’s personal information and to hold all South - African institutions accountable should they not safeguard the collection, storing or sharing - of such information against possible abuse in any way. SADiLaR is strongly committed to - protecting personal information/data and strives to adhere to the {% endblocktrans %}{% trans "guidelines" %} - {% blocktrans %} as set out by the host institution for the Centre, the North-West University.{% endblocktrans %} -
-- {% blocktrans %}Cookies are data saved on the device with which you access this website to customise your - experience or provide essential functions of the website such as enabling you to log in to - secure areas, e.g. the Repository. You can learn more about the different types of cookies - and how to disable non-essential cookies {% endblocktrans %}{% trans "here." %} -
-- {% blocktrans %}By using the SADiLaR website, you consent to our use of cookies and storage thereof on - your device. Should you not accept our use of cookies (or your browser is set to block all - cookies), your ability to access all functions on this website might be impaired.{% endblocktrans %} -
-- {% blocktrans %}Any questions concerning the website or services offered via the website can be addressed - to SADiLaR via email to {% endblocktrans %}info@sadilar.org -
-+ + {% trans "The terms of use statement for the SADiLaR website and services" %} + +
++ {% blocktrans trimmed %} + Intellectual property rights concerning the materials shared via this website and the website + as such belong to the original creator or to SADiLaR. Where exceptions apply, the license + agreement governing a particular resource will be explicitly listed. + {% endblocktrans %} + {% blocktrans trimmed %} + Content included in the website is shared under the Creative Commons License Attribution + 2.0, unless indicated otherwise for specific materials. This license agreement does not allow + the inclusion of any elements contained in the website (such as images, logos, videos and + other resources) in a frame-set or an in-line link to another web page without giving due + credit to the origin of the material. + {% endblocktrans %} +
++ {% blocktrans trimmed %} + SADiLaR and its partners make a conscious effort to obtain copyright clearance for any + materials shared on or used in creation of the website before publication. Should you notice + any potential infringement on your own or anyone else’s intellectual property rights, we ask + that you inform us directly so that we may remove the material and/or clear any outstanding + copyright matters with the rightful owner immediately. + {% endblocktrans %} +
++ {% blocktrans trimmed %} + Please find the privacy policy and privacy statement for the SADiLaR website and services here. + {% endblocktrans %} +
++ {% blocktrans with url="https://www.nwu.ac.za/POPIA_Policy_NWU" trimmed %} + The purpose of the South African Protection of Personal Information Act 4 of 2013 (POPIA) + is to ensure responsible handling of an individual’s personal information and to hold all South + African institutions accountable should they not safeguard the collection, storing or sharing + of such information against possible abuse in any way. SADiLaR is strongly committed to + protecting personal information/data and strives to adhere to the + guidelines + as set out by the host institution for the Centre, the North-West University. + {% endblocktrans %} +
++ {% blocktrans with url="https://www.nwu.ac.za/POPIA_Policy_NWU" trimmed %} + Cookies are data saved on the device with which you access this website to customise your + experience or provide essential functions of the website such as enabling you to log in to + secure areas, e.g. the Repository. You can learn more about the different types of cookies + and how to disable non-essential cookies here. + {% endblocktrans %} +
++ {% blocktrans trimmed %} + By using the SADiLaR website, you consent to our use of cookies and storage thereof on + your device. Should you not accept our use of cookies (or your browser is set to block all + cookies), your ability to access all functions on this website might be impaired. + {% endblocktrans %} +
++ {% blocktrans with address="info@sadilar.org" trimmed %} + Any questions concerning the website or services offered via the website can be addressed + to SADiLaR via email to {{ address }}. + {% endblocktrans %} +
+{% trans "Projects" %} > {{ project.name }}
+{% spaceless %} +{% trans "Projects" %} > {{ project.name }}
-- {{ project.description | linebreaks }} -
-- - {{ project.institution.name }} - -
++ + {{ project.institution.name }} + +
-- - {% trans "Start Date:" %} {{ project.start_date|date:"Y-m-d" }} -
-+ {% icon "date" %} + {% trans "Start Date:" %} {{ project.start_date|date:"Y-m-d" }} +
-- - {% trans "End Date:" %} {{ project.end_date|date:"Y-m-d" }} -
-+ {% icon "date" %} + {% trans "End Date:" %} {{ project.end_date|date:"Y-m-d" }} +