diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd57597..77dff34 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,3 +30,7 @@ repos: - id: commitlint stages: [commit-msg, manual] additional_dependencies: ["@commitlint/config-conventional"] + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.10.0 + hooks: + - id: shellcheck diff --git a/.prod/on_deploy.sh b/.prod/on_deploy.sh deleted file mode 100755 index dbba53c..0000000 --- a/.prod/on_deploy.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -python /app/manage.py migrate --noinput - -# Generate the admin user using the password given in the environment variables. -# If no password is set, the admin user gets a generated password which will -# be written in stdout so that it can be accessed during the initial deployment. -if [[ "$ADMIN_USER_PASSWORD" ]]; then - python /app/manage.py add_admin_user -u admin -p $ADMIN_USER_PASSWORD -e admin@hel.ninja -else - python /app/manage.py add_admin_user -u admin -e admin@hel.ninja -fi diff --git a/Dockerfile b/Dockerfile index 900ae50..dc4aea0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ============================== -FROM registry.access.redhat.com/ubi8/python-311 as appbase +FROM registry.access.redhat.com/ubi8/python-311 AS appbase # ============================== EXPOSE 8000/tcp @@ -7,7 +7,7 @@ EXPOSE 8000/tcp USER root RUN yum --disableplugin subscription-manager -y --allowerasing update \ - && yum --disableplugin subscription-manager -y install pcre-devel \ + && yum --disableplugin subscription-manager -y install pcre-devel nmap-ncat \ && yum --disableplugin subscription-manager -y clean all COPY scripts /scripts @@ -38,7 +38,7 @@ COPY --chown=1000:0 docker-entrypoint.sh /entrypoint/docker-entrypoint.sh ENTRYPOINT ["/entrypoint/docker-entrypoint.sh"] # ============================== -FROM appbase as development +FROM appbase AS development # ============================== COPY --chown=1000:0 requirements-dev.txt ./requirements-dev.txt @@ -49,7 +49,7 @@ ENV DEV_SERVER=1 COPY --chown=1000:0 . /app/ # ============================== -FROM appbase as staticbuilder +FROM appbase AS staticbuilder # ============================== ENV STATIC_ROOT /var/static @@ -57,7 +57,7 @@ COPY --chown=1000:0 . /app/ RUN SECRET_KEY="only-used-for-collectstatic" python manage.py collectstatic --noinput # ============================== -FROM appbase as production +FROM appbase AS production # ============================== # fatal: detected dubious ownership in repository at '/app' diff --git a/docker-compose.yml b/docker-compose.yml index a6fae12..65aa21e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.7" services: postgres: image: postgres:14 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 7d0d00d..70c6540 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,8 +3,13 @@ set -e # Wait for the database -if [ -z "$SKIP_DATABASE_CHECK" -o "$SKIP_DATABASE_CHECK" = "0" ]; then - wait-for-it.sh "${DATABASE_HOST}:${DATABASE_PORT-5432}" --timeout=30 +if [ -z "$SKIP_DATABASE_CHECK" ] || [ "$SKIP_DATABASE_CHECK" = "0" ]; then + until nc --verbose --wait 30 -z "${DATABASE_HOST}" "${DATABASE_PORT-5432}" + do + echo "Waiting for postgres database connection..." + sleep 1 + done + echo "Database is up!" fi # Apply database migrations @@ -17,14 +22,14 @@ fi # Create admin user. Generate password if there isn't one in the environment variables if [[ "$CREATE_ADMIN_USER" = "1" ]]; then if [[ "$ADMIN_USER_PASSWORD" ]]; then - ./manage.py add_admin_user -u admin -p $ADMIN_USER_PASSWORD -e admin@hel.ninja + ./manage.py add_admin_user -u admin -p "$ADMIN_USER_PASSWORD" -e admin@hel.ninja else ./manage.py add_admin_user -u admin -e admin@hel.ninja fi fi # Start server -if [[ ! -z "$@" ]]; then +if [[ -n "$*" ]]; then "$@" elif [[ "$DEV_SERVER" = "1" ]]; then python -Wd ./manage.py runserver 0.0.0.0:8000 diff --git a/documents/api/viewsets.py b/documents/api/viewsets.py index a8b016e..546fda0 100644 --- a/documents/api/viewsets.py +++ b/documents/api/viewsets.py @@ -443,6 +443,10 @@ class DocumentViewSet(AuditLoggingModelViewSet): filterset_class = DocumentFilterSet queryset = Document.objects.none() + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.request_data_extra_fields = {} + def get_queryset(self): user = self.request.user service = get_service_from_request(self.request) @@ -580,13 +584,20 @@ def partial_update(self, request, pk, *args, **kwargs): status_history_serializer.is_valid(raise_exception=True) status_history_serializer.save() - # Make sure the request data query dict is mutable, assign status_timestamp field to be updated. - request.data._mutable = True - request.data["status_timestamp"] = timezone.now() - request.data._mutable = False + # Update status_timestamp field also if the status has changed + self.request_data_extra_fields["status_timestamp"] = timezone.now() return super().partial_update(request, pk, *args, **kwargs) + def get_serializer(self, *args, **kwargs): + if self.request_data_extra_fields: + # Request data itself is immutable so we modify the copy of the + # data before it's given to the serializer. + data = kwargs["data"].copy() + data.update(self.request_data_extra_fields) + kwargs["data"] = data + return super().get_serializer(*args, **kwargs) + def update(self, request, *args, **kwargs): # Only allow for PATCH updates as described by the documentation # PUT requests will fail diff --git a/documents/tests/snapshots/snap_test_api_retrieve_document.py b/documents/tests/snapshots/snap_test_api_retrieve_document.py index 8cccb79..38f04d2 100644 --- a/documents/tests/snapshots/snap_test_api_retrieve_document.py +++ b/documents/tests/snapshots/snap_test_api_retrieve_document.py @@ -19,7 +19,7 @@ "id": "485af718-d9d1-46b9-ad7b-33ea054126e3", "locked_after": None, "metadata": {}, - "service": "service 212", + "service": "service 215", "status": { "timestamp": "2020-06-01T03:00:00+03:00", "value": "testing", @@ -47,7 +47,7 @@ "id": "485af718-d9d1-46b9-ad7b-33ea054126e3", "locked_after": None, "metadata": {}, - "service": "service 215", + "service": "service 218", "status": { "timestamp": "2020-06-01T03:00:00+03:00", "value": "testing", @@ -75,7 +75,7 @@ "id": "485af718-d9d1-46b9-ad7b-33ea054126e3", "locked_after": None, "metadata": {}, - "service": "service 214", + "service": "service 217", "status": { "timestamp": "2020-06-01T03:00:00+03:00", "value": "testing", diff --git a/documents/tests/test_api_patch_document.py b/documents/tests/test_api_patch_document.py index 3640431..1645ff3 100644 --- a/documents/tests/test_api_patch_document.py +++ b/documents/tests/test_api_patch_document.py @@ -6,6 +6,7 @@ from dateutil.relativedelta import relativedelta from dateutil.utils import today from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone from freezegun import freeze_time from guardian.shortcuts import assign_perm from rest_framework import status @@ -663,3 +664,46 @@ def test_audit_log_is_created_when_patching(user, attachments, ip_address): ).count() == 1 ) + + +@pytest.mark.parametrize("format", ["multipart", "json"]) +def test_patch_status_update_in_multiple_formats(service_api_client, format): + """Updating status (and status_timestamp) should work with multipart and json + formatting. + """ + document = DocumentFactory( + service=service_api_client.service, + status="testing", + ) + + response = service_api_client.patch( + reverse("documents-detail", args=[document.id]), + {"status": "changed status"}, + format=format, + ) + + assert response.status_code == status.HTTP_200_OK + document.refresh_from_db() + assert document.status == "changed status" + + +@freeze_time("2021-06-30T12:00:00") +def test_patch_status_timestamp_is_updated(service_api_client): + """When updating the status of a document, the status_timestamp should also get + updated automatically. + """ + document = DocumentFactory( + service=service_api_client.service, + status="testing", + ) + + with freeze_time("2024-12-20T12:00:00"): + dt = timezone.now() + response = service_api_client.patch( + reverse("documents-detail", args=[document.id]), + {"status": "changed status"}, + ) + + assert response.status_code == status.HTTP_200_OK + document.refresh_from_db() + assert document.status_timestamp == dt diff --git a/scripts/wait-for-it.sh b/scripts/wait-for-it.sh deleted file mode 100755 index e9b80f9..0000000 --- a/scripts/wait-for-it.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -# Use this script to test if a given TCP host/port are available -# Source: https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh - -WAITFORIT_cmdname=${0##*/} - -echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } - -usage() -{ - cat << USAGE >&2 -Usage: - $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] - -h HOST | --host=HOST Host or IP under test - -p PORT | --port=PORT TCP port under test - Alternatively, you specify the host and port as host:port - -s | --strict Only execute subcommand if the test succeeds - -q | --quiet Don't output any status messages - -t TIMEOUT | --timeout=TIMEOUT - Timeout in seconds, zero for no timeout - -- COMMAND ARGS Execute command with args after the test finishes -USAGE - exit 1 -} - -wait_for() -{ - if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then - echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" - else - echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" - fi - WAITFORIT_start_ts=$(date +%s) - while : - do - if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then - nc -z $WAITFORIT_HOST $WAITFORIT_PORT - WAITFORIT_result=$? - else - (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 - WAITFORIT_result=$? - fi - if [[ $WAITFORIT_result -eq 0 ]]; then - WAITFORIT_end_ts=$(date +%s) - echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" - break - fi - sleep 1 - done - return $WAITFORIT_result -} - -wait_for_wrapper() -{ - # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 - if [[ $WAITFORIT_QUIET -eq 1 ]]; then - timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & - else - timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & - fi - WAITFORIT_PID=$! - trap "kill -INT -$WAITFORIT_PID" INT - wait $WAITFORIT_PID - WAITFORIT_RESULT=$? - if [[ $WAITFORIT_RESULT -ne 0 ]]; then - echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" - fi - return $WAITFORIT_RESULT -} - -# process arguments -while [[ $# -gt 0 ]] -do - case "$1" in - *:* ) - WAITFORIT_hostport=(${1//:/ }) - WAITFORIT_HOST=${WAITFORIT_hostport[0]} - WAITFORIT_PORT=${WAITFORIT_hostport[1]} - shift 1 - ;; - --child) - WAITFORIT_CHILD=1 - shift 1 - ;; - -q | --quiet) - WAITFORIT_QUIET=1 - shift 1 - ;; - -s | --strict) - WAITFORIT_STRICT=1 - shift 1 - ;; - -h) - WAITFORIT_HOST="$2" - if [[ $WAITFORIT_HOST == "" ]]; then break; fi - shift 2 - ;; - --host=*) - WAITFORIT_HOST="${1#*=}" - shift 1 - ;; - -p) - WAITFORIT_PORT="$2" - if [[ $WAITFORIT_PORT == "" ]]; then break; fi - shift 2 - ;; - --port=*) - WAITFORIT_PORT="${1#*=}" - shift 1 - ;; - -t) - WAITFORIT_TIMEOUT="$2" - if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi - shift 2 - ;; - --timeout=*) - WAITFORIT_TIMEOUT="${1#*=}" - shift 1 - ;; - --) - shift - WAITFORIT_CLI=("$@") - break - ;; - --help) - usage - ;; - *) - echoerr "Unknown argument: $1" - usage - ;; - esac -done - -if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then - echoerr "Error: you need to provide a host and port to test." - usage -fi - -WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} -WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} -WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} -WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} - -# Check to see if timeout is from busybox? -WAITFORIT_TIMEOUT_PATH=$(type -p timeout) -WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) - -WAITFORIT_BUSYTIMEFLAG="" -if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then - WAITFORIT_ISBUSY=1 - # Check if busybox timeout uses -t flag - # (recent Alpine versions don't support -t anymore) - if timeout &>/dev/stdout | grep -q -e '-t '; then - WAITFORIT_BUSYTIMEFLAG="-t" - fi -else - WAITFORIT_ISBUSY=0 -fi - -if [[ $WAITFORIT_CHILD -gt 0 ]]; then - wait_for - WAITFORIT_RESULT=$? - exit $WAITFORIT_RESULT -else - if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then - wait_for_wrapper - WAITFORIT_RESULT=$? - else - wait_for - WAITFORIT_RESULT=$? - fi -fi - -if [[ $WAITFORIT_CLI != "" ]]; then - if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then - echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" - exit $WAITFORIT_RESULT - fi - exec "${WAITFORIT_CLI[@]}" -else - exit $WAITFORIT_RESULT -fi diff --git a/services/tests/conftest.py b/services/tests/conftest.py index 141104f..71225a9 100644 --- a/services/tests/conftest.py +++ b/services/tests/conftest.py @@ -18,6 +18,7 @@ def service_api_client(api_client): credentials = {settings.API_KEY_CUSTOM_HEADER: key} api_client.credentials(**credentials) + api_client.service = service return api_client