diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..11c84ea7 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +SECRET_KEY='' +DEBUG=True +DB_HOST=db +DB_PORT=5432 +DB_NAME=term_db +DB_USER=sadilar +DB_PASSWORD=sadilar +LOGGING_FILE=debug.log +LOGGING_HANDLERS_LEVEL=INFO +LOGGING_LOGGERS_LEVEL=INFO +LOGGING_LOGGERS_DJANGO_LEVEL=INFO +TESTING_DIR=/app/general/tests/files/ +EMAIL_HOST='' +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER='' +EMAIL_HOST_PASSWORD='' +DEFAULT_FROM_EMAIL='' +SERVER_EMAIL='' +EMAIL_SUBJECT_PREFIX='' +EMAIL_BACKEND_CONSOLE='True/False' +SECRET_KEY='' diff --git a/.env.testing b/.env.testing new file mode 100644 index 00000000..e74f436b --- /dev/null +++ b/.env.testing @@ -0,0 +1,12 @@ +SECRET_KEY='django-insecure-w!h85bp^$$e8gm%c23r!0%9i7yzd=6w$$s&ic+6!%306&kj8@k*5' +DEBUG=True +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=term_db +DB_USER=sadilar +DB_PASSWORD=sadilar +LOGGING_FILE=debug.log +LOGGING_HANDLERS_LEVEL=INFO +LOGGING_LOGGERS_LEVEL=INFO +LOGGING_LOGGERS_DJANGO_LEVEL=INFO +TESTING_DIR=/home/runner/work/term_platform/term_platform/app/general/tests/files/ diff --git a/.github/workflows/main.yml b/.github/workflows/develop.yml similarity index 92% rename from .github/workflows/main.yml rename to .github/workflows/develop.yml index a1d1eeeb..01bf37cc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/develop.yml @@ -1,5 +1,5 @@ # https://docs.docker.com/build/ci/github-actions/push-multi-registries/ -name: docker_push +name: docker_push_deploy_test on: workflow_dispatch: @@ -8,7 +8,7 @@ on: - "develop" jobs: - docker: + deploy_test: runs-on: ubuntu-latest steps: - name: Checkout @@ -34,4 +34,4 @@ jobs: with: url: https://api.bitbucket.org/2.0/repositories/team_sadilar/ansible/pipelines/ headers: '{"Authorization": "Bearer ${{ secrets.BITBUCKET_PIPELINE_SECRET }}"}' - body: '{"target": {"ref_type": "branch", "type": "pipeline_ref_target", "ref_name": "master", "selector": {"type": "custom", "pattern": "deploy_term_platform" } }}' + body: '{"target": {"ref_type": "branch", "type": "pipeline_ref_target", "ref_name": "master", "selector": {"type": "custom", "pattern": "deploy_term_platform_test" } }}' diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml new file mode 100644 index 00000000..c8383e19 --- /dev/null +++ b/.github/workflows/production.yml @@ -0,0 +1,30 @@ +# https://docs.docker.com/build/ci/github-actions/push-multi-registries/ +name: docker_push_prod + +on: + push: + tags: + - v** + +jobs: + docker_push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to SADiLaR Container Registry + uses: docker/login-action@v3 + with: + registry: docker.sadilar.org + username: ${{ vars.SADILAR_DOCKER_REPOSITORY_USER }} + password: ${{ secrets.SADILAR_DOCKER_REPOSITORY_SECRET }} + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: | + docker.sadilar.org/term_platform:prod diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 00000000..24ebbca1 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,57 @@ +name: Testing Django +on: [ pull_request, push ] # activates the workflow when there is a push or pull request in the repo +jobs: + test_project: + runs-on: ubuntu-latest # operating system your code will run on + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: sadilar + POSTGRES_PASSWORD: sadilar + POSTGRES_DB: test_db_1 + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + sudo apt-get install -y gettext + - name: Run linting tools + run: | + cd app/ + ruff format . + - name: Create logging folder + run: | + sudo mkdir -p /logging + sudo chown runner:runner /logging + - name: Compile Translation Messages + run: | + cp .env.testing app/.env + cd app/ + python manage.py makemessages --all + python manage.py compilemessages + - name: Run validate_templates + run: | + export DJANGO_TEST_PROCESSES=1 + cp .env.testing app/.env + cd app/ + mkdir -p static_files + python manage.py validate_templates --ignore-app django_filters + - name: Run Tests + run: | + cp .env.testing app/.env + cd app/ + mkdir -p static_files + python manage.py test + env: + DJANGO_SETTINGS_MODULE: app.settings + DATABASE_URL: postgres://sadilar:sadilar@localhost:5432/test_db + - name: Manager Check + run: | + cd app/ + python manage.py check diff --git a/.gitignore b/.gitignore index b24d71e2..168936e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,41 @@ -# These are some examples of commonly ignored file patterns. -# You should customize this list as applicable to your project. -# Learn more about .gitignore: -# https://www.atlassian.com/git/tutorials/saving-changes/gitignore - -# Node artifact files -node_modules/ -dist/ - -# Compiled Java class files -*.class - -# Compiled Python bytecode -*.py[cod] - # Log files *.log -# Package files -*.jar - -# Maven -target/ -dist/ - # JetBrains IDE .idea/ -# Unit test reports -TEST*.xml - -# Generated by MacOS -.DS_Store - # Generated by Windows Thumbs.db -# Applications -*.app -*.exe -*.war - -# Large media files -*.mp4 -*.tiff -*.avi -*.flv -*.mov -*.wmv - +# Django template +*.pot +*.mo +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal +media + +# General Files +.DS_Store +.AppleDouble +.LSOverride + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +#folders +app/static_files/ +/app/documents/ +app/media/ +/app/logging/ +/logging/ +/pdf_uploads/ +/pdf_upload_completed/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..fc533fec --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + # Linting and formatting for Python. + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.2.2 + 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 + rev: 5.13.2 + hooks: + - id: isort + args: [ --profile, black ] + + # Lint: YAML + - repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + args: ["-d {extends: relaxed, rules: {line-length: disable}}", "-s"] + files: \.(yaml|yml)$ diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..ef815f52 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +* @friedelwolff +.github @friedelwolff @jrb-s2c-github diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..283a6eb5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# A minimalist image usable as a base for a production deployment. +FROM python:3.12 + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +SHELL ["/bin/bash", "-c"] + +# Set work directory +WORKDIR /app + +# Install dependencies +COPY requirements.txt /app/ + +RUN <//", + views.PasswordResetConfirmView.as_view(), + name="password_reset_confirm", + ), + path( + "reset/done/", + auth_views.PasswordResetCompleteView.as_view(), + name="password_reset_complete", + ), +] diff --git a/app/accounts/views.py b/app/accounts/views.py new file mode 100644 index 00000000..375e0135 --- /dev/null +++ b/app/accounts/views.py @@ -0,0 +1,55 @@ +from django.contrib.auth import authenticate +from django.contrib.auth import login as auth_login +from django.contrib.auth.views import LoginView as _LoginView +from django.contrib.auth.views import PasswordChangeView as _PasswordChangeView +from django.contrib.auth.views import ( + PasswordResetConfirmView as _PasswordResetConfirmView, +) +from django.contrib.auth.views import PasswordResetView as _PasswordResetView +from django.shortcuts import redirect, render + +from .forms import ( + AuthenticationForm, + PasswordChangeForm, + PasswordResetForm, + SetPasswordForm, + UserCreationForm, +) + + +def register(request): + if request.method == "POST": + form = UserCreationForm(request.POST) + if form.is_valid(): + user = form.save(commit=False) + user.is_staff = True + user.save() + auth_login(request, user) + return redirect("home") + else: + form = UserCreationForm() + return render(request, "registration/register.html", {"form": form}) + + +# We subclass the builtin views where we want to supply our own forms. We +# (mostly) stick to the expected template names, and they are therefore +# automatically picked up, even in views where we don't subclass the builtin +# views. +class LoginView(_LoginView): + form_class = AuthenticationForm + + +class PasswordChangeView(_PasswordChangeView): + form_class = PasswordChangeForm + # The builtin view expects password_change_form.html, but so does the admin + # interface, and we don't want to replace that one as well, so we use a + # custom name. + template_name = "registration/password_change_form2.html" + + +class PasswordResetView(_PasswordResetView): + form_class = PasswordResetForm + + +class PasswordResetConfirmView(_PasswordResetConfirmView): + form_class = SetPasswordForm diff --git a/app/app/__init__.py b/app/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/app/asgi.py b/app/app/asgi.py new file mode 100644 index 00000000..fd207eb3 --- /dev/null +++ b/app/app/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for app project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") + +application = get_asgi_application() diff --git a/app/app/settings.py b/app/app/settings.py new file mode 100644 index 00000000..a1fb5be7 --- /dev/null +++ b/app/app/settings.py @@ -0,0 +1,326 @@ +""" +Django settings for app project. + +Generated by 'django-admin startproject' using Django 5.0.1. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +import os +import sys +from pathlib import Path + +import environ + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Take environment variables from .env file +environ.Env.read_env(os.path.join(BASE_DIR, ".env")) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +SECRET_KEY = os.environ["SECRET_KEY"] + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = bool(os.getenv("DEBUG", "")) +DEBUG_TOOLBAR = DEBUG # to toggle separately if we want to + +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split() +USE_X_FORWARDED_HOST = bool(os.getenv("USE_X_FORWARDED_HOST", "")) +CSRF_TRUSTED_ORIGINS = os.getenv("CSRF_TRUSTED_ORIGINS", "").split() + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "whitenoise.runserver_nostatic", + "django.contrib.staticfiles", + "django.forms", + "users", + "general", + "simple_history", + "accounts", + "django_filters", + "django_htmx", +] +if DEBUG: + INSTALLED_APPS += [ + "django_extensions", + ] + if DEBUG_TOOLBAR: + INSTALLED_APPS += [ + "debug_toolbar", + ] + +AUTH_USER_MODEL = "users.CustomUser" + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + # DebugToolbarMiddleware should go here if enabled. Done below. See + # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#add-the-middleware + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "simple_history.middleware.HistoryRequestMiddleware", + "django_htmx.middleware.HtmxMiddleware", + "general.middleware.ExtraVaryMiddleware", +] + +if DEBUG and DEBUG_TOOLBAR: + MIDDLEWARE.insert(2, "debug_toolbar.middleware.DebugToolbarMiddleware") + +ROOT_URLCONF = "app.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + BASE_DIR / "templates", + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "general.context_processors.template_vars", + ], + }, + }, +] + +# Enable using `TEMPLATE` setting above to render forms as well. This is needed +# to override some form rendering details. See app/django/forms/. +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" + +WSGI_APPLICATION = "app.wsgi.application" + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "HOST": os.environ.get("DB_HOST"), + "PORT": os.environ.get("DB_PORT"), + "NAME": os.environ.get("DB_NAME"), + "USER": os.environ.get("DB_USER"), + "PASSWORD": os.environ.get("DB_PASSWORD"), + "TEST": {"NAME": "test_db"}, + "CONN_MAX_AGE": None, + "CONN_HEALTH_CHECKS": True, + } +} + +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 + # See https://docs.djangoproject.com/en/stable/ref/settings/#internal-ips + # Inside a docker container, it isn't trivial to get the IP address of the + # Docker host that will appear in REMOTE_ADDR. The following seems to work + # for now to add support for a range of IP addresses without having to put + # a huge list in INTERNAL_IPS, e.g. with + # map(str, ipaddress.ip_network('172.0.0.0/24')) + # If this can't resolve the name "host.docker.internal", we assume that the + # browser will contact localhost. + import socket + + try: + host_ip = socket.gethostbyname("host.docker.internal") + except socket.gaierror: + # presumably not in docker + host_ip = None + + import ipaddress + + # Based on https://code.djangoproject.com/ticket/3237#comment:12 + class CIDRList(list): + def __init__(self, addresses): + """Create a new ip_network object for each address range provided.""" + self.networks = [ipaddress.ip_network(address, strict=False) for address in addresses] + + def __contains__(self, address): + """Check if the given address is contained in any of the networks.""" + return any([ipaddress.ip_address(address) in network for network in self.networks]) + + if host_ip: + INTERNAL_IPS = CIDRList([f"{host_ip}/8"]) + else: + INTERNAL_IPS = ["127.0.0.1"] + + +# Email settings +EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost") +EMAIL_PORT = os.environ.get("EMAIL_PORT", 25) +EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", False) +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") +DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "webmaster@localhost") +SERVER_EMAIL = os.environ.get("SERVER_EMAIL", "root@localhost") +EMAIL_SUBJECT_PREFIX = os.environ.get("EMAIL_SUBJECT_PREFIX", "[Django] ") +# Format as: Name Surname address@example.com, Name Surname address@example.com +if _admins := os.environ.get("ADMINS", ""): + ADMINS = [a.strip().rsplit(maxsplit=1) for a in _admins.split(",")] + +email_backend_env = os.environ.get("EMAIL_BACKEND_CONSOLE", "False").lower() in ["true", "1", "yes"] + +if DEBUG and email_backend_env: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/" + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "Africa/Johannesburg" + +USE_I18N = True + +USE_TZ = True + +# Media files (uploads) + +MEDIA_ROOT = BASE_DIR / "media" +MEDIA_URL = "media/" + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +# Static files (CSS, JavaScript, Images) +if DEBUG: + _STATICFILES_BACKEND = "django.contrib.staticfiles.storage.StaticFilesStorage" +else: + _STATICFILES_BACKEND = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +STATIC_URL = "/static/" +STATICFILES_DIRS = [ + BASE_DIR / "static", +] + +STATIC_ROOT = BASE_DIR / "static_files" +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": _STATICFILES_BACKEND, + }, +} + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGGING_DIR = Path("/logging") + +# Internationalization + +USE_I18N = True + +LANGUAGES = [ + ("af", "Afrikaans"), + ("en", "English"), +] + +LANGUAGE_CODE = "en" + +LOCALE_PATHS = [ + os.path.join(BASE_DIR, "locale"), +] + +# Check if the application is under testing +if "test" in sys.argv: + DEBUG = False + +# Logging configuration +if DEBUG: + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + } +else: + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + "file": { + "level": os.environ.get("LOGGING_HANDLERS_LEVEL", "WARNING"), + "class": "logging.FileHandler", + "filename": LOGGING_DIR / os.environ.get("LOGGING_FILE", "debug.log"), + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console", "file"], + "level": os.environ.get("LOGGING_LOGGERS_LEVEL", "WARNING"), + }, + "loggers": { + "django": { + "handlers": ["file"], + "level": os.environ.get("LOGGING_LOGGERS_DJANGO_LEVEL", "WARNING"), + "propagate": True, + }, + }, + "formatters": { + "verbose": { + "format": "{asctime} {levelname} - {name} {module}.py (line: {lineno:d}). - {message}", + "style": "{", + }, + }, + } diff --git a/app/app/urls.py b/app/app/urls.py new file mode 100644 index 00000000..afd11b25 --- /dev/null +++ b/app/app/urls.py @@ -0,0 +1,52 @@ +""" +URL configuration for app project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import include, path +from django.utils.translation import gettext_lazy as _ + +from . import views + +admin.site.index_title = _("Site administration") +admin.site.site_title = _("LwimiLinks administration ") + +urlpatterns = [ + path("admin/", admin.site.urls), + path("_health/", views.health, name="health"), + path("", views.home, name="home"), + path("contact/", views.contact, name="contact"), + path("legal_notices/", views.legal_notices, name="legal_notices"), + path("institutions/", views.institutions, name="institutions"), + path("documents/", views.documents, name="documents"), + path("projects/", views.projects, name="projects"), + path("projects//", views.project_detail, name="project_detail"), + path("institution//", views.institution_detail, name="institution_detail"), + path("documents//", views.document_detail, name="document_detail"), + path("languages/", views.languages, name="languages"), + path("subjects/", views.subjects, name="subjects"), + path("search/", views.search, name="search"), + path("i18n/", include("django.conf.urls.i18n")), + path("accounts/", include("accounts.urls")), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + if settings.DEBUG_TOOLBAR: + urlpatterns.append(path("__debug__/", include("debug_toolbar.urls"))) diff --git a/app/app/views.py b/app/app/views.py new file mode 100644 index 00000000..22d6aa71 --- /dev/null +++ b/app/app/views.py @@ -0,0 +1,406 @@ +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 +from django.utils.translation import gettext as _ + +from general.filters import DocumentFilter +from general.models import Document, Institution, Language, Project, Subject + + +def health(request): + """A very basic (minimal dependency) health endpoint.""" + # If we want/need a health check for DB, cache, files, etc. that should + # probably be separate. + return HttpResponse("OK", content_type="text/plain") + + +def home(request): + template = "app/home.html" + context = {"current_page": "home"} + + return render(request, template_name=template, context=context) + + +def contact(request): + template = "app/contact.html" + context = {"current_page": "contact"} + + return render(request, template_name=template, context=context) + + +def legal_notices(request): + template = "app/legal_notices.html" + context = {"current_page": "legal_notices"} + + return render(request, template_name=template, context=context) + + +def get_date_range(project): + start_date = project.start_date + end_date = project.end_date + + 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: + date = _("Until {year}").format(year=end_date.year) + else: + date = None + return date + + +def get_logo(project): + if project.logo: + logo = project.logo + elif project.institution.logo: + logo = project.institution.logo + else: + logo = None + return logo + + +def get_languages(languages): + if languages.count() < 4: + languages_data = ", ".join(sorted(language.name for language in languages)) + else: + languages_data = _("Multilingual") + return languages_data + + +def get_subjects(subjects): + if subjects.count() < 4: + subjects_data = ", ".join(sorted([subject.name for subject in subjects])) + else: + subjects_data = _("Multiple subjects") + return subjects_data + + +def projects(request): + template = "app/projects.html" + + projects = ( + Project.objects.select_related("institution") + .prefetch_related("subjects", "languages") + .order_by("name") + ) + + if subject_id := request.GET.get("subject"): + projects = projects.filter(subjects__id=subject_id) + if language_id := request.GET.get("language"): + projects = projects.filter(languages__id=language_id) + if institution_id := request.GET.get("institution"): + projects = projects.filter(institution__id=institution_id) + + subjects = Subject.objects.order_by("name") + languages = Language.objects.order_by("name") + institutions = Institution.objects.order_by("name") + + project_data = [] + for project in projects: + project_subjects = project.subjects.all() + project_languages = project.languages.all() + project_data.append( + { + "project": project, + "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, + } + ) + + context = { + "current_page": "projects", + "projects": project_data, + "subjects": subjects, + "languages": languages, + "institutions": institutions, + } + + return render(request, template_name=template, context=context) + + +def project_detail(request, project_id): + template = "app/project_detail.html" + + project = get_object_or_404( + 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, + ) + + context = { + "current_page": "project_detail", + "project": project, + "logo": get_logo(project), + "subjects": project.subjects.all(), + "languages": project.languages.all(), + } + return render(request, template_name=template, context=context) + + +def institution_detail(request, institution_id): + template = "app/institution_detail.html" + + institution = get_object_or_404(Institution, id=institution_id) + projects = Project.objects.filter(institution=institution).order_by("name") + documents = Document.objects.filter(institution=institution).order_by("title") + + context = { + "current_page": "institution_detail", + "institution": institution, + "projects": projects, + "documents": documents, + "logo": institution.logo, + } + + return render(request, template_name=template, context=context) + + +def documents(request): + template = "app/documents.html" + + documents = ( + Document.objects.select_related("institution") + .prefetch_related("subjects", "languages") + .order_by("title") + ) + + url_params = {} + if subject_id := request.GET.get("subject"): + documents = documents.filter(subjects__id=subject_id) + url_params["subject"] = subject_id + if language_id := request.GET.get("language"): + documents = documents.filter(languages__id=language_id) + url_params["language"] = language_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) + + subjects = Subject.objects.order_by("name") + languages = Language.objects.order_by("name") + institutions = Institution.objects.order_by("name") + + document_data = [] + for document in page_obj: + document_subjects = document.subjects.all() + document_languages = document.languages.all() + document_data.append( + { + "document": document, + "subjects": get_subjects(document_subjects), + "languages": get_languages(document_languages), + "institution_name": document.institution.name, + "description": document.description, + "url": document.url, + "category": document.document_type, + } + ) + + context = { + "current_page": "documents", + "page_obj": page_obj, + "documents": document_data, + "url_params": urlencode(url_params), + "subjects": subjects, + "languages": languages, + "institutions": institutions, + } + return render(request, template_name=template, context=context) + + +def document_detail(request, document_id): + template = "app/document_detail.html" + + document = get_object_or_404( + Document.objects.select_related("institution").prefetch_related( + Prefetch("subjects", queryset=Subject.objects.order_by("name")), + Prefetch("languages", queryset=Language.objects.order_by("name")), + ), + id=document_id, + ) + + context = { + "current_page": "document_detail", + "document": document, + } + return render(request, template_name=template, context=context) + + +def languages(request): + template = "app/languages.html" + + languages = ( + Language.objects.all() + .prefetch_related( + Prefetch( + "document_set", + queryset=Document.objects.only("id", "title", "languages").order_by("title"), + ), + Prefetch( + "project_set", + queryset=Project.objects.only("id", "name", "languages").order_by("name"), + ), + ) + .order_by("name") + ) + + language_data = [] + for language in languages: + documents = language.document_set.all() + projects = language.project_set.all() + language_data.append( + { + "language": language, + "documents": documents, + "projects": projects, + } + ) + + context = { + "current_page": "languages", + "language_data": language_data, + } + return render(request, template_name=template, context=context) + + +def subjects(request): + template = "app/subjects.html" + + subjects = ( + Subject.objects.all() + .prefetch_related( + Prefetch( + "document_set", + queryset=Document.objects.only("id", "title", "subjects").order_by("title"), + ), + Prefetch( + "project_set", + queryset=Project.objects.only("id", "name", "subjects").order_by("name"), + ), + ) + .order_by("name") + ) + + subject_data = [] + + for subject in subjects: + documents = subject.document_set.all() + projects = subject.project_set.all() + if documents or projects: + subject_data.append( + { + "subject": subject, + "documents": documents, + "projects": projects, + } + ) + + paginator = Paginator(subject_data, 10) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "current_page": "subjects", + "subject_data": page_obj.object_list, + "page_obj": page_obj, + } + return render(request, template_name=template, context=context) + + +def institutions(request): + template = "app/institutions.html" + context = {} + + # https://docs.djangoproject.com/en/stable/topics/db/aggregation/#combining-multiple-aggregations + subquery = ( + Document.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 = [] + + for institution in institutions: + institution_dict = { + "name": institution.name, + "abbreviation": institution.abbreviation, + "url": institution.url, + "email": institution.email, + "logo": institution.logo, + } + completed_fields_count = sum(1 for value in institution_dict.values() if value) + + """Rating returns % number of completed fields. + + Profile completion weighting is % number of completed fields in 5 star rating. + + Profile completion is calculated using the number of present model fields, + model fields have to be added to the institution_dict""" + + 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) + + context = {"current_page": "institutions", "institutions": institutions_array} + + return render(request, template_name=template, context=context) + + +def search(request): + f = DocumentFilter(request.GET, queryset=Document.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) + + 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 = { + "current_page": "search", + "filter": f, + "page_obj": page_obj, + "url_params": pagination_url(request), + } + + return render(request, template_name=template, context=context) + + +def pagination_url(request): + url_params = { + "search": request.GET.get("search", ""), + "document_type": request.GET.getlist("document_type", []), + "institution": request.GET.getlist("institution", []), + "subjects": request.GET.getlist("subjects", []), + "languages": request.GET.getlist("languages", []), + } + + return urlencode(url_params, doseq=True) diff --git a/app/app/wsgi.py b/app/app/wsgi.py new file mode 100644 index 00000000..1a4e28fe --- /dev/null +++ b/app/app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for app project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") + +application = get_wsgi_application() diff --git a/app/fixtures/institution.json b/app/fixtures/institution.json new file mode 100644 index 00000000..a20ce4b3 --- /dev/null +++ b/app/fixtures/institution.json @@ -0,0 +1,288 @@ +[ + { + "model": "general.Institution", + "pk": 1, + "fields": { + "name": "University of South Africa", + "abbreviation": "UNISA", + "url": "https://www.unisa.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 2, + "fields": { + "name": "North-West University", + "abbreviation": "NWU", + "url": "https://www.nwu.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 3, + "fields": { + "name": "University of Pretoria", + "abbreviation": "UP", + "url": "https://www.up.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 4, + "fields": { + "name": "Tshwane University of Technology", + "abbreviation": "TUT", + "url": "https://tut.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 5, + "fields": { + "name": "University of Johannesburg", + "abbreviation": "UJ", + "url": "https://www.uj.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 6, + "fields": { + "name": "University of KwaZulu-Natal", + "abbreviation": "UKZN", + "url": "https://www.ukzn.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 7, + "fields": { + "name": "University of the Free State", + "abbreviation": "UFS", + "url": "https://www.ufs.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 8, + "fields": { + "name": "Cape Peninsula University of Technology", + "abbreviation": "CPUT", + "url": "https://www.cput.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 9, + "fields": { + "name": "University of the Witwatersrand", + "abbreviation": "WITS", + "url": "https://www.wits.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 10, + "fields": { + "name": "University of Stellenbosch", + "abbreviation": "US", + "url": "http://www.sun.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 11, + "fields": { + "name": "University of Cape Town", + "abbreviation": "UCT", + "url": "https://uct.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 12, + "fields": { + "name": "Nelson Mandela Metropolitan University", + "abbreviation": "NMU", + "url": "https://www.mandela.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 13, + "fields": { + "name": "Walter Sisulu University", + "abbreviation": "WSU", + "url": "https://www.wsu.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 14, + "fields": { + "name": "Durban University of Technology", + "abbreviation": "DUT", + "url": "https://www.dut.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 15, + "fields": { + "name": "University of Limpopo", + "abbreviation": "UL", + "url": "https://www.ul.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 16, + "fields": { + "name": "Vaal University of Technology", + "abbreviation": "VUT", + "url": "https://www.vut.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 17, + "fields": { + "name": "University of Zululand", + "abbreviation": "UniZulu", + "url": "https://www.unizulu.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 18, + "fields": { + "name": "University of the Western Cape", + "abbreviation": "UWC", + "url": "https://www.uwc.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 19, + "fields": { + "name": "Central University of Technology", + "abbreviation": "CUT", + "url": "https://www.cut.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 20, + "fields": { + "name": "University of Fort Hare", + "abbreviation": "UFH", + "url": "https://www.ufh.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 21, + "fields": { + "name": "Rhodes University", + "abbreviation": "RU", + "url": "https://www.ru.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 22, + "fields": { + "name": "University of Venda", + "abbreviation": "Venda", + "url": "https://www.univen.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 23, + "fields": { + "name": "Mangosuthu University of Technology", + "abbreviation": "MUT", + "url": "https://www.mut.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 24, + "fields": { + "name": "Sefako Makgatho Health Sciences University", + "abbreviation": "FMHSU", + "url": "https://www.smu.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 25, + "fields": { + "name": "University of Mpumalanga", + "abbreviation": "UMP", + "url": "https://www.ump.ac.za/", + "email": "", + "logo": "" + } + }, + { + "model": "general.Institution", + "pk": 26, + "fields": { + "name": "Sol Plaatje University", + "abbreviation": "SPU", + "url": "https://www.spu.ac.za/", + "email": "", + "logo": "" + } + } +] diff --git a/app/fixtures/language.json b/app/fixtures/language.json new file mode 100644 index 00000000..bca08c22 --- /dev/null +++ b/app/fixtures/language.json @@ -0,0 +1,98 @@ +[ + { + "model": "general.Language", + "pk": 1, + "fields": { + "name": "Afrikaans", + "iso_code": "af" + } + }, + { + "model": "general.Language", + "pk": 2, + "fields": { + "name": "English", + "iso_code": "en" + } + }, + { + "model": "general.Language", + "pk": 3, + "fields": { + "name": "Ndebele", + "iso_code": "nr" + } + }, + { + "model": "general.Language", + "pk": 4, + "fields": { + "name": "Northern Sotho", + "iso_code": "nso" + } + }, + { + "model": "general.Language", + "pk": 5, + "fields": { + "name": "Sotho", + "iso_code": "st" + } + }, + { + "model": "general.Language", + "pk": 6, + "fields": { + "name": "South African Sign Language", + "iso_code": "sfs" + } + }, + { + "model": "general.Language", + "pk": 7, + "fields": { + "name": "Swati", + "iso_code": "ss" + } + }, + { + "model": "general.Language", + "pk": 8, + "fields": { + "name": "Tsonga", + "iso_code": "ts" + } + }, + { + "model": "general.Language", + "pk": 9, + "fields": { + "name": "Tswana", + "iso_code": "tn" + } + }, + { + "model": "general.Language", + "pk": 10, + "fields": { + "name": "Venda", + "iso_code": "ve" + } + }, + { + "model": "general.Language", + "pk": 11, + "fields": { + "name": "Xhosa", + "iso_code": "xh" + } + }, + { + "model": "general.Language", + "pk": 12, + "fields": { + "name": "Zulu", + "iso_code": "zu" + } + } +] diff --git a/app/fixtures/projects.json b/app/fixtures/projects.json new file mode 100644 index 00000000..7f1874e0 --- /dev/null +++ b/app/fixtures/projects.json @@ -0,0 +1,26 @@ +[ + { + "model": "general.Project", + "pk": 1, + "fields": { + "name": "Study Centre", + "url": "https://example.dev", + "logo": "", + "start_date": "2020-01-01", + "end_date": "2020-01-01", + "institution": 1 + } + }, + { + "model": "general.Project", + "pk": 2, + "fields": { + "name": "Research Centre", + "url": "https://example.dev", + "logo": "", + "start_date": "2020-01-01", + "end_date": "2020-01-01", + "institution": 2 + } + } +] diff --git a/app/fixtures/subjects.json b/app/fixtures/subjects.json new file mode 100644 index 00000000..ab46c7ca --- /dev/null +++ b/app/fixtures/subjects.json @@ -0,0 +1,1010 @@ +[ + { + "model": "general.Subject", + "pk": 1, + "fields": { + "name": "Accounting" + } + }, + { + "model": "general.Subject", + "pk": 2, + "fields": { + "name": "Agricultural management" + } + }, + { + "model": "general.Subject", + "pk": 3, + "fields": { + "name": "Agricultural science" + } + }, + { + "model": "general.Subject", + "pk": 4, + "fields": { + "name": "Agronomy" + } + }, + { + "model": "general.Subject", + "pk": 5, + "fields": { + "name": "Anaesthesiology" + } + }, + { + "model": "general.Subject", + "pk": 6, + "fields": { + "name": "Anatomy" + } + }, + { + "model": "general.Subject", + "pk": 7, + "fields": { + "name": "Ancient history" + } + }, + { + "model": "general.Subject", + "pk": 8, + "fields": { + "name": "Ancient Near Eastern studies" + } + }, + { + "model": "general.Subject", + "pk": 9, + "fields": { + "name": "Animal health" + } + }, + { + "model": "general.Subject", + "pk": 10, + "fields": { + "name": "Anthropology" + } + }, + { + "model": "general.Subject", + "pk": 11, + "fields": { + "name": "Applied information science" + } + }, + { + "model": "general.Subject", + "pk": 12, + "fields": { + "name": "Applied mathematics" + } + }, + { + "model": "general.Subject", + "pk": 13, + "fields": { + "name": "Archaeology" + } + }, + { + "model": "general.Subject", + "pk": 14, + "fields": { + "name": "Archives and records management" + } + }, + { + "model": "general.Subject", + "pk": 15, + "fields": { + "name": "Art history" + } + }, + { + "model": "general.Subject", + "pk": 16, + "fields": { + "name": "Astronomy" + } + }, + { + "model": "general.Subject", + "pk": 17, + "fields": { + "name": "Auditing" + } + }, + { + "model": "general.Subject", + "pk": 18, + "fields": { + "name": "Banking and finance" + } + }, + { + "model": "general.Subject", + "pk": 19, + "fields": { + "name": "Biblical archaeology" + } + }, + { + "model": "general.Subject", + "pk": 20, + "fields": { + "name": "Biochemistry" + } + }, + { + "model": "general.Subject", + "pk": 21, + "fields": { + "name": "Biology" + } + }, + { + "model": "general.Subject", + "pk": 22, + "fields": { + "name": "Biomedical science" + } + }, + { + "model": "general.Subject", + "pk": 23, + "fields": { + "name": "Biotechnology" + } + }, + { + "model": "general.Subject", + "pk": 24, + "fields": { + "name": "Botany" + } + }, + { + "model": "general.Subject", + "pk": 25, + "fields": { + "name": "Business management" + } + }, + { + "model": "general.Subject", + "pk": 26, + "fields": { + "name": "Chemical engineering" + } + }, + { + "model": "general.Subject", + "pk": 27, + "fields": { + "name": "Chemistry" + } + }, + { + "model": "general.Subject", + "pk": 28, + "fields": { + "name": "Christian leadership" + } + }, + { + "model": "general.Subject", + "pk": 29, + "fields": { + "name": "Christian spirituality" + } + }, + { + "model": "general.Subject", + "pk": 30, + "fields": { + "name": "Church history" + } + }, + { + "model": "general.Subject", + "pk": 31, + "fields": { + "name": "Civil engineering" + } + }, + { + "model": "general.Subject", + "pk": 32, + "fields": { + "name": "Classical culture" + } + }, + { + "model": "general.Subject", + "pk": 33, + "fields": { + "name": "Classical studies" + } + }, + { + "model": "general.Subject", + "pk": 34, + "fields": { + "name": "Communication science" + } + }, + { + "model": "general.Subject", + "pk": 35, + "fields": { + "name": "Communication studies" + } + }, + { + "model": "general.Subject", + "pk": 36, + "fields": { + "name": "Community health" + } + }, + { + "model": "general.Subject", + "pk": 37, + "fields": { + "name": "Composition studies" + } + }, + { + "model": "general.Subject", + "pk": 38, + "fields": { + "name": "Computer science" + } + }, + { + "model": "general.Subject", + "pk": 39, + "fields": { + "name": "Consumer science" + } + }, + { + "model": "general.Subject", + "pk": 40, + "fields": { + "name": "Corrections management" + } + }, + { + "model": "general.Subject", + "pk": 41, + "fields": { + "name": "Credit management" + } + }, + { + "model": "general.Subject", + "pk": 42, + "fields": { + "name": "Criminology" + } + }, + { + "model": "general.Subject", + "pk": 43, + "fields": { + "name": "Curriculum and instructional studies" + } + }, + { + "model": "general.Subject", + "pk": 44, + "fields": { + "name": "Decoloniality" + } + }, + { + "model": "general.Subject", + "pk": 45, + "fields": { + "name": "Development studies" + } + }, + { + "model": "general.Subject", + "pk": 46, + "fields": { + "name": "Early childhood education" + } + }, + { + "model": "general.Subject", + "pk": 47, + "fields": { + "name": "Economics" + } + }, + { + "model": "general.Subject", + "pk": 48, + "fields": { + "name": "Educational foundations" + } + }, + { + "model": "general.Subject", + "pk": 49, + "fields": { + "name": "Educational leadership and managment" + } + }, + { + "model": "general.Subject", + "pk": 50, + "fields": { + "name": "Electrical engineering" + } + }, + { + "model": "general.Subject", + "pk": 51, + "fields": { + "name": "Entrepreneurship" + } + }, + { + "model": "general.Subject", + "pk": 52, + "fields": { + "name": "Environmental sciences" + } + }, + { + "model": "general.Subject", + "pk": 53, + "fields": { + "name": "Epidemiology" + } + }, + { + "model": "general.Subject", + "pk": 54, + "fields": { + "name": "Financial accounting" + } + }, + { + "model": "general.Subject", + "pk": 55, + "fields": { + "name": "Financial governance" + } + }, + { + "model": "general.Subject", + "pk": 56, + "fields": { + "name": "Financial intelligence" + } + }, + { + "model": "general.Subject", + "pk": 57, + "fields": { + "name": "Food service management" + } + }, + { + "model": "general.Subject", + "pk": 58, + "fields": { + "name": "Forensic auditing" + } + }, + { + "model": "general.Subject", + "pk": 59, + "fields": { + "name": "Forensic investigation" + } + }, + { + "model": "general.Subject", + "pk": 60, + "fields": { + "name": "Gender studies" + } + }, + { + "model": "general.Subject", + "pk": 61, + "fields": { + "name": "Genetics" + } + }, + { + "model": "general.Subject", + "pk": 62, + "fields": { + "name": "Geography" + } + }, + { + "model": "general.Subject", + "pk": 63, + "fields": { + "name": "Health sciences education" + } + }, + { + "model": "general.Subject", + "pk": 64, + "fields": { + "name": "Health services management" + } + }, + { + "model": "general.Subject", + "pk": 65, + "fields": { + "name": "Health studies" + } + }, + { + "model": "general.Subject", + "pk": 66, + "fields": { + "name": "History" + } + }, + { + "model": "general.Subject", + "pk": 67, + "fields": { + "name": "Horticulture" + } + }, + { + "model": "general.Subject", + "pk": 68, + "fields": { + "name": "Hospitality management" + } + }, + { + "model": "general.Subject", + "pk": 69, + "fields": { + "name": "Human resource management" + } + }, + { + "model": "general.Subject", + "pk": 70, + "fields": { + "name": "Inclusive education" + } + }, + { + "model": "general.Subject", + "pk": 71, + "fields": { + "name": "Industrial and organisational psychology" + } + }, + { + "model": "general.Subject", + "pk": 72, + "fields": { + "name": "Information resource management" + } + }, + { + "model": "general.Subject", + "pk": 73, + "fields": { + "name": "Information science" + } + }, + { + "model": "general.Subject", + "pk": 74, + "fields": { + "name": "Information systems" + } + }, + { + "model": "general.Subject", + "pk": 75, + "fields": { + "name": "Information technology" + } + }, + { + "model": "general.Subject", + "pk": 76, + "fields": { + "name": "Insurance" + } + }, + { + "model": "general.Subject", + "pk": 77, + "fields": { + "name": "Internal auditing" + } + }, + { + "model": "general.Subject", + "pk": 78, + "fields": { + "name": "International politics" + } + }, + { + "model": "general.Subject", + "pk": 79, + "fields": { + "name": "Investment" + } + }, + { + "model": "general.Subject", + "pk": 80, + "fields": { + "name": "Islamic studies" + } + }, + { + "model": "general.Subject", + "pk": 81, + "fields": { + "name": "Language education" + } + }, + { + "model": "general.Subject", + "pk": 82, + "fields": { + "name": "Law" + } + }, + { + "model": "general.Subject", + "pk": 83, + "fields": { + "name": "Life and consumer sciences" + } + }, + { + "model": "general.Subject", + "pk": 84, + "fields": { + "name": "Linguistics" + } + }, + { + "model": "general.Subject", + "pk": 85, + "fields": { + "name": "Management accounting" + } + }, + { + "model": "general.Subject", + "pk": 86, + "fields": { + "name": "Marketing and retail management" + } + }, + { + "model": "general.Subject", + "pk": 87, + "fields": { + "name": "Mathematics" + } + }, + { + "model": "general.Subject", + "pk": 88, + "fields": { + "name": "Mathematics education" + } + }, + { + "model": "general.Subject", + "pk": 89, + "fields": { + "name": "Mechanical and industrial engineering" + } + }, + { + "model": "general.Subject", + "pk": 90, + "fields": { + "name": "Microbiology" + } + }, + { + "model": "general.Subject", + "pk": 91, + "fields": { + "name": "Midwifery" + } + }, + { + "model": "general.Subject", + "pk": 92, + "fields": { + "name": "Mine engineering" + } + }, + { + "model": "general.Subject", + "pk": 93, + "fields": { + "name": "Ministry" + } + }, + { + "model": "general.Subject", + "pk": 94, + "fields": { + "name": "Missiology" + } + }, + { + "model": "general.Subject", + "pk": 95, + "fields": { + "name": "Music" + } + }, + { + "model": "general.Subject", + "pk": 96, + "fields": { + "name": "Nature conservation" + } + }, + { + "model": "general.Subject", + "pk": 97, + "fields": { + "name": "Neurology" + } + }, + { + "model": "general.Subject", + "pk": 98, + "fields": { + "name": "New Testament" + } + }, + { + "model": "general.Subject", + "pk": 99, + "fields": { + "name": "Nursing" + } + }, + { + "model": "general.Subject", + "pk": 100, + "fields": { + "name": "Nutrition" + } + }, + { + "model": "general.Subject", + "pk": 101, + "fields": { + "name": "Obstetrics" + } + }, + { + "model": "general.Subject", + "pk": 102, + "fields": { + "name": "Occupational therapy" + } + }, + { + "model": "general.Subject", + "pk": 103, + "fields": { + "name": "Oenology" + } + }, + { + "model": "general.Subject", + "pk": 104, + "fields": { + "name": "Old Testament" + } + }, + { + "model": "general.Subject", + "pk": 105, + "fields": { + "name": "Operations management" + } + }, + { + "model": "general.Subject", + "pk": 106, + "fields": { + "name": "Orthodontics" + } + }, + { + "model": "general.Subject", + "pk": 107, + "fields": { + "name": "Paediatrics" + } + }, + { + "model": "general.Subject", + "pk": 108, + "fields": { + "name": "Pathology" + } + }, + { + "model": "general.Subject", + "pk": 109, + "fields": { + "name": "Pharmacology" + } + }, + { + "model": "general.Subject", + "pk": 110, + "fields": { + "name": "Philosophy" + } + }, + { + "model": "general.Subject", + "pk": 111, + "fields": { + "name": "Physics" + } + }, + { + "model": "general.Subject", + "pk": 112, + "fields": { + "name": "Physiology" + } + }, + { + "model": "general.Subject", + "pk": 113, + "fields": { + "name": "Policing" + } + }, + { + "model": "general.Subject", + "pk": 114, + "fields": { + "name": "Politics" + } + }, + { + "model": "general.Subject", + "pk": 115, + "fields": { + "name": "Practical theology" + } + }, + { + "model": "general.Subject", + "pk": 116, + "fields": { + "name": "Private law" + } + }, + { + "model": "general.Subject", + "pk": 117, + "fields": { + "name": "Psychology" + } + }, + { + "model": "general.Subject", + "pk": 118, + "fields": { + "name": "Psychiatry" + } + }, + { + "model": "general.Subject", + "pk": 119, + "fields": { + "name": "Public administration" + } + }, + { + "model": "general.Subject", + "pk": 120, + "fields": { + "name": "Public health" + } + }, + { + "model": "general.Subject", + "pk": 121, + "fields": { + "name": "Public relations" + } + }, + { + "model": "general.Subject", + "pk": 122, + "fields": { + "name": "Radiology" + } + }, + { + "model": "general.Subject", + "pk": 123, + "fields": { + "name": "Religious studies" + } + }, + { + "model": "general.Subject", + "pk": 124, + "fields": { + "name": "Risk management" + } + }, + { + "model": "general.Subject", + "pk": 125, + "fields": { + "name": "Safety management" + } + }, + { + "model": "general.Subject", + "pk": 126, + "fields": { + "name": "Science and technology education" + } + }, + { + "model": "general.Subject", + "pk": 127, + "fields": { + "name": "Security management" + } + }, + { + "model": "general.Subject", + "pk": 128, + "fields": { + "name": "Social sciences" + } + }, + { + "model": "general.Subject", + "pk": 129, + "fields": { + "name": "Social work" + } + }, + { + "model": "general.Subject", + "pk": 130, + "fields": { + "name": "Sociology" + } + }, + { + "model": "general.Subject", + "pk": 131, + "fields": { + "name": "Statistics" + } + }, + { + "model": "general.Subject", + "pk": 132, + "fields": { + "name": "Supply chain management" + } + }, + { + "model": "general.Subject", + "pk": 133, + "fields": { + "name": "Systematic theology" + } + }, + { + "model": "general.Subject", + "pk": 134, + "fields": { + "name": "Taxation" + } + }, + { + "model": "general.Subject", + "pk": 135, + "fields": { + "name": "Theological ethics" + } + }, + { + "model": "general.Subject", + "pk": 136, + "fields": { + "name": "Theology" + } + }, + { + "model": "general.Subject", + "pk": 137, + "fields": { + "name": "Theory of literature" + } + }, + { + "model": "general.Subject", + "pk": 138, + "fields": { + "name": "Tourism management" + } + }, + { + "model": "general.Subject", + "pk": 139, + "fields": { + "name": "Transport economics and logistics" + } + }, + { + "model": "general.Subject", + "pk": 140, + "fields": { + "name": "Urology" + } + }, + { + "model": "general.Subject", + "pk": 141, + "fields": { + "name": "Veterinary medicine" + } + }, + { + "model": "general.Subject", + "pk": 142, + "fields": { + "name": "Viticulture" + } + }, + { + "model": "general.Subject", + "pk": 143, + "fields": { + "name": "Visual arts" + } + }, + { + "model": "general.Subject", + "pk": 144, + "fields": { + "name": "Zoology" + } + } +] diff --git a/app/general/__init__.py b/app/general/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/general/admin.py b/app/general/admin.py new file mode 100644 index 00000000..ba878a8f --- /dev/null +++ b/app/general/admin.py @@ -0,0 +1,105 @@ +import magic +from django.contrib import admin +from django.forms import HiddenInput, ModelForm +from django.utils.translation import gettext as _ +from simple_history.admin import SimpleHistoryAdmin + +from general.service.extract_text import GetTextError, pdf_to_text + +from .models import Document, Institution, Language, Project, Subject + + +class DocumentForm(ModelForm): + class Meta: + model = Document + fields = "__all__" # noqa: DJ007 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["document_data"].widget = HiddenInput() + + # If the instance has a mime_type, the field should be disabled + if not self.instance.mime_type: + self.fields["mime_type"].widget = HiddenInput() + else: + self.fields["mime_type"].widget.attrs["disabled"] = True + + def clean(self): + cleaned_data = super().clean() + url = cleaned_data.get("url", "") + uploaded_file = cleaned_data.get("uploaded_file", "") + + if uploaded_file: + file_type = magic.from_buffer(uploaded_file.read(), mime=True) + if file_type != "application/pdf": + self.add_error("uploaded_file", _("Only PDF files are allowed.")) + cleaned_data["mime_type"] = file_type + + limit = 10 * 1024 * 1024 + if uploaded_file.size and uploaded_file.size > limit: + self.add_error("uploaded_file", _("File size must not exceed 10MB.")) + if not self.has_error("uploaded_file"): + # Don't parse if validation above failed + try: + cleaned_data["document_data"] = pdf_to_text(uploaded_file) + except GetTextError: + return self.add_error( + "uploaded_file", + _("The uploaded file is corrupted or not fully downloaded."), + ) + uploaded_file.seek(0) # Reset file pointer after read + + if not url and not uploaded_file: + self.add_error("url", _("Either URL or uploaded file must be provided.")) + self.add_error("uploaded_file", _("Either URL or uploaded file must be provided.")) + + return cleaned_data + + +class DocumentAdmin(SimpleHistoryAdmin): + ordering = ["title"] + list_display = ["title", "license", "document_type", "available"] + search_fields = ["title"] + list_filter = ["institution", "license", "document_type"] + form = DocumentForm + history_list_display = ["title", "license", "document_type", "available"] + + +class SubjectAdmin(SimpleHistoryAdmin): + ordering = ["name"] + search_fields = ["name"] + list_display = ["name"] + history_list_display = ["name"] + + +class LanguageAdmin(SimpleHistoryAdmin): + ordering = ["name"] + history_list_display = ["name", "iso_code"] + list_display = ["name", "iso_code"] + + +class ProjectAdminInline(admin.TabularInline): + model = Project + extra = 0 + + +class ProjectAdmin(SimpleHistoryAdmin): + ordering = ["name"] + search_fields = ["name"] + list_display = ["name"] + history_list_display = ["name"] + + +class InstitutionAdmin(SimpleHistoryAdmin): + ordering = ["name"] + search_fields = ["name"] + list_display = ["name"] + history_list_display = ["name", "abbreviation"] + + +admin.site.register(Project, ProjectAdmin) +admin.site.register(Institution, InstitutionAdmin) +admin.site.register(Language, LanguageAdmin) +admin.site.register(Subject, SubjectAdmin) +admin.site.register(Document, DocumentAdmin) diff --git a/app/general/apps.py b/app/general/apps.py new file mode 100644 index 00000000..a4212cb5 --- /dev/null +++ b/app/general/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class GeneralConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "general" + verbose_name = _("General") 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 new file mode 100644 index 00000000..0eaa4882 --- /dev/null +++ b/app/general/filters.py @@ -0,0 +1,165 @@ +import django_filters +from django import forms +from django.contrib.postgres.search import ( + SearchHeadline, + SearchQuery, + SearchRank, + SearchVector, +) +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 Document, Institution, Language, Project, Subject + + +class DocumentFilter(django_filters.FilterSet): + search = django_filters.CharFilter(method="ignore", label=_("Search")) + + institution = ModelMultipleChoiceFilter( + label=_("Institution"), + queryset=Institution.objects.all().order_by("name"), + widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check-input"}), + ) + subjects = ModelMultipleChoiceFilter( + label=_("Subjects"), + queryset=Subject.objects.all().order_by("name"), + widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check-input"}), + ) + languages = ModelMultipleChoiceFilter( + label=_("Languages"), + queryset=Language.objects.all().order_by("name"), + widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check-input"}), + ) + + class Meta: + model = Document + fields = [ + "institution", + "subjects", + "languages", + ] + + def filter_queryset(self, queryset): + # More information about weighting and normalization in postgres: + # https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-RANKING + + search = self.form.cleaned_data.get("search", "").strip() + query = SearchQuery(search) + + # A fixed list of identical fields are required to join queries of + # 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", + "extra", + "rank", + "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"), + boost=Value(0.05), + rank=F("boost"), + ) + + queryset = super().filter_queryset(queryset) + project_query = super().filter_queryset(project_query) + + # We limit the headline to limit the performance impact. On very large + # documents, this slows things down if unconstrained. + search_headline = SearchHeadline( + Left("document_data", 20_000), query, max_words=15, min_words=10 + ) + queryset = queryset.annotate( + heading=F("title"), + extra=F("description"), + view=Value("document_detail"), + logo_url=Value(""), + associated_url=F("url"), + boost=Value(0.01), + rank=F("boost"), + ) + if search: + # 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) + 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()` + return queryset diff --git a/app/general/management/__init__.py b/app/general/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/general/management/commands/__init__.py b/app/general/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/general/management/commands/dev_update_vector_search.py b/app/general/management/commands/dev_update_vector_search.py new file mode 100644 index 00000000..9b1628c8 --- /dev/null +++ b/app/general/management/commands/dev_update_vector_search.py @@ -0,0 +1,12 @@ +from django.core.management.base import BaseCommand + +from general.models import Document + + +class Command(BaseCommand): + help = "Updating the Vector Search index on document_file." + + def handle(self, *args, **options): + for document_file in Document.objects.all(): + document_file.save() # This line updates the vector search for the document file + print(f"Updated {document_file.title}.") diff --git a/app/general/management/commands/import_documents.py b/app/general/management/commands/import_documents.py new file mode 100644 index 00000000..f116167d --- /dev/null +++ b/app/general/management/commands/import_documents.py @@ -0,0 +1,54 @@ +# TODO: +# - Provide better command-line parameters for control, e.g. +# - import for given institution +# - associate with specific language(s)/subject(s) +# - make usable outside Docker + +import os +import random + +import magic +from django.core.files.base import ContentFile +from django.core.management.base import BaseCommand + +from general.models import Document +from general.service.extract_text import GetTextError, pdf_to_text + + +class Command(BaseCommand): + help = "Mass PDF uploader for testing purposes" + + def add_arguments(self, parser): + parser.add_argument("directory", help="Directory with files to import") + + def handle(self, *args, **options): + for root, dirs, files in os.walk(options["directory"]): + for file in files: + if not os.path.splitext(file)[1] == ".pdf": + continue + file_path = os.path.join(root, file) + self.handle_file(file_path, file) + + def handle_file(self, file_path, file_name): + print(file_name) + file_type = magic.from_file(file_path, mime=True) + if file_type == "application/pdf": + self.save_data(file_path, file_name) + else: + print("Only PDF files are allowed") + + def save_data(self, file_path, file_name): + with open(file_path, "rb") as f: + content_file = ContentFile(f.read(), name=file_name) + + try: + instance = Document( + title=file_name, + document_data=pdf_to_text(file_path), + uploaded_file=content_file, + document_type="Glossary", + institution_id=random.randint(1, 20), + ) + instance.save() + except GetTextError as e: + print(f"Error: {e}") diff --git a/app/general/middleware.py b/app/general/middleware.py new file mode 100644 index 00000000..589ed26e --- /dev/null +++ b/app/general/middleware.py @@ -0,0 +1,17 @@ +from django.utils.cache import patch_vary_headers + + +class ExtraVaryMiddleware: + """Ensure HTML pages vary on HX-Request + + This is needed so that incomplete responses based on base_htmx.html are not + reused as full-page responses, for example on browser restore of a page.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if "text/html" in response.headers["Content-Type"]: + patch_vary_headers(response, ["HX-Request"]) + return response diff --git a/app/general/migrations/00010_documentfile_search_vector_trigger.py b/app/general/migrations/00010_documentfile_search_vector_trigger.py new file mode 100644 index 00000000..d955104c --- /dev/null +++ b/app/general/migrations/00010_documentfile_search_vector_trigger.py @@ -0,0 +1,35 @@ +from django.contrib.postgres.search import SearchVector +from django.db import migrations + + +def compute_search_vector(apps, schema_editor): + Quote = apps.get_model("general", "DocumentFile") + Quote.objects.update(search_vector=SearchVector("document_data", "title")) + + +class Migration(migrations.Migration): + dependencies = [ + ("general", "0009_documentfile_search_vector_and_more"), + ] + + operations = [ + migrations.RunSQL( + sql=""" + CREATE TRIGGER search_vector_trigger + BEFORE INSERT OR UPDATE OF document_data, title, search_vector + ON general_documentfile + FOR EACH ROW EXECUTE PROCEDURE + tsvector_update_trigger( + search_vector, 'pg_catalog.english', document_data, title + ); + UPDATE general_documentfile SET search_vector = NULL; + """, + reverse_sql=""" + DROP TRIGGER IF EXISTS search_vector_trigger + ON general_documentfile; + """, + ), + migrations.RunPython( + compute_search_vector, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/app/general/migrations/0001_initial.py b/app/general/migrations/0001_initial.py new file mode 100644 index 00000000..bb69dc54 --- /dev/null +++ b/app/general/migrations/0001_initial.py @@ -0,0 +1,53 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Institution', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, unique=True)), + ('abbreviation', models.CharField(max_length=200)), + ('url', models.URLField(blank=True)), + ('email', models.EmailField(blank=True, max_length=200)), + ('logo', models.FileField(blank=True, upload_to='logos/')), + ], + ), + migrations.CreateModel( + name='Language', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, unique=True)), + ('iso_code', models.CharField(help_text='Enter the ISO code for the language', max_length=50, unique=True)), + ], + ), + migrations.CreateModel( + name='Subject', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, unique=True)), + ], + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('url', models.URLField()), + ('logo', models.FileField(blank=True, upload_to='logos/')), + ('start_date', models.DateField(blank=True, null=True)), + ('end_date', models.DateField(blank=True, null=True)), + ('institution', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='general.institution', verbose_name='institution')), + ('languages', models.ManyToManyField(blank=True, to='general.language')), + ('subjects', models.ManyToManyField(blank=True, to='general.subject')), + ], + ), + ] diff --git a/app/general/migrations/0002_documentfile.py b/app/general/migrations/0002_documentfile.py new file mode 100644 index 00000000..ca831d4b --- /dev/null +++ b/app/general/migrations/0002_documentfile.py @@ -0,0 +1,29 @@ +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DocumentFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('url', models.URLField(blank=True)), + ('uploaded_file', models.FileField(blank=True, help_text='Only PDF files are allowed.', upload_to='documents/', validators=[django.core.validators.FileExtensionValidator(['pdf'])])), + ('available', models.BooleanField(default=True)), + ('license', models.CharField(choices=[('MIT', 'MIT'), ('GNU', 'GNU'), ('Apache', 'Apache')], max_length=200)), + ('mime_type', models.CharField(blank=True, help_text='This input will auto-populate.', max_length=200)), + ('document_type', models.CharField(choices=[('Glossary', 'Glossary'), ('Translation', 'Translation')], max_length=200)), + ('Institution', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='general.institution')), + ('languages', models.ManyToManyField(blank=True, to='general.language')), + ('subjects', models.ManyToManyField(blank=True, to='general.subject')), + ], + ), + ] diff --git a/app/general/migrations/0003_rename_institution_documentfile_institution_and_more.py b/app/general/migrations/0003_rename_institution_documentfile_institution_and_more.py new file mode 100644 index 00000000..4403260e --- /dev/null +++ b/app/general/migrations/0003_rename_institution_documentfile_institution_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 5.0.2 on 2024-04-19 09:28 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0002_documentfile'), + ] + + operations = [ + migrations.RenameField( + model_name='documentfile', + old_name='Institution', + new_name='institution', + ), + migrations.AlterField( + model_name='documentfile', + name='document_type', + field=models.CharField(choices=[('Glossary', 'Glossary'), ('Policy', 'Policy')], max_length=200), + ), + migrations.AlterField( + model_name='documentfile', + name='license', + field=models.CharField(choices=[('(c)', 'All rights reserved'), ('CC0', 'No rights reserved'), ('CC BY', 'Creative Commons Attribution'), ('CC BY-SA', 'Creative Commons Attribution-ShareAlike'), ('CC BY-NC', 'Creative Commons Attribution-NonCommercial'), ('CC BY-NC-SA', 'Creative Commons Attribution-NonCommercial-ShareAlike')], default='(c)', help_text='\n \n More information about Creative Commons licenses.\n \'\n ', max_length=200), + ), + migrations.AlterField( + model_name='documentfile', + name='uploaded_file', + field=models.FileField(blank=True, help_text='PDF files up to 10MB are allowed.', upload_to='documents/', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), + ), + migrations.AlterField( + model_name='documentfile', + name='url', + field=models.URLField(blank=True, verbose_name='URL'), + ), + migrations.AlterField( + model_name='institution', + name='url', + field=models.URLField(blank=True, verbose_name='URL'), + ), + migrations.AlterField( + model_name='language', + name='iso_code', + field=models.CharField(help_text='The 2 or 3 letter code from ISO 639.', max_length=50, unique=True, verbose_name='ISO code'), + ), + migrations.AlterField( + model_name='project', + name='url', + field=models.URLField(blank=True, verbose_name='URL'), + ), + ] diff --git a/app/general/migrations/0004_historicaldocumentfile_historicalinstitution_and_more.py b/app/general/migrations/0004_historicaldocumentfile_historicalinstitution_and_more.py new file mode 100644 index 00000000..a30c129c --- /dev/null +++ b/app/general/migrations/0004_historicaldocumentfile_historicalinstitution_and_more.py @@ -0,0 +1,128 @@ +import django.core.validators +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0003_rename_institution_documentfile_institution_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalDocumentFile', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('url', models.URLField(blank=True, verbose_name='URL')), + ('uploaded_file', models.TextField(blank=True, help_text='PDF files up to 10MB are allowed.', max_length=100, validators=[django.core.validators.FileExtensionValidator(['pdf'])])), + ('available', models.BooleanField(default=True)), + ('license', models.CharField(choices=[('(c)', 'All rights reserved'), ('CC0', 'No rights reserved'), ('CC BY', 'Creative Commons Attribution'), ('CC BY-SA', 'Creative Commons Attribution-ShareAlike'), ('CC BY-NC', 'Creative Commons Attribution-NonCommercial'), ('CC BY-NC-SA', 'Creative Commons Attribution-NonCommercial-ShareAlike')], default='(c)', help_text='\n \n More information about Creative Commons licenses.\n \'\n ', max_length=200)), + ('mime_type', models.CharField(blank=True, help_text='This input will auto-populate.', max_length=200)), + ('document_type', models.CharField(choices=[('Glossary', 'Glossary'), ('Policy', 'Policy')], max_length=200)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('institution', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='general.institution')), + ], + options={ + 'verbose_name': 'historical document file', + 'verbose_name_plural': 'historical document files', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalInstitution', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=200)), + ('abbreviation', models.CharField(max_length=200)), + ('url', models.URLField(blank=True, verbose_name='URL')), + ('email', models.EmailField(blank=True, max_length=200)), + ('logo', models.TextField(blank=True, max_length=100)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical institution', + 'verbose_name_plural': 'historical institutions', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalLanguage', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=150)), + ('iso_code', models.CharField(db_index=True, help_text='The 2 or 3 letter code from ISO 639.', max_length=50, verbose_name='ISO code')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical language', + 'verbose_name_plural': 'historical languages', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalProject', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('url', models.URLField(blank=True, verbose_name='URL')), + ('logo', models.TextField(blank=True, max_length=100)), + ('start_date', models.DateField(blank=True, null=True)), + ('end_date', models.DateField(blank=True, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('institution', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='general.institution', verbose_name='institution')), + ], + options={ + 'verbose_name': 'historical project', + 'verbose_name_plural': 'historical projects', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalSubject', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=150)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical subject', + 'verbose_name_plural': 'historical subjects', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/app/general/migrations/0005_alter_institution_logo_alter_project_logo.py b/app/general/migrations/0005_alter_institution_logo_alter_project_logo.py new file mode 100644 index 00000000..aa039c46 --- /dev/null +++ b/app/general/migrations/0005_alter_institution_logo_alter_project_logo.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2024-05-10 08:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0004_historicaldocumentfile_historicalinstitution_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='institution', + name='logo', + field=models.ImageField(blank=True, upload_to='institutions/logos/'), + ), + migrations.AlterField( + model_name='project', + name='logo', + field=models.ImageField(blank=True, upload_to='projects/logos/'), + ), + ] diff --git a/app/general/migrations/0006_documentfile_document_data.py b/app/general/migrations/0006_documentfile_document_data.py new file mode 100644 index 00000000..8e6e3296 --- /dev/null +++ b/app/general/migrations/0006_documentfile_document_data.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-05-23 12:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0005_alter_institution_logo_alter_project_logo'), + ] + + operations = [ + migrations.AddField( + model_name='documentfile', + name='document_data', + field=models.TextField(blank=True), + ), + ] diff --git a/app/general/migrations/0007_alter_documentfile_options_and_more.py b/app/general/migrations/0007_alter_documentfile_options_and_more.py new file mode 100644 index 00000000..b178a9bd --- /dev/null +++ b/app/general/migrations/0007_alter_documentfile_options_and_more.py @@ -0,0 +1,250 @@ +# Generated by Django 5.0.2 on 2024-05-27 10:56 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0006_documentfile_document_data'), + ] + + operations = [ + migrations.AlterModelOptions( + name='documentfile', + options={'verbose_name': 'Document File', 'verbose_name_plural': 'Document Files'}, + ), + migrations.AlterModelOptions( + name='historicaldocumentfile', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Document File', 'verbose_name_plural': 'historical Document Files'}, + ), + migrations.AlterModelOptions( + name='historicalinstitution', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Institution', 'verbose_name_plural': 'historical Institutions'}, + ), + migrations.AlterModelOptions( + name='historicallanguage', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Language', 'verbose_name_plural': 'historical Languages'}, + ), + migrations.AlterModelOptions( + name='historicalproject', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Project', 'verbose_name_plural': 'historical Projects'}, + ), + migrations.AlterModelOptions( + name='historicalsubject', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Subject', 'verbose_name_plural': 'historical Subjects'}, + ), + migrations.AlterModelOptions( + name='institution', + options={'verbose_name': 'Institution', 'verbose_name_plural': 'Institutions'}, + ), + migrations.AlterModelOptions( + name='language', + options={'verbose_name': 'Language', 'verbose_name_plural': 'Languages'}, + ), + migrations.AlterModelOptions( + name='project', + options={'verbose_name': 'Project', 'verbose_name_plural': 'Projects'}, + ), + migrations.AlterModelOptions( + name='subject', + options={'verbose_name': 'Subject', 'verbose_name_plural': 'Subjects'}, + ), + migrations.AlterField( + model_name='documentfile', + name='available', + field=models.BooleanField(default=True, verbose_name='available'), + ), + migrations.AlterField( + model_name='documentfile', + name='document_data', + field=models.TextField(blank=True, verbose_name='document data'), + ), + migrations.AlterField( + model_name='documentfile', + name='document_type', + field=models.CharField(choices=[('Glossary', 'Glossary'), ('Policy', 'Policy')], max_length=200, verbose_name='document type'), + ), + migrations.AlterField( + model_name='documentfile', + name='institution', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='general.institution', verbose_name='institution'), + ), + migrations.AlterField( + model_name='documentfile', + name='languages', + field=models.ManyToManyField(blank=True, to='general.language', verbose_name='languages'), + ), + migrations.AlterField( + model_name='documentfile', + name='license', + field=models.CharField(choices=[('(c)', 'All rights reserved'), ('CC0', 'No rights reserved'), ('CC BY', 'Creative Commons Attribution'), ('CC BY-SA', 'Creative Commons Attribution-ShareAlike'), ('CC BY-NC', 'Creative Commons Attribution-NonCommercial'), ('CC BY-NC-SA', 'Creative Commons Attribution-NonCommercial-ShareAlike')], default='(c)', help_text='\n More information about Creative Commons licenses.\n ', max_length=200, verbose_name='license'), + ), + migrations.AlterField( + model_name='documentfile', + name='mime_type', + field=models.CharField(blank=True, help_text='This input will auto-populate.', max_length=200, verbose_name='MIME type'), + ), + migrations.AlterField( + model_name='documentfile', + name='subjects', + field=models.ManyToManyField(blank=True, to='general.subject', verbose_name='subjects'), + ), + migrations.AlterField( + model_name='documentfile', + name='title', + field=models.CharField(max_length=200, verbose_name='title'), + ), + migrations.AlterField( + model_name='documentfile', + name='uploaded_file', + field=models.FileField(blank=True, help_text='PDF files up to 10MB are allowed.', upload_to='documents/', validators=[django.core.validators.FileExtensionValidator(['pdf'])], verbose_name='uploaded file'), + ), + migrations.AlterField( + model_name='historicaldocumentfile', + name='available', + field=models.BooleanField(default=True, verbose_name='available'), + ), + migrations.AlterField( + model_name='historicaldocumentfile', + name='document_type', + field=models.CharField(choices=[('Glossary', 'Glossary'), ('Policy', 'Policy')], max_length=200, verbose_name='document type'), + ), + migrations.AlterField( + model_name='historicaldocumentfile', + name='institution', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='general.institution', verbose_name='institution'), + ), + migrations.AlterField( + model_name='historicaldocumentfile', + name='license', + field=models.CharField(choices=[('(c)', 'All rights reserved'), ('CC0', 'No rights reserved'), ('CC BY', 'Creative Commons Attribution'), ('CC BY-SA', 'Creative Commons Attribution-ShareAlike'), ('CC BY-NC', 'Creative Commons Attribution-NonCommercial'), ('CC BY-NC-SA', 'Creative Commons Attribution-NonCommercial-ShareAlike')], default='(c)', help_text='\n More information about Creative Commons licenses.\n ', max_length=200, verbose_name='license'), + ), + migrations.AlterField( + model_name='historicaldocumentfile', + name='mime_type', + field=models.CharField(blank=True, help_text='This input will auto-populate.', max_length=200, verbose_name='MIME type'), + ), + migrations.AlterField( + model_name='historicaldocumentfile', + name='title', + field=models.CharField(max_length=200, verbose_name='title'), + ), + migrations.AlterField( + model_name='historicaldocumentfile', + name='uploaded_file', + field=models.TextField(blank=True, help_text='PDF files up to 10MB are allowed.', max_length=100, validators=[django.core.validators.FileExtensionValidator(['pdf'])], verbose_name='uploaded file'), + ), + migrations.AlterField( + model_name='historicalinstitution', + name='abbreviation', + field=models.CharField(max_length=200, verbose_name='abbreviation'), + ), + migrations.AlterField( + model_name='historicalinstitution', + name='email', + field=models.EmailField(blank=True, max_length=200, verbose_name='email'), + ), + migrations.AlterField( + model_name='historicalinstitution', + name='logo', + field=models.TextField(blank=True, max_length=100, verbose_name='logo'), + ), + migrations.AlterField( + model_name='historicalinstitution', + name='name', + field=models.CharField(db_index=True, max_length=200, verbose_name='name'), + ), + migrations.AlterField( + model_name='historicallanguage', + name='name', + field=models.CharField(db_index=True, max_length=150, verbose_name='name'), + ), + migrations.AlterField( + model_name='historicalproject', + name='end_date', + field=models.DateField(blank=True, null=True, verbose_name='end date'), + ), + migrations.AlterField( + model_name='historicalproject', + name='logo', + field=models.TextField(blank=True, max_length=100, verbose_name='logo'), + ), + migrations.AlterField( + model_name='historicalproject', + name='name', + field=models.CharField(max_length=200, verbose_name='name'), + ), + migrations.AlterField( + model_name='historicalproject', + name='start_date', + field=models.DateField(blank=True, null=True, verbose_name='start date'), + ), + migrations.AlterField( + model_name='historicalsubject', + name='name', + field=models.CharField(db_index=True, max_length=150, verbose_name='name'), + ), + migrations.AlterField( + model_name='institution', + name='abbreviation', + field=models.CharField(max_length=200, verbose_name='abbreviation'), + ), + migrations.AlterField( + model_name='institution', + name='email', + field=models.EmailField(blank=True, max_length=200, verbose_name='email'), + ), + migrations.AlterField( + model_name='institution', + name='logo', + field=models.ImageField(blank=True, upload_to='institutions/logos/', verbose_name='logo'), + ), + migrations.AlterField( + model_name='institution', + name='name', + field=models.CharField(max_length=200, unique=True, verbose_name='name'), + ), + migrations.AlterField( + model_name='language', + name='name', + field=models.CharField(max_length=150, unique=True, verbose_name='name'), + ), + migrations.AlterField( + model_name='project', + name='end_date', + field=models.DateField(blank=True, null=True, verbose_name='end date'), + ), + migrations.AlterField( + model_name='project', + name='languages', + field=models.ManyToManyField(blank=True, to='general.language', verbose_name='languages'), + ), + migrations.AlterField( + model_name='project', + name='logo', + field=models.ImageField(blank=True, upload_to='projects/logos/', verbose_name='logo'), + ), + migrations.AlterField( + model_name='project', + name='name', + field=models.CharField(max_length=200, verbose_name='name'), + ), + migrations.AlterField( + model_name='project', + name='start_date', + field=models.DateField(blank=True, null=True, verbose_name='start date'), + ), + migrations.AlterField( + model_name='project', + name='subjects', + field=models.ManyToManyField(blank=True, to='general.subject', verbose_name='subjects'), + ), + migrations.AlterField( + model_name='subject', + name='name', + field=models.CharField(max_length=150, unique=True, verbose_name='name'), + ), + ] diff --git a/app/general/migrations/0008_documentfile_description_and_more.py b/app/general/migrations/0008_documentfile_description_and_more.py new file mode 100644 index 00000000..b6aa2fd4 --- /dev/null +++ b/app/general/migrations/0008_documentfile_description_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.2 on 2024-06-11 08:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0007_alter_documentfile_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='documentfile', + name='description', + field=models.TextField(blank=True, verbose_name='description'), + ), + migrations.AddField( + model_name='historicaldocumentfile', + name='description', + field=models.TextField(blank=True, verbose_name='description'), + ), + migrations.AddField( + model_name='historicalproject', + name='description', + field=models.TextField(blank=True, verbose_name='description'), + ), + migrations.AddField( + model_name='project', + name='description', + field=models.TextField(blank=True, verbose_name='description'), + ), + migrations.AlterField( + model_name='project', + name='institution', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='general.institution', verbose_name='institution'), + ), + ] diff --git a/app/general/migrations/0009_documentfile_search_vector_and_more.py b/app/general/migrations/0009_documentfile_search_vector_and_more.py new file mode 100644 index 00000000..e8e883d3 --- /dev/null +++ b/app/general/migrations/0009_documentfile_search_vector_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.2 on 2024-06-14 10:33 + +import django.contrib.postgres.indexes +import django.contrib.postgres.search +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0008_documentfile_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='documentfile', + name='search_vector', + field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True), + ), + migrations.AddIndex( + model_name='documentfile', + index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='general_doc_search__752b22_gin'), + ), + ] diff --git a/app/general/migrations/0011_alter_documentfile_options_and_more.py b/app/general/migrations/0011_alter_documentfile_options_and_more.py new file mode 100644 index 00000000..4010da6f --- /dev/null +++ b/app/general/migrations/0011_alter_documentfile_options_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.2 on 2024-07-02 05:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '00010_documentfile_search_vector_trigger'), + ] + + operations = [ + migrations.AlterModelOptions( + name='documentfile', + options={'verbose_name': 'Document', 'verbose_name_plural': 'Documents'}, + ), + migrations.AlterModelOptions( + name='historicaldocumentfile', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Document', 'verbose_name_plural': 'historical Documents'}, + ), + migrations.AlterField( + model_name='documentfile', + name='document_type', + field=models.CharField(choices=[('Glossary', 'Glossary'), ('Policy', 'Policy')], max_length=200, verbose_name='document category'), + ), + migrations.AlterField( + model_name='historicaldocumentfile', + name='document_type', + field=models.CharField(choices=[('Glossary', 'Glossary'), ('Policy', 'Policy')], max_length=200, verbose_name='document category'), + ), + ] diff --git a/app/general/migrations/0012_alter_documentfile_search_vector.py b/app/general/migrations/0012_alter_documentfile_search_vector.py new file mode 100644 index 00000000..2bde464c --- /dev/null +++ b/app/general/migrations/0012_alter_documentfile_search_vector.py @@ -0,0 +1,32 @@ +import django.contrib.postgres.search +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('general', '0011_alter_documentfile_options_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='documentfile', + name='search_vector', + ), # also deletes `search_vector_trigger` and general_doc_search__752b22_gin + migrations.AddField( + model_name='documentfile', + name='search_vector', + field=models.GeneratedField(db_persist=True, expression=django.contrib.postgres.search.CombinedSearchVector( + django.contrib.postgres.search.CombinedSearchVector( + django.contrib.postgres.search.SearchVector('title', config='english', weight='A'), '||', + django.contrib.postgres.search.SearchVector('description', config='english', weight='B'), + django.contrib.postgres.search.SearchConfig('english')), '||', + django.contrib.postgres.search.SearchVector('document_data', config='english', weight='C'), + django.contrib.postgres.search.SearchConfig('english')), null=True, + output_field=django.contrib.postgres.search.SearchVectorField()), + ), + migrations.AddIndex( + model_name='documentfile', + index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], + name='general_doc_search__752b22_gin'), + ), + ] diff --git a/app/general/migrations/0013_rename_documentfile_document_and_more.py b/app/general/migrations/0013_rename_documentfile_document_and_more.py new file mode 100644 index 00000000..f432997b --- /dev/null +++ b/app/general/migrations/0013_rename_documentfile_document_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.8 on 2024-08-17 14:47 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0012_alter_documentfile_search_vector'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RenameModel( + old_name='DocumentFile', + new_name='Document', + ), + migrations.RenameModel( + old_name='HistoricalDocumentFile', + new_name='HistoricalDocument', + ), + migrations.RenameIndex( + model_name='document', + new_name='general_doc_search__12340c_gin', + old_name='general_doc_search__752b22_gin', + ), + ] diff --git a/app/general/migrations/__init__.py b/app/general/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/general/models.py b/app/general/models.py new file mode 100644 index 00000000..0e56d660 --- /dev/null +++ b/app/general/models.py @@ -0,0 +1,171 @@ +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVector, SearchVectorField +from django.core.validators import FileExtensionValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ +from simple_history.models import HistoricalRecords + + +class Project(models.Model): + name = models.CharField(max_length=200, verbose_name=_("name")) + description = models.TextField(blank=True, verbose_name=_("description")) + url = models.URLField(max_length=200, blank=True, verbose_name=_("URL")) + logo = models.ImageField(upload_to="projects/logos/", blank=True, verbose_name=_("logo")) + start_date = models.DateField(blank=True, null=True, verbose_name=_("start date")) + end_date = models.DateField(blank=True, null=True, verbose_name=_("end date")) + institution = models.ForeignKey( + "Institution", on_delete=models.CASCADE, blank=False, verbose_name=_("institution") + ) + subjects = models.ManyToManyField("Subject", blank=True, verbose_name=_("subjects")) + languages = models.ManyToManyField("Language", blank=True, verbose_name=_("languages")) + + # added simple historical records to the model + history = HistoricalRecords() + + class Meta: + verbose_name = _("Project") + verbose_name_plural = _("Projects") + + def __str__(self): + return self.name + + +class Institution(models.Model): + name = models.CharField(max_length=200, unique=True, verbose_name=_("name")) + abbreviation = models.CharField(max_length=200, verbose_name=_("abbreviation")) + url = models.URLField(max_length=200, blank=True, verbose_name=_("URL")) + email = models.EmailField(max_length=200, blank=True, verbose_name=_("email")) + logo = models.ImageField(upload_to="institutions/logos/", blank=True, verbose_name=_("logo")) + + # added simple historical records to the model + history = HistoricalRecords() + + class Meta: + verbose_name = _("Institution") + verbose_name_plural = _("Institutions") + + def __str__(self): + return f"{self.name} ({self.abbreviation})" + + +class Language(models.Model): + name = models.CharField(max_length=150, unique=True, verbose_name=_("name")) + iso_code = models.CharField( + max_length=50, + unique=True, + help_text=_("The 2 or 3 letter code from ISO 639."), + verbose_name=_("ISO code"), + ) + + # added simple historical records to the model + history = HistoricalRecords() + + class Meta: + verbose_name = _("Language") + verbose_name_plural = _("Languages") + + def __str__(self): + return self.name + + +class Subject(models.Model): + name = models.CharField(max_length=150, unique=True, verbose_name=_("name")) + + # added simple historical records to the model + history = HistoricalRecords() + + class Meta: + verbose_name = _("Subject") + verbose_name_plural = _("Subjects") + + def __str__(self): + return self.name + + +class Document(models.Model): + file_validators = [FileExtensionValidator(["pdf"])] + + # names and abbreviations based on + # https://en.wikipedia.org/wiki/Creative_Commons_license#Six_regularly_used_licenses + license_choices = [ + ("(c)", _("All rights reserved")), + ("CC0", _("No rights reserved")), + ("CC BY", _("Creative Commons Attribution")), + ("CC BY-SA", _("Creative Commons Attribution-ShareAlike")), + ("CC BY-NC", _("Creative Commons Attribution-NonCommercial")), + ("CC BY-NC-SA", _("Creative Commons Attribution-NonCommercial-ShareAlike")), + ] + license_help_text = _( + """ + More information about Creative Commons licenses. + """ + ) + document_type_choices = [("Glossary", _("Glossary")), ("Policy", _("Policy"))] + + file_type = "pdf" + + title = models.CharField(max_length=200, verbose_name=_("title")) + description = models.TextField(blank=True, verbose_name=_("description")) + url = models.URLField(max_length=200, blank=True, verbose_name=_("URL")) + uploaded_file = models.FileField( + upload_to="documents/", + validators=file_validators, + blank=True, + help_text=_("PDF files up to 10MB are allowed."), + verbose_name=_("uploaded file"), + ) + available = models.BooleanField(default=True, verbose_name=_("available")) + license = models.CharField( + max_length=200, + choices=license_choices, + default="(c)", + help_text=license_help_text, + verbose_name=_("license"), + ) + mime_type = models.CharField( + max_length=200, + blank=True, + help_text=_("This input will auto-populate."), + verbose_name=_("MIME type"), + ) + document_type = models.CharField( + max_length=200, choices=document_type_choices, verbose_name=_("document category") + ) + document_data = models.TextField(blank=True, verbose_name=_("document data")) + institution = models.ForeignKey( + "Institution", on_delete=models.CASCADE, verbose_name=_("institution") + ) + subjects = models.ManyToManyField("Subject", blank=True, verbose_name=_("subjects")) + languages = models.ManyToManyField("Language", blank=True, verbose_name=_("languages")) + + # `config="english"` is required to ensure that the `expression` is + # immutable, otherwise the migration to the GeneratedField fails. + search_vector = models.GeneratedField( + expression=( + SearchVector("title", config="english", weight="A") + + SearchVector("description", config="english", weight="B") + + SearchVector("document_data", config="english", weight="C") + ), + output_field=SearchVectorField(), + db_persist=True, + null=True, + blank=True, + ) + + # added simple historical records to the model + history = HistoricalRecords(excluded_fields=["document_data", "search_vector"]) + + class Meta: + verbose_name = _("Document") + verbose_name_plural = _("Documents") + + indexes = [ + GinIndex(fields=["search_vector"]), + ] + + def __str__(self): + return self.title diff --git a/app/general/service/__init__.py b/app/general/service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/general/service/extract_text.py b/app/general/service/extract_text.py new file mode 100644 index 00000000..a9c65ab1 --- /dev/null +++ b/app/general/service/extract_text.py @@ -0,0 +1,30 @@ +# TODO: +# - remove unneeded whitespace (e.g. multiple consecutive spaces) +# - remove unprintable characters, or replacing them with some symbol +# character so that excerpts look better. +# - consider removing a few too common words, like single digits "1", etc. +# or maybe anything that occurs too frequently in the full-text index that +# could cause a full-table scan. +# - consider multilingual stemming to enhance chances of success in a multi- +# lingual setup... hard! + + +class GetTextError(Exception): + pass + + +def pdf_to_text(pdf): + # imports postponed, as they will normally not be needed frequently + from pypdf import PdfReader + from pypdf.errors import PdfStreamError + + text_list = [] + try: + for page in PdfReader(pdf).pages: + text_list.append(page.extract_text()) + except PdfStreamError: + raise GetTextError("The uploaded PDF file is corrupted or not fully downloaded.") + except Exception: + raise GetTextError("Error during text extraction from PDF file.") + + return " ".join(text_list) 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/__init__.py b/app/general/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/general/tests/files/Lorem.pdf b/app/general/tests/files/Lorem.pdf new file mode 100644 index 00000000..5c074df3 Binary files /dev/null and b/app/general/tests/files/Lorem.pdf differ diff --git a/app/general/tests/test_document.py b/app/general/tests/test_document.py new file mode 100644 index 00000000..a7248c2a --- /dev/null +++ b/app/general/tests/test_document.py @@ -0,0 +1,65 @@ +import unittest + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase + +from general.models import Document, Institution, Language, Subject + + +class DocumentTest(TestCase): + def setUp(self): + self.subject = Subject.objects.create(name="Test Subject") + self.language = Language.objects.create(name="Test Language", iso_code="TL") + self.institution = Institution.objects.create(name="Test Institution") + + self.title = "Some document" + self.url = "https://example.com" + self.uploaded_file = SimpleUploadedFile( + "example.pdf", b"file_content", content_type="application/pdf" + ) + self.license = "MIT" + self.mime_type = "pdf" + self.document_type = "Glossary" + self.institution = self.institution + + self.document = Document.objects.create( + title=self.title, + url=self.url, + uploaded_file=self.uploaded_file, + license=self.license, + mime_type=self.mime_type, + document_type=self.document_type, + institution=self.institution, + ) + self.document.subjects.add(self.subject) + self.document.languages.add(self.language) + + def test_document_creation(self): + self.assertEqual(Document.objects.count(), 1) + self.assertEqual(Document.objects.get().title, self.title) + + def test_document_str_representation(self): # Test __str__ method + self.assertEqual(str(self.document), self.title) + + def test_document_available_by_default(self): # Test default value + self.assertTrue(self.document.available) + + def test_history_records_creation(self): + self.assertEqual(self.document.history.count(), 1) + self.assertEqual(self.document.history.first().title, "Some document") + self.assertEqual(self.document.history.first().url, "https://example.com") + self.assertEqual(self.document.history.first().uploaded_file, "documents/example.pdf") + self.assertEqual(self.document.history.first().license, "MIT") + self.assertEqual(self.document.history.first().mime_type, "pdf") + self.assertEqual(self.document.history.first().document_type, "Glossary") + self.assertEqual(self.document.institution, self.institution) + self.assertIn(self.subject, self.document.subjects.all()) + self.assertIn(self.language, self.document.languages.all()) + + def tearDown(self): + if self.document.uploaded_file: + self.document.uploaded_file.delete() + + +if __name__ == "__main__": + unittest.main() diff --git a/app/general/tests/test_document_admin.py b/app/general/tests/test_document_admin.py new file mode 100644 index 00000000..11d14b0a --- /dev/null +++ b/app/general/tests/test_document_admin.py @@ -0,0 +1,97 @@ +import os +import unittest + +from django.core.files.uploadedfile import SimpleUploadedFile + +from general.admin import DocumentForm +from general.models import Institution + + +class TestDocumentForm(unittest.TestCase): + def __init__(self, methodName: str = "runTest"): + super().__init__(methodName) + self.form = None + + def setUp(self): + test_dir = os.getenv("TESTING_DIR", "/app/general/tests/files") + test_file = test_dir + "/Lorem.pdf" + + with open(test_file, "rb") as f: + pdf_file = f.read() + + self.file_mock = SimpleUploadedFile("test.pdf", pdf_file, content_type="application/pdf") + + def test_clean_without_url_and_file(self): + tests_form = { + "title": "Test", + "license": "MIT", + "document_type": "Glossary", + "mime_type": "pdf", + "institution": Institution.objects.create(name="Test Institution"), + "url": "", + "uploaded_file": "", + "description": "Test description", + } + + form = DocumentForm(tests_form) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors["url"], ["Either URL or uploaded file must be provided."]) + self.assertEqual( + form.errors["uploaded_file"], ["Either URL or uploaded file must be provided."] + ) + + def test_clean_without_file(self): + tests_form = { + "title": "Test", + "license": "(c)", + "document_type": "Glossary", + "mime_type": "pdf", + "institution": Institution.objects.create(name="Test Institution 2"), + "url": "www.example.com", + "uploaded_file": "", + "document_data": "", + "description": "", + } + + form = DocumentForm(tests_form) + self.assertTrue(form.is_valid()) + + # + def test_clean_without_url(self): + tests_form = { + "title": "Test", + "license": "CC0", + "document_type": "Glossary", + "mime_type": "pdf", + "institution": Institution.objects.create(name="Test Institution 3"), + "url": "", + "uploaded_file": self.file_mock, + "document_data": "", + "description": "Test description", + } + + form = DocumentForm(tests_form, files={"uploaded_file": self.file_mock}) + self.assertTrue(form.is_valid()) + + def test_clean_with_large_file(self): + self.file_mock.size = 15728640 + + tests_form = { + "title": "Test", + "license": "MIT", + "document_type": "Glossary", + "mime_type": "pdf", + "institution": Institution.objects.create(name="Test Institution 4"), + "url": "", + "uploaded_file": self.file_mock, + "description": "Test description", + } + + form = DocumentForm(tests_form, files={"uploaded_file": self.file_mock}) + self.assertFalse(form.is_valid()) + self.assertIn("uploaded_file", form.errors) + self.assertEqual(form.errors["uploaded_file"], ["File size must not exceed 10MB."]) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/general/tests/test_document_detail.py b/app/general/tests/test_document_detail.py new file mode 100644 index 00000000..d6ea0c1b --- /dev/null +++ b/app/general/tests/test_document_detail.py @@ -0,0 +1,42 @@ +from django.test import TestCase +from django.urls import reverse + +from general.models import Document, Institution, Language, Project, Subject + + +class DocumentDetailViewTest(TestCase): + def setUp(self): + self.institution = Institution.objects.create(name="University of Cape Town") + + self.subject1 = Subject.objects.create(name="Anatomy") + self.subject2 = Subject.objects.create(name="Biology") + + self.language1 = Language.objects.create(name="Afrikaans", iso_code="af") + self.language2 = Language.objects.create(name="English", iso_code="en") + + self.document = Document.objects.create( + title="Afrikaans_HL_P1_Feb-March_2011", + description="This is a description of the document.", + url="https://externaldocumentrepository.com/document1", + uploaded_file="path/to/document.pdf", + available=True, + license="(c)", + document_type="Glossary", + institution=self.institution, + mime_type="application/pdf", + document_data="", + search_vector=None, + ) + self.document.subjects.add(self.subject1, self.subject2) + self.document.languages.add(self.language1, self.language2) + + def test_document_detail_num_queries(self): + url = reverse("document_detail", args=[self.document.id]) + 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) + self.assertContains(response, self.language1.name) diff --git a/app/general/tests/test_documents.py b/app/general/tests/test_documents.py new file mode 100644 index 00000000..9f39a890 --- /dev/null +++ b/app/general/tests/test_documents.py @@ -0,0 +1,48 @@ +from django.test import TestCase +from django.urls import reverse + +from general.models import Document, 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 = Document.objects.create( + title="Document 5", + institution=self.institution, + ) + self.document5.subjects.add(self.subject1) + self.document5.languages.add(self.language1) + + self.document6 = Document.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) + + def test_with_filters(self): + response = self.client.get(reverse("documents"), {"language": self.language3.id}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["documents"]), 1) + self.assertEqual(response.context["documents"][0]["document"].title, "Document 6") diff --git a/app/general/tests/test_extract_text_service.py b/app/general/tests/test_extract_text_service.py new file mode 100644 index 00000000..fa1e7ecf --- /dev/null +++ b/app/general/tests/test_extract_text_service.py @@ -0,0 +1,18 @@ +import os +import unittest + +from general.service.extract_text import pdf_to_text + + +class TestExtractTextService(unittest.TestCase): + def setUp(self): + test_dir = os.getenv("TESTING_DIR", "/app/general/tests/files") + self.file_name = os.path.join(test_dir, "Lorem.pdf") + + def test_text_extraction(self): + with open(self.file_name, "rb") as file: + text = pdf_to_text(file) + self.assertIn("fermentum turpis.", text) + self.assertNotIn("notintext.", text) + self.assertGreater(len(text), 1470, "Too little text extracted") + self.assertGreater(len(text.split()), 220, "Too few words (spaces) extracted") diff --git a/app/general/tests/test_filter.py b/app/general/tests/test_filter.py new file mode 100644 index 00000000..36f36190 --- /dev/null +++ b/app/general/tests/test_filter.py @@ -0,0 +1,112 @@ +import unittest + +from django.test import TestCase + +from general.filters import DocumentFilter +from general.models import Document, Institution, Language, Project, Subject + + +class TestSearchFilter(TestCase): + def setUp(self): + self.institution1 = Institution.objects.create(name="Institution 1") + self.institution2 = Institution.objects.create(name="Institution 2") + + # Create languages + self.language1 = Language.objects.create(name="English", iso_code="EN") + self.language2 = Language.objects.create(name="Afrikaans", iso_code="AF") + + # Create subjects + self.subject1 = Subject.objects.create(name="Science") + self.subject2 = Subject.objects.create(name="Math") + + # Create Documents + self.doc1 = Document.objects.create( + title="Document 1", + document_data="Document 1 content", + institution=self.institution1, + document_type="glossary", + ) + self.doc1.subjects.add(self.subject1) + self.doc1.languages.add(self.language1) + + self.doc2 = Document.objects.create( + title="Document 2", + document_data="Document 2 content", + institution=self.institution2, + document_type="glossary", + ) + self.doc2.subjects.add(self.subject2) + self.doc2.languages.add(self.language2) + + # Create Projects for search testing + self.project1 = Project.objects.create( + name="Project 1", + description="Project 1 description", + institution=self.institution1, + logo="logo1.png", + ) + + def test_institution_filter(self): + data = {"institution": [self.institution1.id]} + filter = DocumentFilter(data=data) + qs = filter.qs + self.assertEqual(len(qs), 2) + # TODO: ordering between documents and projects are not yet defined + self.assertEqual(qs[0]["id"], self.project1.id) + + def test_subjects_filter(self): + data = {"subjects": [self.subject1.id]} + filter = DocumentFilter(data=data) + qs = filter.qs + self.assertEqual(len(qs), 1) + self.assertEqual(qs[0]["id"], self.doc1.id) + + def test_languages_filter(self): + data = {"languages": [self.language1.id]} + filter = DocumentFilter(data=data) + qs = filter.qs + self.assertEqual(len(qs), 1) + self.assertEqual(qs[0]["id"], self.doc1.id) + + def test_combined_filters(self): + data = { + "institution": [self.institution1.id], + "subjects": [self.subject1.id], + "languages": [self.language1.id], + } + filter = DocumentFilter(data=data) + qs = filter.qs + self.assertEqual(len(qs), 1) + self.assertEqual(qs[0]["id"], self.doc1.id) + + def test_search_filter_documents(self): + data = {"search": "Document"} + filter = DocumentFilter(data=data) + qs = filter.qs + self.assertEqual(len(qs), 2) + self.assertCountEqual([qs[0]["id"], qs[1]["id"]], [self.doc1.id, self.doc2.id]) + + data = {"search": "Document 1"} + filter = DocumentFilter(data=data) + qs = filter.qs + self.assertEqual(len(qs), 1) + self.assertEqual(qs[0]["id"], self.doc1.id) + + def test_search_filter_projects(self): + data = {"search": "Project 1"} + filter = DocumentFilter(data=data) + qs = filter.qs + self.assertEqual(len(qs), 1) + self.assertEqual(qs[0]["id"], self.project1.id) + + def test_search_filter_combined(self): + data = {"search": "1"} + filter = DocumentFilter(data=data) + 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__": + unittest.main() diff --git a/app/general/tests/test_import_documents.py b/app/general/tests/test_import_documents.py new file mode 100644 index 00000000..a18de80b --- /dev/null +++ b/app/general/tests/test_import_documents.py @@ -0,0 +1,57 @@ +import os +import random +import unittest +from unittest.mock import MagicMock, patch + +from faker import Faker + +from general.management.commands.import_documents import Command +from general.models import Document, Institution + + +class TestHandleFile(unittest.TestCase): + def setUp(self): + self.command = Command() + self.command.save_data = MagicMock() + self.test_dir = os.getenv("TESTING_DIR", "/app/general/tests/files") + self.test_file = self.test_dir + "Lorem.pdf" + self.name = "Test file" + self.fake = Faker() + + def tearDown(self): + try: + document_file = Document.objects.get(title=self.name) + path = document_file.uploaded_file.path + if os.path.isfile(path): + os.remove(path) + except Document.DoesNotExist: + pass + + def test_handle_file_pdf(self): + self.command.handle_file(self.test_file, self.test_file) + self.command.save_data.assert_called_once() + + def test_handle_file_non_pdf(self): + with patch("magic.from_file") as from_file: + from_file.return_value = None + self.command.handle_file(self.test_file, self.test_file) + self.command.save_data.assert_not_called() + + def test_save_data(self): + command = Command() + # Create some Institutions instances for testing + for i in range(1, 21): + id = random.randint(1, 1000) + Institution.objects.create( + id=i, + name=f"{id}_{self.fake.company()}", + abbreviation=f"{id}_{self.fake.company_suffix()}", + url=f"{id}_{self.fake.url()}", + email=f"{id}_{self.fake.company_email()}", + logo="", + ) + + command.save_data(self.test_file, self.name) + document_file = Document.objects.get(title=self.name) + self.assertEqual(document_file.title, self.name) + self.assertIn("Lorem ipsum dolor", document_file.document_data) diff --git a/app/general/tests/test_institution_detail.py b/app/general/tests/test_institution_detail.py new file mode 100644 index 00000000..2904e598 --- /dev/null +++ b/app/general/tests/test_institution_detail.py @@ -0,0 +1,27 @@ +from django.test import TestCase +from django.urls import reverse + +from general.models import Institution + + +class InstitutionDetailViewTests(TestCase): + def setUp(self): + self.institution = Institution.objects.create( + name="Sample Institution", + abbreviation="SI", + url="https://example.com", + email="info@example.com", + logo="path/to/logo.png", + ) + + def test_institution_detail_view_with_valid_id(self): + 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) diff --git a/app/general/tests/test_languages.py b/app/general/tests/test_languages.py new file mode 100644 index 00000000..ffbb5bbf --- /dev/null +++ b/app/general/tests/test_languages.py @@ -0,0 +1,39 @@ +from django.test import TestCase +from django.urls import reverse + +from general.models import Document, Institution, Language, Project + + +class LanguagesViewTest(TestCase): + def setUp(self): + self.institution = Institution.objects.create( + name="Test Institution", + abbreviation="TI", + url="http://testinstitution.org", + email="info@testinstitution.org", + ) + + self.language1 = Language.objects.create(name="English", iso_code="lang1") + self.language2 = Language.objects.create(name="Spanish", iso_code="lang2") + + self.document1 = Document.objects.create( + title="Document 1", institution=self.institution, document_type="report" + ) + self.document1.languages.add(self.language1) + + self.document2 = Document.objects.create( + title="Document 2", institution=self.institution, document_type="report" + ) + self.document2.languages.add(self.language2) + + self.project1 = Project.objects.create(name="Project 1", institution=self.institution) + self.project1.languages.add(self.language1) + + self.project2 = Project.objects.create(name="Project 2", institution=self.institution) + self.project2.languages.add(self.language2) + + def test_view_basics(self): + with self.assertNumQueries(3): + response = self.client.get(reverse("languages")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="main-heading"') diff --git a/app/general/tests/test_logging.py b/app/general/tests/test_logging.py new file mode 100644 index 00000000..b24d6ccb --- /dev/null +++ b/app/general/tests/test_logging.py @@ -0,0 +1,23 @@ +import logging +import os + +from django.test import TestCase + + +class LoggingTest(TestCase): + def setUp(self): + LOGGING_DIR = "/logging" + self.logger = logging.getLogger("django") + self.log_file = os.path.join(LOGGING_DIR, "debug.log") + + def test_log_file_created(self): + """Test if the log file is created.""" + self.logger.error("This is a test error message.") + + self.assertTrue(os.path.exists(self.log_file)) + + def test_log_message(self): + """Test if the log message is written to the file.""" + with open(self.log_file, "r") as f: + content = f.read() + self.assertIn("This is a test error message.", content) diff --git a/app/general/tests/test_project_detail.py b/app/general/tests/test_project_detail.py new file mode 100644 index 00000000..5991b237 --- /dev/null +++ b/app/general/tests/test_project_detail.py @@ -0,0 +1,57 @@ +from django.test import TestCase +from django.urls import reverse + +from general.models import Institution, Language, Project, Subject + + +class ProjectDetailViewTests(TestCase): + def setUp(self): + self.institution = Institution.objects.create(name="Test University") + + self.project = Project.objects.create( + name="Test Project", + description="This is a test project", + url="https://example.com", + logo="path/to/logo.png", + start_date="2020-01-01", + end_date="2021-01-01", + institution=self.institution, + ) + self.subject1 = Subject.objects.create(name="Subject 1") + self.subject2 = Subject.objects.create(name="Subject 2") + + self.project.subjects.add(self.subject1, self.subject2) + + self.language1 = Language.objects.create(name="Language 1", iso_code="lang1") + self.language2 = Language.objects.create(name="Language 2", iso_code="lang2") + + self.project.languages.add(self.language1, self.language2) + + 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) + self.assertContains(response, self.subject1.name) + self.assertContains(response, self.language1.name) + + def test_project_detail_view_context(self): + response = self.client.get(reverse("project_detail", args=[self.project.id])) + self.assertIn("project", response.context) + self.assertIn("logo", response.context) + self.assertIn("subjects", response.context) + self.assertIn("languages", response.context) + self.assertEqual(response.context["project"], self.project) + self.assertEqual(list(response.context["subjects"]), [self.subject1, self.subject2]) + self.assertEqual(list(response.context["languages"]), [self.language1, self.language2]) + + def test_project_detail_view_num_queries(self): + with self.assertNumQueries(3): + response = self.client.get(reverse("project_detail", args=[self.project.id])) + + def test_project_detail_view_invalid_id(self): + invalid_project_id = self.project.id + 1 + response = self.client.get(reverse("project_detail", args=[invalid_project_id])) + self.assertEqual(response.status_code, 404) diff --git a/app/general/tests/test_projects.py b/app/general/tests/test_projects.py new file mode 100644 index 00000000..239016b2 --- /dev/null +++ b/app/general/tests/test_projects.py @@ -0,0 +1,103 @@ +from datetime import date + +from django.test import Client, TestCase +from django.urls import reverse + +from app.views import get_date_range +from general.models import Institution, Language, Project, Subject + + +class ProjectViewTests(TestCase): + def setUp(self): + self.client = Client() + 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.project1 = Project.objects.create( + name="Project 1", + start_date="2020-01-01", + end_date="2021-01-01", + institution=self.institution, + logo="project_logo.png", + ) + self.project1.subjects.add(self.subject1) + self.project1.languages.add(self.language1) + + self.project2 = Project.objects.create( + name="Project 2", + end_date="2021-01-01", + institution=self.institution, + logo="project_logo.png", + ) + self.project2.subjects.add(self.subject1) + self.project2.languages.add(self.language1) + self.project2.languages.add(self.language2) + self.project2.languages.add(self.language3) + self.project2.languages.add(self.language4) + + self.url = reverse("projects") + + def test_projects_view(self): + 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") + self.assertEqual(response.context["projects"][1]["project"].name, "Project 2") + + def test_projects_view_with_filters(self): + response = self.client.get(reverse("projects"), {"language": self.language3.id}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["projects"]), 1) + self.assertEqual(response.context["projects"][0]["project"].name, "Project 2") + + def test_projects_view_multilingual(self): + response = self.client.get(reverse("projects")) + self.assertEqual(response.status_code, 200) + projects = response.context["projects"] + self.assertEqual(projects[1]["languages"], "Multilingual") + + +class GetDateRangeTests(TestCase): + def setUp(self): + self.institution = Institution.objects.create(name="Institution") + self.project = Project.objects.create(name="Project", institution=self.institution) + + def test_get_date_range_different_years(self): + self.project.start_date = date(2020, 1, 1) + self.project.end_date = date(2021, 1, 1) + self.project.save() + self.assertEqual(get_date_range(self.project), "2020 – 2021") + + def test_get_date_range_same_year(self): + self.project.start_date = date(2020, 1, 1) + self.project.end_date = date(2020, 12, 31) + self.project.save() + self.assertEqual(get_date_range(self.project), 2020) + + def test_get_date_range_only_start_date(self): + self.project.start_date = date(2020, 1, 1) + self.project.end_date = None + self.project.save() + self.assertEqual(get_date_range(self.project), "Since 2020") + + def test_get_date_range_only_end_date(self): + self.project.start_date = None + self.project.end_date = date(2020, 12, 31) + self.project.save() + self.assertEqual(get_date_range(self.project), "Until 2020") + + def test_get_date_range_no_dates(self): + self.project.start_date = None + self.project.end_date = None + self.project.save() + self.assertIsNone(get_date_range(self.project)) diff --git a/app/general/tests/test_view_search.py b/app/general/tests/test_view_search.py new file mode 100644 index 00000000..2a3b7af8 --- /dev/null +++ b/app/general/tests/test_view_search.py @@ -0,0 +1,77 @@ +import unittest + +from django.test import Client, TestCase +from django.urls import reverse + +from general.models import Document, Institution, Language, Subject + + +class SearchViewTest(TestCase): + def setUp(self): + # Create institutions + self.institution1 = Institution.objects.create(name="Institution 1") + self.institution2 = Institution.objects.create(name="Institution 2") + + # Create languages + self.language1 = Language.objects.create(name="English", iso_code="EN") + self.language2 = Language.objects.create(name="Afrikaans", iso_code="AF") + + # Create subjects + self.subject1 = Subject.objects.create(name="Science") + self.subject2 = Subject.objects.create(name="Math") + + # Create documents + for i in range(10): + doc = Document.objects.create( + title=f"Document {i + 1}", + institution=self.institution1 if i % 2 == 0 else self.institution2, + document_type="report" if i % 2 == 0 else "article", + document_data="Document {i + 1} content", + ) + 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): + response = self.client.get(reverse("search"), {"page": "1"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["page_obj"]), 5) + + response = self.client.get(reverse("search"), {"page": "2"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["page_obj"]), 5) + + response = self.client.get(reverse("search"), {"page": "3"}) + self.assertEqual(response.status_code, 200) + + def test_search_filtering(self): + response = self.client.get(reverse("search"), {"search": "Document 1"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["page_obj"][0]["heading"], "Document 1") + + def test_invalid_page_number(self): + response = self.client.get(reverse("search"), {"page": "invalid"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["page_obj"]), 5) + + def test_combined_filters(self): + response = self.client.get( + reverse("search"), + { + "institution": self.institution1.id, + "document_type": "report", + "subjects": self.subject1.id, + "languages": self.language1.id, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["page_obj"]), 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/general/tests/test_views.py b/app/general/tests/test_views.py new file mode 100644 index 00000000..77eff2e9 --- /dev/null +++ b/app/general/tests/test_views.py @@ -0,0 +1,18 @@ +from django.test import Client, TestCase + + +class TestViews(TestCase): + def test_health(self): + response = Client().get("/_health/") + self.assertIn(b"OK", response.content) + + +class CustomTests(TestCase): + def setUp(self): + self.client = Client() + + def test_custom_404_page(self): + response = self.client.get("/example/") + + self.assertEqual(response.status_code, 404) + self.assertTemplateUsed(response, "404.html") diff --git a/app/general/tests/test_views_institution.py b/app/general/tests/test_views_institution.py new file mode 100644 index 00000000..9690c56f --- /dev/null +++ b/app/general/tests/test_views_institution.py @@ -0,0 +1,62 @@ +from django.test import Client, TestCase +from django.urls import reverse + +from general.models import Document, Institution, Project + + +class InstitutionsViewTestCase(TestCase): + def setUp(self): + inst1 = Institution.objects.create( + name="Institution Banana", + abbreviation="UniB", + url="unibanana.co.za", + email="unibanana@email.com", + logo="/unibanana/logo", + ) + Institution.objects.create( + name="Institution Apple", abbreviation="UniA", url="uniapple.co.za" + ) + + Project.objects.create(name="Test Project 1", institution=inst1) + Project.objects.create(name="Test Project 2", institution=inst1) + Document.objects.create(title="Test document 1", institution=inst1) + Document.objects.create(title="Test document 2", institution=inst1) + + self.url = reverse("institutions") + + 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): + response = self.client.get(self.url) + + self.assertIn("current_page", response.context) + self.assertIn("institutions", response.context) + self.assertEqual(response.context["current_page"], "institutions") + + def test_institutions_view_correct_projects_returned(self): + response = self.client.get(self.url) + + institutions = response.context["institutions"] + + 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) + 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) + + institutions = response.context["institutions"] + + institution1 = next(inst for inst in institutions if inst["name"] == "Institution Apple") + institution2 = next(inst for inst in institutions if inst["name"] == "Institution Banana") + + self.assertEqual(institution1["rating"], 60) + self.assertEqual(institution2["rating"], 100) diff --git a/app/general/tests/tests_institution.py b/app/general/tests/tests_institution.py new file mode 100644 index 00000000..198fccd1 --- /dev/null +++ b/app/general/tests/tests_institution.py @@ -0,0 +1,47 @@ +import unittest + +from django.test import TestCase + +from general.models import Institution + + +class TestInstitution(TestCase): + def setUp(self): + self.institution = Institution.objects.create( + name="Test University", + abbreviation="tu", + url="http://www.testuni.com", + email="info@testuni.dev", + logo="testuni.png", + ) + + def test_institution_creation(self): + self.assertTrue(isinstance(self.institution, Institution)) + self.assertEqual(str(self.institution), "Test University (tu)") + + def test_institution_name(self): + self.assertEqual(self.institution.name, "Test University") + + def test_institution_abbreviation(self): + self.assertEqual(self.institution.abbreviation, "tu") + + def test_institution_url(self): + self.assertEqual(self.institution.url, "http://www.testuni.com") + + def test_institution_email(self): + self.assertEqual(self.institution.email, "info@testuni.dev") + + def test_institution_logo(self): + self.assertEqual(self.institution.logo, "testuni.png") + + def test_history_records_creation(self): + self.assertEqual(self.institution.history.count(), 1) + self.assertEqual(self.institution.history.first().name, "Test University") + self.assertEqual(self.institution.history.first().abbreviation, "tu") + self.assertEqual(self.institution.history.first().url, "http://www.testuni.com") + self.assertEqual(self.institution.history.first().email, "info@testuni.dev") + self.assertEqual(self.institution.history.first().logo, "testuni.png") + + +if __name__ == "__main__": + unittest.main() diff --git a/app/general/tests/tests_language.py b/app/general/tests/tests_language.py new file mode 100644 index 00000000..abe839ef --- /dev/null +++ b/app/general/tests/tests_language.py @@ -0,0 +1,32 @@ +import unittest + +from django.test import TestCase + +from general.models import Language + + +class TestLanguage(TestCase): + def setUp(self): + self.language = Language.objects.create(name="English", iso_code="EN") + self.language2 = Language.objects.create(name="Afrikaans", iso_code="AF") + + def test_subject_creation(self): + self.assertEqual(self.language.name, "English") + self.assertEqual(self.language.iso_code, "EN") + + self.assertEqual(self.language2.name, "Afrikaans") + self.assertEqual(self.language2.iso_code, "AF") + + def test_subject_name_uniqueness(self): + with self.assertRaises(Exception): + Language.objects.create(name="English") + + # + def test_history_records_creation(self): + self.assertEqual(self.language.history.count(), 1) + self.assertEqual(self.language.history.first().name, "English") + self.assertEqual(self.language.history.first().iso_code, "EN") + + +if __name__ == "__main__": + unittest.main() diff --git a/app/general/tests/tests_projects.py b/app/general/tests/tests_projects.py new file mode 100644 index 00000000..1a0b981e --- /dev/null +++ b/app/general/tests/tests_projects.py @@ -0,0 +1,75 @@ +import unittest +from datetime import datetime + +from django.test import TestCase + +from general.models import Institution, Language, Project, Subject + + +class TestProjects(TestCase): + def setUp(self): + self.institution = Institution.objects.create(name="Test Institution") + self.subject = Subject.objects.create(name="Test Subject") + self.language = Language.objects.create(name="Test Language", iso_code="TL") + self.project = Project.objects.create( + name="Test Project", + url="http://test.com", + logo="http://test.com/logo.png", + start_date="2023-01-01", + end_date="2023-12-31", + institution=self.institution, + ) + self.project.subjects.add(self.subject) + self.project.languages.add(self.language) + + def test_project_creation(self): + self.assertTrue(isinstance(self.project, Project)) + self.assertEqual(self.project.__str__(), "Test Project") + self.assertEqual(self.project.name, "Test Project") + self.assertEqual(self.project.url, "http://test.com") + self.assertEqual(self.project.logo, "http://test.com/logo.png") + self.assertEqual(self.project.start_date, "2023-01-01") + self.assertEqual(self.project.end_date, "2023-12-31") + self.assertEqual(self.project.institution, self.institution) + self.assertIn(self.subject, self.project.subjects.all()) + + def test_project_name(self): + self.assertEqual(self.project.name, "Test Project") + + def test_project_url(self): + self.assertEqual(self.project.url, "http://test.com") + + def test_project_start_date(self): + date_format = "%Y-%m-%d" + end_date = datetime.strptime(self.project.start_date, date_format) + self.assertEqual(end_date.strftime(date_format), "2023-01-01") + + def test_project_end_date(self): + date_format = "%Y-%m-%d" + end_date = datetime.strptime(self.project.end_date, date_format) + self.assertEqual(end_date.strftime(date_format), "2023-12-31") + + def test_project_institution(self): + self.assertEqual(self.project.institution.name, "Test Institution") + + def test_project_subject(self): + self.assertTrue(self.project.subjects.filter(name="Test Subject").exists()) + + def test_project_language(self): + self.assertTrue(self.project.languages.filter(name="Test Language").exists()) + + def test_str(self): + self.assertEqual(str(self.project), "Test Project") + + def test_history_records_creation(self): + self.assertEqual(self.project.history.count(), 1) + self.assertEqual(self.project.history.first().name, "Test Project") + self.assertEqual(self.project.history.first().url, "http://test.com") + self.assertEqual(self.project.history.first().logo, "http://test.com/logo.png") + self.assertEqual(self.project.history.first().start_date.strftime("%Y-%m-%d"), "2023-01-01") + self.assertEqual(self.project.history.first().end_date.strftime("%Y-%m-%d"), "2023-12-31") + self.assertEqual(self.project.history.first().institution, self.institution) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/general/tests/tests_subject.py b/app/general/tests/tests_subject.py new file mode 100644 index 00000000..2b02ae19 --- /dev/null +++ b/app/general/tests/tests_subject.py @@ -0,0 +1,55 @@ +from django.test import TestCase +from django.urls import reverse + +from general.models import Document, Institution, Project, Subject + + +class TestSubjects(TestCase): + def setUp(self): + self.institution = Institution.objects.create( + name="Test Institution", + abbreviation="TI", + url="http://testinstitution.org", + email="info@testinstitution.org", + ) + + self.subject1 = Subject.objects.create(name="Mathematics") + self.subject2 = Subject.objects.create(name="Science") + + self.document1 = Document.objects.create( + title="Document 1", institution=self.institution, document_type="report" + ) + self.document1.subjects.add(self.subject1) + + self.document2 = Document.objects.create( + title="Document 2", institution=self.institution, document_type="report" + ) + self.document2.subjects.add(self.subject2) + + self.project1 = Project.objects.create(name="Project 1", institution=self.institution) + self.project1.subjects.add(self.subject1) + + self.project2 = Project.objects.create(name="Project 2", institution=self.institution) + self.project2.subjects.add(self.subject2) + + def test_subject_creation(self): + self.assertEqual(str(self.subject1), "Mathematics") + self.assertEqual(str(self.subject2), "Science") + + def test_subject_name_uniqueness(self): + with self.assertRaises(Exception): + Subject.objects.create(name="Mathematics") + + def test_history_records_creation(self): + self.assertEqual(self.subject1.history.count(), 1) + self.assertEqual(self.subject1.history.first().name, "Mathematics") + + def test_subjects_view_num_queries(self): + with self.assertNumQueries(3): + 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/gunicorn.conf.py b/app/gunicorn.conf.py new file mode 100644 index 00000000..2aa9408e --- /dev/null +++ b/app/gunicorn.conf.py @@ -0,0 +1,12 @@ +# Send output from the web app to the error log: +capture_output = True +errorlog = "-" + +keepalive = 30 +max_requests = 1000000 +max_requests_jitter = 1000 +graceful_timeout = 5 +preload_app = True + +workers = 4 +threads = 3 diff --git a/app/locale/af/LC_MESSAGES/django.po b/app/locale/af/LC_MESSAGES/django.po new file mode 100644 index 00000000..7078ed66 --- /dev/null +++ b/app/locale/af/LC_MESSAGES/django.po @@ -0,0 +1,22 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-05-21 12:07+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: templates/admin/base_site.html:11 +msgid "Django site admin" +msgstr "" diff --git a/app/locale/en/LC_MESSAGES/django.po b/app/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..7e56819b --- /dev/null +++ b/app/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,22 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-05-21 11:54+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: templates/admin/base_site.html:11 +msgid "Django site admin" +msgstr "" diff --git a/app/manage.py b/app/manage.py new file mode 100755 index 00000000..923e331a --- /dev/null +++ b/app/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/app/pyproject.toml b/app/pyproject.toml new file mode 100644 index 00000000..5be1384c --- /dev/null +++ b/app/pyproject.toml @@ -0,0 +1,10 @@ +[tool.ruff] +line-length = 100 +exclude = ["migrations", ".venv"] + +[tool.ruff.lint] +select = [ + "DJ", + "I", + "W" +] diff --git a/app/schema/schema.png b/app/schema/schema.png new file mode 100644 index 00000000..c3c8a6ae Binary files /dev/null and b/app/schema/schema.png differ diff --git a/app/static/css/admin.css b/app/static/css/admin.css new file mode 100644 index 00000000..33b7dfce --- /dev/null +++ b/app/static/css/admin.css @@ -0,0 +1,72 @@ +/* + Django admin styles + extended from https://github.com/django/django/blob/main/django/contrib/admin/static/admin/css/base.css + Remember to sync colours with front-end CSS. +*/ + +/* VARIABLE DEFINITIONS */ +html[data-theme="light"], +:root { + --primary: #1a2f69; + --secondary: #b0b4d9; + --primary-light: #8288bc; + --primary-black: #000000; + --primary-white: #fff; + + --header-color: var(--primary-white); + --header-branding-color: var(--primary-light); + --header-bg: var(--primary); + --header-link-color: #000000; + + --breadcrumbs-bg: var(--primary); + + --link-fg: var(--primary); + --link-selected-fg: var(--primary); + + --message-success-bg: #9fadd1; + --message-warning-bg: var(--primary-light); + + --selected-row: var(--secondary); + + --button-bg: var(--primary); + --button-hover-bg: #485D95; + --default-button-bg:#485D95; + --default-button-hover-bg: var(--primary); +} + +/* LINKS */ +a.section:link, a.section:visited { + color: #ffffff; +} + +/* HEADER */ +#header { + padding: 3px 15px 0 10px; + background: #ffffff; + color: #000; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--link-fg); +} + +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--primary-black); + color: var(--primary-white); +} + +.header-title { + margin: 10px 2px; +} + +.main-logo { + width: 140px;; + height: 73px; +} + + +.module h2, .module caption, .inline-group h2 { + color: var(--primary-white); +} diff --git a/app/static/css/dark_mode_override.css b/app/static/css/dark_mode_override.css new file mode 100644 index 00000000..aa594072 --- /dev/null +++ b/app/static/css/dark_mode_override.css @@ -0,0 +1,39 @@ +/* + Django admin styles + extended from https://github.com/django/django/blob/main/django/contrib/admin/static/admin/css/dark_mode.css + Remember to sync colours with front-end CSS. +*/ + +@media (prefers-color-scheme: dark) { + :root { + --primary: #1a2f69; + --secondary: #485d95; + --primary-black: #000000; + --primary-white: #fff; + + --link-hover-color: var(--primary-white); + --link-selected-fg: #6f94c6; + + --link-fg: #81d4fa; + + --selected-row: #2d4481; + } + } + + +html[data-theme="dark"] { + --primary: #1a2f69; + --secondary: #485d95; + --primary-black: #000000; + --primary-white: #fff; + + --link-fg: #81d4fa; + --link-hover-color: var(--primary-white); + --link-selected-fg: #6f94c6; + + --selected-row: #2d4481; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--primary-black); +} diff --git a/app/static/img/UP_logo.png b/app/static/img/UP_logo.png new file mode 100644 index 00000000..0edeecd3 Binary files /dev/null and b/app/static/img/UP_logo.png differ 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/static/img/lwimilinks.svg b/app/static/img/lwimilinks.svg new file mode 100644 index 00000000..4046ed74 --- /dev/null +++ b/app/static/img/lwimilinks.svg @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/templates/400.html b/app/templates/400.html new file mode 100644 index 00000000..da81bca7 --- /dev/null +++ b/app/templates/400.html @@ -0,0 +1,5 @@ +{% extends "base_error.html" %} +{% load i18n %} + +{% block error_title %}{% trans "Bad request (400)" %}{% endblock %} +{% block error_message %}{% trans "The server cannot process the request due to client error." %}{% endblock %} diff --git a/app/templates/403.html b/app/templates/403.html new file mode 100644 index 00000000..8b8dbb58 --- /dev/null +++ b/app/templates/403.html @@ -0,0 +1,5 @@ +{% extends "base_error.html" %} +{% load i18n %} + +{% block error_title %}{% trans "Forbidden (403)" %}{% endblock %} +{% block error_message %}{% trans "You do not have permission to access on this server." %}{% endblock %} diff --git a/app/templates/404.html b/app/templates/404.html new file mode 100644 index 00000000..649e22c3 --- /dev/null +++ b/app/templates/404.html @@ -0,0 +1,5 @@ +{% extends "base_error.html" %} +{% load i18n %} + +{% block error_title %}{% trans "Not found (404)" %}{% endblock %} +{% block error_message %}{% trans "The requested page was not found on this server." %}{% endblock %} diff --git a/app/templates/500.html b/app/templates/500.html new file mode 100644 index 00000000..be6be8d4 --- /dev/null +++ b/app/templates/500.html @@ -0,0 +1,7 @@ +{% extends "base_error.html" %} +{% load i18n %} + +{% block error_title %}{% trans "Internal Server Error (500)" %}{% endblock %} +{% block error_message %} +{% trans "The server encountered an unexpected condition that prevented it from fulfilling the request." %} +{% endblock %} diff --git a/app/templates/admin/base_site.html b/app/templates/admin/base_site.html new file mode 100644 index 00000000..84dfac52 --- /dev/null +++ b/app/templates/admin/base_site.html @@ -0,0 +1,34 @@ +{% extends "admin/base.html" %} +{% comment %} +Django Admin template +extended from: +https://github.com/django/django/blob/stable/5.0.x/django/contrib/admin/templates/admin/base.html +and +https://github.com/django/django/blob/stable/5.0.x/django/contrib/admin/templates/admin/base_site.html + +When upgrading Django, compare the changes in the above, to work out what +should be incorporated here, and update the links above that serve as the point +of comparison. +{% endcomment %} + +{% load static %} +{% load i18n %} + +{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block extrastyle %} + + + +{% endblock %} + +{% block branding %} +

+ + + +

+{% if user.is_anonymous %} + {% include "admin/color_theme_toggle.html" %} +{% endif %} +{% endblock %} diff --git a/app/templates/app/_nav_item.html b/app/templates/app/_nav_item.html new file mode 100644 index 00000000..5fc7f8ec --- /dev/null +++ b/app/templates/app/_nav_item.html @@ -0,0 +1,8 @@ + diff --git a/app/templates/app/_navbar_items.html b/app/templates/app/_navbar_items.html new file mode 100644 index 00000000..5cf6bcde --- /dev/null +++ b/app/templates/app/_navbar_items.html @@ -0,0 +1,14 @@ +{% load i18n %} +{% spaceless %} + +{% endspaceless %} diff --git a/app/templates/app/_pagination.html b/app/templates/app/_pagination.html new file mode 100644 index 00000000..9579749b --- /dev/null +++ b/app/templates/app/_pagination.html @@ -0,0 +1,69 @@ +{% load i18n %} +{% spaceless %} +{% if page_obj.has_previous or page_obj.has_next %} + +{% endif %} +{% endspaceless %} diff --git a/app/templates/app/_search_filter.html b/app/templates/app/_search_filter.html new file mode 100644 index 00000000..ddb229b8 --- /dev/null +++ b/app/templates/app/_search_filter.html @@ -0,0 +1,13 @@ +{% spaceless %} +
+ {{ field.label }} +
{# open if currently filtering on this field #} + {{ summary }} + {% for checkbox in field %} +
+ +
+ {% endfor %} +
+
+{% endspaceless %} diff --git a/app/templates/app/_subj_lang_institution_filter.html b/app/templates/app/_subj_lang_institution_filter.html new file mode 100644 index 00000000..158ea7e6 --- /dev/null +++ b/app/templates/app/_subj_lang_institution_filter.html @@ -0,0 +1,47 @@ +{% load i18n %} +{% spaceless %} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {% trans "Reset" %} +
+
+{% endspaceless %} diff --git a/app/templates/app/contact.html b/app/templates/app/contact.html new file mode 100644 index 00000000..3cec35ba --- /dev/null +++ b/app/templates/app/contact.html @@ -0,0 +1,60 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} + +{% block content %} +{% spaceless %} +
+
+

{% trans "Contact us" %}

+
+
+

{% trans "Contact details for more information" %}

+

+ {% blocktrans trimmed %} + Juan Steyn
+ South African Centre for Digital Language Resources (SADiLaR)
+ North-West University
+ South Africa + {% endblocktrans %} +

+

+27 (0)18 285-2750

+

info@sadilar.org

+
+ +
+

{% trans "Physical address" %}

+
+ {% blocktrans trimmed %} + Buildings F16 C & F16 D
+ North-West University
+ Potchefstroom Campus
+ Potchefstroom
+ South Africa + {% endblocktrans %} +
+

+ + {% trans "Directions" %} + +

+
+ +
+

{% trans "Postal address" %}

+
+ {% blocktrans trimmed %} + SADiLaR
+ Internal Box 340
+ Private bag X6001
+ Potchefstroom
+ South Africa
+ 2520 + {% endblocktrans %} +
+
+
+
+
+{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/css/local.css b/app/templates/app/css/local.css new file mode 100644 index 00000000..6c8b0994 --- /dev/null +++ b/app/templates/app/css/local.css @@ -0,0 +1,92 @@ +/*{% comment %} +NOTE: this is a Django template that renders a CSS file that is inlined +in the base template. This way it can easily be edited as a CSS file, and keeps +the base template simple. + +Django template comments are used to reduce the payload. +{% endcomment %}*/ +:root { + --primary: #1a2f69; + --sdlr-red: #d11f26; + --bs-primary: var(--primary); + --bs-primary-rgb: 26, 47, 105; {# identical to --primary #} + --bs-blue: var(--primary); +} +.btn-primary { + --bs-btn-bg: var(--primary); + --bs-btn-border-color: var(--primary); + --bs-btn-disabled-bg: var(--primary); +} +.card { + --bs-card-border-color: var(--sdlr-red); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-primary); + --bs-navbar-active-color: #fff; +} + +.limit-text-width { + max-width: 44rem; +} + +.logo100 { + max-height: 100px; + max-width: 100%; + aspect-ratio: auto; +} +/*{# Avoids content shift when icon comes late: #}*/ +.icon { + width: 1.5em; + display: inline-block; +} + +.checkbox-container { + max-height: 250px; + max-width: 600px; + overflow-y: auto; +} +/*{# Bootstrap 5.3's .invalid-feedback named for Django form error messages #}*/ +.errorlist { + width: 100%; + margin-top: .25rem; + font-size: .875em; + color: var(--bs-form-invalid-color); +} +/*{# start after 40%/3s: quick responses will be done already #}*/ +.htmx-indicator{ + display: none; + position: fixed; + z-index: 2000; + top: 0; + width: 100%; + height: 5px; + background: + linear-gradient(90deg, + #fff0 40%, + rgba(var(--bs-primary-rgb), 0.06) 55%, + rgba(var(--bs-primary-rgb), 0.3) 70%, + rgba(var(--bs-primary-rgb), 0.06) 85% + ) + #fff; + background-size: 300% 100%; + animation: l1 3s infinite linear; +} +@keyframes l1 { + 0% {background-position: right} +} +.htmx-request .htmx-indicator{ + display: block; +} +.htmx-request.htmx-indicator{ + display: block; +} + +/*{# The following two are needed for correct footer placement #}*/ +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} +footer { + margin-top: auto; +} diff --git a/app/templates/app/document_detail.html b/app/templates/app/document_detail.html new file mode 100644 index 00000000..5a62493d --- /dev/null +++ b/app/templates/app/document_detail.html @@ -0,0 +1,77 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} +{% load bs_icons %} + +{% block title %}{{ document.title }}{% endblock %} + +{% block content %} +{% spaceless %} +
+

{% trans "Documents" %} > {{ document.title }}

+ +
+

{{ document.title }}

+ {% if document.url %} + + {% endif %} + + {% if document.uploaded_file %} + + {% endif %} + +
+ {{ document.description | linebreaks}} +
+ +

+ + {{ document.institution.name }} + +

+ +

{% trans "License:" %} {{ document.license }}

+

{% trans "Category:" %} {{ document.document_type }}

+
+ + +
+ {% if document.subjects.all %} +
+

{% trans "Subjects" %}

+
    + {% for subject in document.subjects.all %} +
  • {% icon "subject" %}{{ subject.name }}
  • + {% endfor %} +
+
+ {% endif %} + + {% if document.languages.all %} +
+

{% trans "Languages" %}

+
    + {% for language in document.languages.all %} +
  • {% icon "language" %}{{ language.name }}
  • + {% endfor %} +
+
+ {% endif %} + +
+ +
+ +{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/documents.html b/app/templates/app/documents.html new file mode 100644 index 00000000..ecb18399 --- /dev/null +++ b/app/templates/app/documents.html @@ -0,0 +1,53 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} +{% load bs_icons %} + +{% block title %}{% trans "Documents" %}{% endblock %} + +{% block content %} +{% spaceless %} +
+

{% trans "Documents" %}

+ + {% include "app/_subj_lang_institution_filter.html" with view="documents"%} + + {% for item in documents %} +
{# additional indent simplifies comparison with projects.html #} +

+ + {{ item.document.title }} + +

+ {% if item.document.url %} + + {% endif %} +
+ {{ item.institution_name }} +
+

+ {{ item.description|truncatewords:30 }} + {% trans "Read more" %} +

+ + {% if item.languages %} +
{% icon "language" %} {{ item.languages }}
+ {% endif %} + + {% if item.subjects %} +
{% icon "subject" %} {{ item.subjects }}
+ {% endif %} +
+
+ {% endfor %} + + {% include "app/_pagination.html" %} + +
+{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/home.html b/app/templates/app/home.html new file mode 100644 index 00000000..0698c3ee --- /dev/null +++ b/app/templates/app/home.html @@ -0,0 +1,85 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} + +{% block content %} +{% spaceless %} +

{% trans "LwimiLinks" %}

+
+
+

{% trans "Explore" %}

+

+ {% blocktrans trimmed %} + LwimiLinks is a place for multilingual terminology and other useful language + resources. We encourage institutions to upload their resources, and to register + information on related projects. + {% endblocktrans %} +

+
+ + +
+

+ {% trans "Browse through the available information by one of the following categories:" %} +

+ +
+
+ +
+
+

{% trans "Background information" %}

+

+ {% 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 %} +

+
+
+ +{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/htmx-config.json b/app/templates/app/htmx-config.json new file mode 100644 index 00000000..aa9d640b --- /dev/null +++ b/app/templates/app/htmx-config.json @@ -0,0 +1,17 @@ +{% comment %} +NOTE: this is a Django template that renders a JSON file that is inlined +in the base template. This way it can easily be edited as a JSON file, and keeps +the base template simple. + +This stores the HTMX configuration. See +https://htmx.org/reference/#config + +Django template comments are used to reduce the payload. + +Consider e.g. `"timeout": 2000` for testing network error handling +{% endcomment %}{ + "responseHandling": [{# https://htmx.org/docs/#requests #} + {"code":"[23]..", "swap": true}, + {"code":"[45]..", "swap": true, "error":true}{# swap so that error pages show #} + ] +} diff --git a/app/templates/app/institution_detail.html b/app/templates/app/institution_detail.html new file mode 100644 index 00000000..75cea3d5 --- /dev/null +++ b/app/templates/app/institution_detail.html @@ -0,0 +1,62 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} +{% load bs_icons %} + +{% block title %}{{ institution.name }}{% endblock %} + +{% block content %} +{% spaceless %} +
+

{% trans "Institutions" %} > {{ institution.name }}

+ +
+ + + {% if institution.logo %} +
+ +
+ {% endif %} +
+ +
+ {% if projects %} +
+

{% trans "Projects" %}

+
    + {% for project in projects %} +
  • + {% icon "project" %} + {{ project.name }} +
  • + {% endfor %} +
+
+ {% endif %} + {% if documents %} +
+

{% trans "Documents" %}

+
    + {% for document in documents %} +
  • + {% icon "document" %} + {{ document.title }} +
  • + {% endfor %} +
+
+ {% endif %} +
+
+ +{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/institutions.html b/app/templates/app/institutions.html new file mode 100644 index 00000000..0c67a994 --- /dev/null +++ b/app/templates/app/institutions.html @@ -0,0 +1,55 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} +{% load bs_icons %} + +{% block title %}{% trans "Institutions" %}{% endblock %} + +{% block content %} +{% spaceless %} +

{% trans "Overview of institutions" %}

+
{# row has unwanted margin #} + {% for institution in institutions %} +
+
+
+
+

+ {{ institution.name }} +

+

{{ institution.abbreviation }}

+ {% if institution.project_count %} +

+ {% icon "project" %} + {% blocktrans count project_count=institution.project_count trimmed %} + {{ project_count }} project + {% plural %} + {{ project_count }} projects + {% endblocktrans %} +

+ {% endif %} + {% if institution.document_count %} +

+ {% icon "document" %} + {% blocktrans count document_count=institution.document_count trimmed %} + {{ document_count }} document + {% plural %} + {{ document_count }} documents + {% endblocktrans %} +

+ {% endif %} +
+ {% if institution.logo %} +
+ +
+ {% endif %} +
+
+
+ {% endfor %} +
+{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/js/debug-toolbar-handler.js b/app/templates/app/js/debug-toolbar-handler.js new file mode 100644 index 00000000..5d976162 --- /dev/null +++ b/app/templates/app/js/debug-toolbar-handler.js @@ -0,0 +1,18 @@ +/*{% comment %} +NOTE: this is a Django template that renders a JavaScript file that is inlined +in the base template. This way it can easily be edited as a JS file, and keeps +the base template simple. + +Since we use HTMX, add the event handler as recommended here: +https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#htmx +{% endcomment %}*/ +if (typeof window.htmx !== "undefined") { + htmx.on("htmx:afterSettle", function(detail) { + if ( + typeof window.djdt !== "undefined" + && detail.target instanceof HTMLBodyElement + ) { + djdt.show_toolbar(); + } + }); +} diff --git a/app/templates/app/js/page-status.js b/app/templates/app/js/page-status.js new file mode 100644 index 00000000..4ac26388 --- /dev/null +++ b/app/templates/app/js/page-status.js @@ -0,0 +1,65 @@ +/*{% load i18n %} +{% comment %} +NOTE: this is a Django template that renders a JavaScript file that is inlined +in the base template. This way it can easily be edited as a JS file, and keeps +the base template simple. + +Django template comments are used to reduce the payload. Furthermore JavaScript +comments are used to hide Django code from syntax highlighting. Note the use of +the `escapejs` filter when we create JS strings for correct escaping. + +This requires care to test well. Scenarios: + * Fresh load of pages 200 / 404. + * From freshly loaded 200 click on 404. + * From freshly loaded 404 click on 200. + * From a 200 page, click on anything while server is down. + * From a 404 page, click on anything while server is down. +In each case the error box should appear/disappear as necessary. Screen readers +should announce the error as an alert. Support is (as of 2024) not consistently +great. + +Furthermore: + - Test with JS disabled. + - Test with HTMX not (yet) loaded. + + If an HTMX event takes too long, #loader gives a visual indication and we set + the text for accessibility purposes. This will be quite chatty if done on + every request, so we only add the text if it takes longer than a timeout. If + things are working well, the new content should be announced (and announced in + good time), so the loader is only needed to confirm that the request is + submitted if things are taking long enough that the user might have doubts. + + Below we translate a few strings by Django, so we don't need the JavaScript + i18n infrastructure. +{% endcomment %} +{% trans 'Loading...' as loading %} +{% trans "Network error" as title %} +{% trans 'Couldn’t load the page. Check your network connection and try to refresh the page.' as message %} +*/ +function handleAfterRequest(evt) { + if (evt.detail.successful) { + clearTimeout(timeoutID); + document.getElementById("loader-text").innerText = ""; + } else { + if (typeof evt.detail.failed === "undefined") { + //{# Not an error page. Probably network problems. #} + document.getElementById("error-title").innerText = "{{ title | escapejs }}"; + document.getElementById("error-message").innerText = "{{ message | escapejs }}"; + } + const errorBlock = document.getElementById("error-block"); + errorBlock.removeAttribute("hidden"); + errorBlock.scrollIntoView(); + } +} + +function handleBeforeRequest(evt) { + document.getElementById("error-block").setAttribute("hidden", ""); + timeoutID = setTimeout(function () { + document.getElementById("loader-text").innerText = "{{ loading | escapejs }}"; + }, 1000) +} + +if (typeof htmx !== "undefined") { + htmx.on("htmx:beforeRequest", handleBeforeRequest); + htmx.on("htmx:afterRequest", handleAfterRequest); +} diff --git a/app/templates/app/languages.html b/app/templates/app/languages.html new file mode 100644 index 00000000..b417c69a --- /dev/null +++ b/app/templates/app/languages.html @@ -0,0 +1,64 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} +{% load bs_icons %} + +{% block title %}{% trans "Languages" %}{% endblock %} + +{% block content %} +{% spaceless %} +
+

{% trans "Information by language" %}

+
+ {% for item in language_data %} +

{{ item.language.name }}

+
+ {% if item.documents.exists %} +

{% trans "Documents" %}

+
+
    + {% for document in item.documents %} +
  • + {% icon "document" %} + {{ document.title }} +
  • + {% endfor %} +
+
+ {% else %} + {# Only show this in two-column mode to ensure consistent layout and placement #} + + {% endif %} +
+
+ {% if item.projects.exists %} +

{% trans "Projects" %}

+
+
    + {% for project in item.projects %} +
  • + {% icon "project" %} + {{ project.name }} +
  • + {% endfor %} +
+
+ {% else %} + {# Only show this in two-column mode to ensure consistent layout and placement #} + + {% endif %} +
+
+ {% endfor %} +
+ +
+ +{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/legal_notices.html b/app/templates/app/legal_notices.html new file mode 100644 index 00000000..05f520b2 --- /dev/null +++ b/app/templates/app/legal_notices.html @@ -0,0 +1,85 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} + +{% block content %} +{% spaceless %} +
+
+

{% trans "Legal Notices" %}

+

{% trans "Terms of use" %}

+

+ + {% trans "The terms of use statement for the SADiLaR website and services" %} + +

+

{% trans "Copyright" %}

+

+ {% 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 %} +

+

{% trans "Disclaimer" %}

+

+ {% 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 %} +

+

{% trans "Privacy Policy and Privacy Statement" %}

+

+ {% blocktrans trimmed %} + Please find the privacy policy and privacy statement for the SADiLaR website and services here. + {% endblocktrans %} +

+

{% trans "Protection of Personal Information" %}

+

+ {% 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 %} +

+

{% trans "Use of cookies" %}

+

+ {% 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 %} +

+

{% trans "User support" %}

+

+ {% 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 %} +

+
+
+{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/project_detail.html b/app/templates/app/project_detail.html new file mode 100644 index 00000000..e7656623 --- /dev/null +++ b/app/templates/app/project_detail.html @@ -0,0 +1,87 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} +{% load bs_icons %} + +{% block title %}{{ project.name }}{% endblock %} + +{% block content %} +{% spaceless %} +
+

{% trans "Projects" %} > {{ project.name }}

+ +
+ {# incorrect indent facilitates comparison with document_detail.html #} + + +
+ {% if logo %} + + {% endif %} +
+ +
+ +
+ {% if subjects %} +
+

{% trans "Subjects" %}

+
    + {% for subject in subjects %} +
  • {% icon "subject" %}{{ subject.name }}
  • + {% endfor %} +
+
+ {% endif %} + + {% if languages %} +
+

{% trans "Languages" %}

+
    + {% for language in languages %} +
  • {% icon "language" %}{{ language.name }}
  • + {% endfor %} +
+
+ {% endif %} + +
+ +
+ +{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/projects.html b/app/templates/app/projects.html new file mode 100644 index 00000000..64a21c6a --- /dev/null +++ b/app/templates/app/projects.html @@ -0,0 +1,72 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} +{% load bs_icons %} + +{% block title %}{% trans "Projects" %}{% endblock %} + +{% block content %} +{% spaceless %} +
+

{% trans "Projects" %}

+ + {% include "app/_subj_lang_institution_filter.html" with view="projects" %} + + {% for item in projects %} +
{# container adds unwanted padding #} + +
+
{# "wrong" indent simplifies comparison with documents.html #} +

+ + {{ item.project.name }} + +

+ {% if item.project.url %} + + {% endif %} +
+ {{ item.institution_name }} +
+ +
+
+ {% if item.logo %} + + {% endif %} +
+
+ +
+

+ {{ item.description|truncatewords:30 }} + {% trans "Read more" %} +

+ + {% if item.languages %} +
{% icon "language" %} {{ item.languages }}
+ {% endif %} + + {% if item.subjects %} +
{% icon "subject" %} {{ item.subjects }}
+ {% endif %} + + {% if item.date %} +
{% icon "date" %} {{ item.date }}
+ {% endif %} + +
+ +
+ {% endfor %} + +
+{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/search.html b/app/templates/app/search.html new file mode 100644 index 00000000..c3ba9eb6 --- /dev/null +++ b/app/templates/app/search.html @@ -0,0 +1,122 @@ +{% extends BASE_TEMPLATE %} +{% comment %} + +This page is bigger due to forms, etc. "{% spaceless %}" reduces unnecessary +spaces in a few places, but there are places where it can't be used. The spaces +in the headline can be relevant, e.g. term1 term2. + +The spaceless tags are kept on the left since they don't take part in the HTML +structure. +{% endcomment %} +{% load static %} +{% load i18n %} + +{% block title %}{{ request.GET.search }} - {% trans "Search" %}{% endblock %} + +{% block content %} +{% spaceless %} +
+

{% trans "Search and filter for information" %}

+
+
+ + {# Search #} +
+ +
+ +
+ + {% if page_obj.paginator.num_pages > 1 %} + + {% endif %} +
+{% endspaceless %} + + {# Results #} +
+ {% for result in page_obj %} +{% spaceless %} +
+
+

+ + {{ result.heading }} + +

+ + {% if result.associated_url %} + + {{ result.associated_url }} + + {% endif %} + + {% if result.extra %} +

{{ result.extra|truncatewords:20 }}

+ {% endif %} +{% endspaceless %} + {% if result.search_headline.strip %} +
+

… {{ result.search_headline|safe }} …

+
+ {% endif %} + {% comment "Left for debugging of search ranking" %} +

{{ result.rank }}

+ {% endcomment %} +
+ {% if result.logo_url %} +
+ +
+ {% endif %} +
+ {% empty %} +

{% trans "No results." %}

+ {% endfor %} +
+ + {# Pagination #} + {% include "app/_pagination.html" %} + +
+ + {# Filters #} +{% spaceless %} + {% if request.htmx.target != "search-main" %} +
{# target for link at the top #} +

{% trans "Filters" %}

+
+ {% with form=filter.form %} + {% include "app/_search_filter.html" with field=form.institution summary=_("Filter by institution") %} + {% include "app/_search_filter.html" with field=form.subjects summary=_("Filter by subject") %} + {% include "app/_search_filter.html" with field=form.languages summary=_("Filter by language") %} + {% endwith %} + +
+ + {% trans 'Reset' %} +
+ {% endif %} + +
+
+{% endspaceless %} +{% endblock content %} diff --git a/app/templates/app/subjects.html b/app/templates/app/subjects.html new file mode 100644 index 00000000..d143eb6f --- /dev/null +++ b/app/templates/app/subjects.html @@ -0,0 +1,65 @@ +{% extends BASE_TEMPLATE %} +{% load static %} +{% load i18n %} +{% load bs_icons %} + +{% block title %}{% trans "Subjects" %}{% endblock %} + +{% block content %} +{% spaceless %} +
+

{% trans "Information by subject" %}

+
+ {% for item in subject_data %} +

{{ item.subject.name }}

+
+ {% if item.documents.exists %} +

{% trans "Documents" %}

+
+
    + {% for document in item.documents %} +
  • + {% icon "document" %} + {{ document.title }} +
  • + {% endfor %} +
+
+ {% else %} + {# Only show this in two-column mode to ensure consistent layout and placement #} + + {% endif %} +
+
+ {% if item.projects.exists %} +

{% trans "Projects" %}

+
+
    + {% for project in item.projects %} +
  • + {% icon "project" %} + {{ project.name }} +
  • + {% endfor %} +
+
+ {% else %} + {# Only show this in two-column mode to ensure consistent layout and placement #} + + {% endif %} +
+
+ {% endfor %} +
+ + {% include "app/_pagination.html" %} + +
+{% endspaceless %} +{% endblock content %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 00000000..c736d04c --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,136 @@ +{% load static %} +{% load i18n %} +{% load bs_icons %} +{% spaceless %} + + + + {% block title %}{% trans "LwimiLinks" %}{% endblock %} + + + + + + + {# htmx JS at the end of body seems to create problems when navigating through browser history #} + + + + + + +
+{% block header %} +
+ +{% endblock header %} +
+ +{% block error %} +{# Render the error block hidden, so that it can be updated on the front-end. #} + +{% endblock error %} + +{% endspaceless %} + +
+{% block content %} + {% comment %} + The content block should probably be overridden by all pages. + The `aria-labelledby` means this base template expects some element with + id="main-heading" in the child template, otherwise this is invalid HTML. + {% endcomment %} +{% endblock content %} +
+ +{% spaceless %} + +{% endspaceless %} + + + +{% if debug %} + {% load django_htmx %} + {% django_htmx_script %} +{% endif %} +{% if debug_toolbar %} + +{% endif %} + diff --git a/app/templates/base_error.html b/app/templates/base_error.html new file mode 100644 index 00000000..1a18e526 --- /dev/null +++ b/app/templates/base_error.html @@ -0,0 +1,11 @@ +{% extends BASE_TEMPLATE|default:"base.html" %} +{# BASE_TEMPLATE can be undefined in certain error conditions, therefore `default` #} +{% load i18n %} + +{% block title %}{% trans "Error" %}{% endblock %} + +{% block error %} +{% with show_error=True%} + {{ block.super }} +{% endwith %} +{% endblock %} diff --git a/app/templates/base_htmx.html b/app/templates/base_htmx.html new file mode 100644 index 00000000..28dd7c40 --- /dev/null +++ b/app/templates/base_htmx.html @@ -0,0 +1,37 @@ +{% load i18n %} +{% spaceless %} +{# Required for HTMX title swapping to work #} + + {% block title %}{% trans "LwimiLinks" %}{% endblock %} + + +{% comment %} +Some elements outside of
might need to be updated. + - Navbar with indication of active/current page. + - Error messages +These use hx-swap-oob so that they are swapped in from the HTMX response. The +document title is automatically swapped by HTMX. +{% endcomment %} + +{% include "app/_navbar_items.html" %} + +{% block error %} + {% if show_error %} +

{% block error_title %}{% endblock %}

+

{% block error_message %}{% endblock %}

+ {% endif %} +{% endblock %} + +{% endspaceless %} +
+{% block content %}{# See notes in base.html #}{% endblock content %} +
+{% spaceless %} + +{% if debug_toolbar %} + +{% endif %} + +{% endspaceless %} diff --git a/app/templates/django/forms/field.html b/app/templates/django/forms/field.html new file mode 100644 index 00000000..2df6154a --- /dev/null +++ b/app/templates/django/forms/field.html @@ -0,0 +1,21 @@ +{% comment %} +Based on +https://github.com/django/django/blob/stable/5.0.x/django/forms/templates/django/forms/field.html +for some small customisations: + - helptext below field + - bootstrap classes +{% endcomment %} +{% spaceless %} +
+{% if field.use_fieldset %} +
+ {% if field.label %}{{ field.legend_tag }}{% endif %} +{% else %} + {% if field.label %}{{ field.label_tag }}{% endif %} +{% endif %} +{{ field.errors }} +{{ field }} +{% if field.help_text %}
{{ field.help_text|safe }}
{% endif %} +{% if field.use_fieldset %}
{% endif %} +
+{% endspaceless %} diff --git a/app/templates/registration/login.html b/app/templates/registration/login.html new file mode 100644 index 00000000..10968cca --- /dev/null +++ b/app/templates/registration/login.html @@ -0,0 +1,20 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}{% trans "Log In" %}{% endblock %} + +{% block account_content %} +

{% trans "Log In" %}

+
+ {% csrf_token %} + {{ form }} + {% trans "Forgot Password?" %} + +
+ +
+

+ {% trans "Don’t have an account?" %} + {% trans "Create one" %} +

+{% endblock %} diff --git a/app/templates/registration/password_change_done.html b/app/templates/registration/password_change_done.html new file mode 100644 index 00000000..24d2f913 --- /dev/null +++ b/app/templates/registration/password_change_done.html @@ -0,0 +1,9 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}{% trans "Password change successful" %}{% endblock %} + +{% block account_content %} +

{% trans "Password change successful" %}

+

{% trans "Your password was changed." %} +{% endblock %} diff --git a/app/templates/registration/password_change_form2.html b/app/templates/registration/password_change_form2.html new file mode 100644 index 00000000..762359ed --- /dev/null +++ b/app/templates/registration/password_change_form2.html @@ -0,0 +1,19 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}{% trans "Enter new password" %}{% endblock %} + +{% block account_content %} +

{% trans "Enter new password" %}

+

+ {% blocktrans trimmed %} + Please enter your old password, for security’s sake, and then enter your new + password twice so we can verify you typed it in correctly. + {% endblocktrans %} +

+
+ {% csrf_token %} + {{ form }} + +
+{% endblock %} diff --git a/app/templates/registration/password_reset_complete.html b/app/templates/registration/password_reset_complete.html new file mode 100644 index 00000000..2dc8c0d7 --- /dev/null +++ b/app/templates/registration/password_reset_complete.html @@ -0,0 +1,12 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}{% trans "Password reset complete" %}{% endblock %} + +{% block account_content %} +

{% trans "Password was successfully set" %}

+

{% trans "Your password has been set. You may go ahead and log in now." %}

+

+ {% trans "Log in" %} +

+{% endblock %} diff --git a/app/templates/registration/password_reset_confirm.html b/app/templates/registration/password_reset_confirm.html new file mode 100644 index 00000000..4ab6510f --- /dev/null +++ b/app/templates/registration/password_reset_confirm.html @@ -0,0 +1,26 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}{% trans "Enter new password" %}{% endblock %} + +{% block account_content %} + {% if validlink %} +

{% trans "Enter new password" %}

+

{% trans "Please enter your new password twice so we can verify you typed it in correctly." %}

+ +
{% csrf_token %} + {{ form }} + +
+ + {% else %} + +

{% trans "Password reset link was invalid" %}

+

+ {% blocktrans trimmed %} + The password reset link was invalid, possibly because it has + already been used. Please request a new password reset. + {% endblocktrans %} +

+ {% endif %} +{% endblock %} diff --git a/app/templates/registration/password_reset_done.html b/app/templates/registration/password_reset_done.html new file mode 100644 index 00000000..390d970d --- /dev/null +++ b/app/templates/registration/password_reset_done.html @@ -0,0 +1,20 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}Password reset sent{% endblock %} + +{% block account_content %} +

{% trans "Password reset sent" %}

+

+ {% blocktrans trimmed %} + We’ve emailed you instructions for setting your password, if an account + exists with the email you entered. You should receive them shortly. + {% endblocktrans %} +

+

+ {% blocktrans trimmed %} + If you don’t receive an email, please make sure you’ve entered the address + you registered with, and check your spam folder. + {% endblocktrans %} +

+{% endblock %} diff --git a/app/templates/registration/password_reset_form.html b/app/templates/registration/password_reset_form.html new file mode 100644 index 00000000..29277340 --- /dev/null +++ b/app/templates/registration/password_reset_form.html @@ -0,0 +1,19 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}{% trans "Password reset" %}{% endblock %} + +{% block account_content %} +

{% trans "Password reset" %}

+

+ {% blocktrans trimmed %} + Forgotten your password? Enter your email address below, and we’ll email + instructions for setting a new one. + {% endblocktrans %} +

+
+ {% csrf_token %} + {{ form }} + +
+{% endblock %} diff --git a/app/templates/registration/register.html b/app/templates/registration/register.html new file mode 100644 index 00000000..79880100 --- /dev/null +++ b/app/templates/registration/register.html @@ -0,0 +1,13 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}{% trans "Register" %}{% endblock %} + +{% block account_content %} +

{% trans "Register" %}

+
+ {% csrf_token %} + {{ form }} + +
+{% endblock %} diff --git a/app/templates/registration/registration_base.html b/app/templates/registration/registration_base.html new file mode 100644 index 00000000..d72902b7 --- /dev/null +++ b/app/templates/registration/registration_base.html @@ -0,0 +1,14 @@ +{% extends BASE_TEMPLATE %} + +{% spaceless %} +{% block content %} +
+
+
+ {% block account_content%} + {% endblock %} +
+
+
+{% endblock %} +{% endspaceless %} diff --git a/app/users/__init__.py b/app/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/users/admin.py b/app/users/admin.py new file mode 100644 index 00000000..19ed5e5e --- /dev/null +++ b/app/users/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from simple_history.admin import SimpleHistoryAdmin + +from .models import CustomUser + + +class CustomUserAdmin(UserAdmin, SimpleHistoryAdmin): + model = CustomUser + ordering = ["username"] + list_display = [ + "username", + "email", + "first_name", + "last_name", + "is_staff", + "is_active", + ] + + list_filter = [ + "username", + "email", + ] + + fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("institution", "languages", "subject")}),) + add_fieldsets = UserAdmin.add_fieldsets + history_list_display = ["username", "email", "first_name", "last_name", "is_staff", "is_active"] + + +admin.site.register(CustomUser, CustomUserAdmin) diff --git a/app/users/apps.py b/app/users/apps.py new file mode 100644 index 00000000..757ee5ca --- /dev/null +++ b/app/users/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" + verbose_name = _("Users") diff --git a/app/users/migrations/0001_initial.py b/app/users/migrations/0001_initial.py new file mode 100644 index 00000000..4e27c840 --- /dev/null +++ b/app/users/migrations/0001_initial.py @@ -0,0 +1,47 @@ +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('general', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('institution', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='general.institution')), + ('languages', models.ManyToManyField(to='general.language')), + ('subject', models.ManyToManyField(to='general.subject')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/app/users/migrations/0002_alter_customuser_languages_alter_customuser_subject.py b/app/users/migrations/0002_alter_customuser_languages_alter_customuser_subject.py new file mode 100644 index 00000000..96c97d95 --- /dev/null +++ b/app/users/migrations/0002_alter_customuser_languages_alter_customuser_subject.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2024-04-19 07:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='languages', + field=models.ManyToManyField(blank=True, to='general.language'), + ), + migrations.AlterField( + model_name='customuser', + name='subject', + field=models.ManyToManyField(blank=True, to='general.subject'), + ), + ] diff --git a/app/users/migrations/0003_historicalcustomuser.py b/app/users/migrations/0003_historicalcustomuser.py new file mode 100644 index 00000000..8f2685d1 --- /dev/null +++ b/app/users/migrations/0003_historicalcustomuser.py @@ -0,0 +1,47 @@ + +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0004_historicaldocumentfile_historicalinstitution_and_more'), + ('users', '0002_alter_customuser_languages_alter_customuser_subject'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalCustomUser', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(db_index=True, error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('institution', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='general.institution')), + ], + options={ + 'verbose_name': 'historical user', + 'verbose_name_plural': 'historical users', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/app/users/migrations/__init__.py b/app/users/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/users/models.py b/app/users/models.py new file mode 100644 index 00000000..52adb0d1 --- /dev/null +++ b/app/users/models.py @@ -0,0 +1,17 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from simple_history.models import HistoricalRecords + +from general.models import Institution, Language, Subject + + +class CustomUser(AbstractUser): + institution = models.ForeignKey(Institution, on_delete=models.CASCADE, null=True, blank=True) + languages = models.ManyToManyField(Language, blank=True) + subject = models.ManyToManyField(Subject, blank=True) + + # added simple historical records to the model + history = HistoricalRecords() + + def __str__(self): + return self.username diff --git a/app/users/tests.py b/app/users/tests.py new file mode 100644 index 00000000..a79ca8be --- /dev/null +++ b/app/users/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/app/users/views.py b/app/users/views.py new file mode 100644 index 00000000..fd0e0449 --- /dev/null +++ b/app/users/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here. diff --git a/doc/upgrades.md b/doc/upgrades.md new file mode 100644 index 00000000..1e0e8521 --- /dev/null +++ b/doc/upgrades.md @@ -0,0 +1,48 @@ +Upgrades +======== +Dependencies are currently spread out in a number of areas. This file tries to +summarise them. + +Policy around upgrades will eventually emerge. At the start of the project, the +focus is on the future, and therefore on the latest versions, possibly with a +focus on long-term support (LTS) versions. + + +Python +------ +See the Python version in `Dockerfile`, and maybe in some CI related bits. + +PostgreSQL +---------- +Check `docker-compose.ytml`, test setups in CI and probably things in any real +deployments. + +Python dependencies +------------------- +Review `requirements.txt` and related files. We should still move to transitive +pinning with something like poetry in future. We upgrade Django aggressively +until we reach version 5.2 (LTS), and after that we will likely stay on LTS +releases. + +Look up each package at https://pypi.org/ + +CSS +--- +Bootstrap is the main consideration. Versions are available here: +https://getbootstrap.com/docs/versions/ + +To review supported devices, see the appropriate page for the version, e.g. +https://getbootstrap.com/docs/5.3/getting-started/browsers-devices/ + +Obviously you might want to consider support information from +https://caniuse.com or MDN +https://developer.mozilla.org/en-US/docs/Web/CSS/word-break + +JavaScript +---------- +We try to keep JS dependencies minimal. Check `base.html`. Obviously some JS is +also included via Django, but we only consider Django as a whole. Some minimal +JS is also part of Bootstrap (see above). + +For HTMX, release information is available here: +https://github.com/bigskysoftware/htmx/releases diff --git a/doc/urls.md b/doc/urls.md new file mode 100644 index 00000000..f3caedc1 --- /dev/null +++ b/doc/urls.md @@ -0,0 +1,5 @@ +URL naming +========== + +If a URL is not really for public consumption, prefix it with an undrescore, +e.g. "/_health/". diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..63e24e21 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +# This is meant for local development, but should give an idea of what you +# can consider in production. + +services: + db: + image: postgres:16 + container_name: sadilar-terminology-db + environment: + - POSTGRES_DB=term_db + - POSTGRES_USER=sadilar + - POSTGRES_PASSWORD=${DB_PASSWORD} + volumes: + - .:/app + ports: + - "5432:5432" + web: + build: + context: . + dockerfile: Dockerfile-dev + container_name: sadilar-terminology-web + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - ./app:/app + - ./logging:/logging + - ./pdf_uploads:/pdf_uploads + - ./pdf_upload_completed:/pdf_upload_completed + ports: + - "8000:8000" + depends_on: + - db + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - SECRET_KEY=${SECRET_KEY} + - DEBUG=True + - DB_HOST=db + - DB_PORT=5432 + - DB_NAME=term_db # see POSTGRES_DB above + - DB_USER=sadilar # see POSTGRES_USER above + - DB_PASSWORD=${DB_PASSWORD} # see POSTGRES_PASSWORD above + - TESTING_DIR=/app/general/tests/files/ + - FEATURE_FLAG=search_feature + - EMAIL_HOST=${EMAIL_HOST-} + - EMAIL_HOST_USER=${EMAIL_HOST_USER-} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD-"none"} + - EMAIL_PORT=${EMAIL_PORT-"none"} + - EMAIL_BACKEND_CONSOLE=${EMAIL_BACKEND_CONSOLE:-True} + - EMAIL_USE_TLS=${EMAIL_USE_TLS:-True} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..11ebf19f --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e +set -o pipefail +set -x + +python manage.py check --deploy --database default +python manage.py migrate --no-input +python manage.py collectstatic --no-input +python manage.py compilemessages + +gunicorn app.wsgi:application --bind 0.0.0.0:8000 --config gunicorn.conf.py diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..5301288b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt +django-debug-toolbar +django-extensions +faker +pygraphviz +ruff diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 00000000..bfe3dc05 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,5 @@ +-r requirements.txt +django-debug-toolbar +django-extensions +faker +ruff diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..5abaf06f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +django-environ==0.11.2 +django-filter==24.3 +django-htmx==1.19.0 +django-simple-history==3.7.0 +django==5.0.8 +gunicorn==23.0.0 +pillow==10.4.0 +psycopg2-binary==2.9.9 +pypdf==4.3.1 +python-magic==0.4.27 +whitenoise==6.7.0