diff --git a/README.md b/README.md index 6f553a9..072643f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ Other collections, such as [the ones in the AAOF Legacy Collection](https://www. - `bfd9000_web/`: Django application for managing and viewing the BFD9000 data. To run the application, follow the instructions in `bfd9000_web/README.md`. +## Docker Notes + +When you run the app with Docker you will need a one-time volume ownership fix so the app's non-root user can write media files. + +See `docker.md` for the exact commands and troubleshooting steps. + ## Django Bootstrap The archive app provides a convenience management command to initialize a local development database: diff --git a/bfd9000_web/BFD9000/urls.py b/bfd9000_web/BFD9000/urls.py index 4f39ac7..e289fb8 100644 --- a/bfd9000_web/BFD9000/urls.py +++ b/bfd9000_web/BFD9000/urls.py @@ -17,15 +17,24 @@ from django.contrib import admin from django.contrib.auth import views as auth_views +from django.contrib.auth import logout +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect from django.urls import path, include from django.conf import settings from django.conf.urls.static import static from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +def logout_view(request: HttpRequest) -> HttpResponse: + """Log out on any request method and redirect to login.""" + logout(request) + return redirect('login') + urlpatterns = [ path('admin/', admin.site.urls), path('login/', auth_views.LoginView.as_view(template_name='archive/login.html'), name='login'), - path('logout/', auth_views.LogoutView.as_view(next_page='login'), name='logout'), + path('logout/', logout_view, name='logout'), path('', include('archive.urls')), # OpenAPI Schema path('api/schema/', SpectacularAPIView.as_view(), name='schema'), diff --git a/bfd9000_web/archive/management/commands/setup_curator_group.py b/bfd9000_web/archive/management/commands/setup_curator_group.py new file mode 100644 index 0000000..1e6ab6f --- /dev/null +++ b/bfd9000_web/archive/management/commands/setup_curator_group.py @@ -0,0 +1,40 @@ +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand + +from archive.models import Encounter, Record, Subject + +class Command(BaseCommand): + help = ( + "Create or update the Curator group with required add/change permissions. " + "Safe to rerun: removes delete_* and auth.* permissions on each run, " + "but does not remove other manually granted permissions. " + "See docs/permissions.md for the full policy." + ) + + def handle(self, *args, **options): + group, _ = Group.objects.get_or_create(name="Curator") + subject_ct = ContentType.objects.get_for_model(Subject) + encounter_ct = ContentType.objects.get_for_model(Encounter) + record_ct = ContentType.objects.get_for_model(Record) + + subject_add = Permission.objects.get(codename="add_subject", content_type=subject_ct) + subject_change = Permission.objects.get(codename="change_subject", content_type=subject_ct) + encounter_add = Permission.objects.get(codename="add_encounter", content_type=encounter_ct) + encounter_change = Permission.objects.get(codename="change_encounter", content_type=encounter_ct) + + required_perms = [subject_add, subject_change, encounter_add, encounter_change] + for perm in required_perms: + group.permissions.add(perm) + + forbidden_perms = Permission.objects.filter( + content_type__in=[subject_ct, encounter_ct, record_ct], + codename__startswith="delete_", + ) + auth_management_perms = Permission.objects.filter(content_type__app_label="auth") + forbidden_perms = forbidden_perms | auth_management_perms + + for perm in forbidden_perms: + group.permissions.remove(perm) + + self.stdout.write(self.style.SUCCESS("Curator group permissions updated.")) diff --git a/bfd9000_web/archive/permissions.py b/bfd9000_web/archive/permissions.py new file mode 100644 index 0000000..7d331c8 --- /dev/null +++ b/bfd9000_web/archive/permissions.py @@ -0,0 +1,52 @@ +# pyright: reportIncompatibleMethodOverride=false + +from typing import Any + +from django.db.models import Model +from rest_framework.permissions import SAFE_METHODS, BasePermission +from rest_framework.request import Request + + +class CuratorOrSuperuserEditPermission(BasePermission): + """Require model add/change perms for writes and auth for reads.""" + + def has_permission(self, request: Request, view: Any): # pyright: ignore[reportIncompatibleMethodOverride] + if not request.user or not request.user.is_authenticated: + return False + if request.user.is_superuser: + return True + if request.method in SAFE_METHODS: + return True + if request.method == "DELETE": + return False + + qs = getattr(view, "queryset", None) + if qs is None and hasattr(view, "get_queryset"): + try: + qs = view.get_queryset() + except Exception: + qs = None + model: type[Model] | None = getattr(qs, "model", None) + if model is None: + return False + + app_label = model._meta.app_label + model_name = model._meta.model_name + if model_name is None: + return False + if request.method == "POST": + return request.user.has_perm(f"{app_label}.add_{model_name}") + if request.method in {"PUT", "PATCH"}: + return request.user.has_perm(f"{app_label}.change_{model_name}") + return False + + +class RecordPermission(BasePermission): + """Allow authenticated users to read/create/update records.""" + + def has_permission(self, request: Request, view: Any): # pyright: ignore[reportIncompatibleMethodOverride] + if not request.user or not request.user.is_authenticated: + return False + if request.method == "DELETE": + return bool(request.user.is_superuser) + return True diff --git a/bfd9000_web/archive/static/favicon.svg b/bfd9000_web/archive/static/favicon.svg new file mode 100644 index 0000000..6f2dd9e --- /dev/null +++ b/bfd9000_web/archive/static/favicon.svg @@ -0,0 +1,5 @@ + diff --git a/bfd9000_web/archive/templates/archive/base.html b/bfd9000_web/archive/templates/archive/base.html index f0b4427..532ecfd 100644 --- a/bfd9000_web/archive/templates/archive/base.html +++ b/bfd9000_web/archive/templates/archive/base.html @@ -5,7 +5,7 @@