diff --git a/.circleci/config.yml b/.circleci/config.yml index 854ce2af..ea39d67d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,6 +9,7 @@ defaults: GOOGLE_PROJECT_ID: "excellent-zoo-300106" GOOGLE_COMPUTE_ZONE: "us-central1-a" GOOGLE_CLUSTER_NAME: "ipno" + CLOUDSDK_CORE_DISABLE_PROMPTS: "1" - &set-push-env USER_NAME: "East Agile" USER_EMAIL: "open-source@eastagile.com" @@ -148,7 +149,7 @@ jobs: django_migrate: description: Migrate database machine: - image: ubuntu-2004:202010-01 + image: ubuntu-2204:current environment: *gcloud-env steps: - run: *set-gcloud-service-key @@ -169,7 +170,7 @@ jobs: django_collect_static: description: Collect static machine: - image: ubuntu-2004:202010-01 + image: ubuntu-2204:current environment: *gcloud-env steps: - run: *set-gcloud-service-key @@ -190,7 +191,7 @@ jobs: deploy: description: Deploy application to Google Kubernetes Engine machine: - image: ubuntu-2004:202010-01 + image: ubuntu-2204:current environment: *gcloud-env steps: - checkout diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..9d92b2b8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,256 @@ +name: Deploy + +on: + push: + branches: + - main + - staging + +env: + GOOGLE_PROJECT_ID: excellent-zoo-300106 + GOOGLE_COMPUTE_ZONE: us-central1-a + GOOGLE_CLUSTER_NAME: ipno + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:13.7 + env: + POSTGRES_USER: ipno + POSTGRES_DB: ipno + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + elasticsearch: + image: elasticsearch:7.10.1 + env: + discovery.type: single-node + ports: + - 9200:9200 + + redis: + image: redis:7.0.5 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.8' + cache: 'pip' + + - name: Install native packages + run: | + sudo apt-get update + sudo apt-get -y install ghostscript python3-wand + + - name: Install dependencies + run: | + pip install "pip<24.1" + pip install -r requirements/dev.txt + + - name: Install spacy model + run: python -m spacy download en_core_web_sm + + - name: Lint + run: bin/lint.sh + + - name: Run tests + env: + IPNO_API_KEY: ${{ secrets.IPNO_API_KEY }} + DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }} + POSTGRES_HOST: localhost + POSTGRES_USER: ipno + POSTGRES_DB: ipno + POSTGRES_PASSWORD: password + ELASTICSEARCH_HOST: localhost:9200 + CELERY_BROKER_URL: redis://localhost:6379 + run: | + python -m pytest --cov-report term --cov=ipno ipno + + build-and-push: + needs: test + runs-on: ubuntu-latest + outputs: + image-tag: ${{ steps.set-tag.outputs.tag }} + namespace: ${{ steps.set-env.outputs.NAMESPACE }} + deploy-env: ${{ steps.set-env.outputs.DEPLOY_ENV }} + celery-workers: ${{ steps.set-env.outputs.CELERY_NUM_WORKER }} + cloud-sql-db: ${{ steps.set-env.outputs.CLOUD_SQL_DB }} + + steps: + - uses: actions/checkout@v4 + + - name: Set deployment environment + id: set-env + run: | + if [ "${{ github.ref_name }}" == "main" ]; then + echo "DEPLOY_ENV=--production" >> $GITHUB_OUTPUT + echo "NAMESPACE=ipno-production" >> $GITHUB_OUTPUT + echo "DOCKER_PRETAG=backend-production" >> $GITHUB_OUTPUT + echo "CELERY_NUM_WORKER=4" >> $GITHUB_OUTPUT + echo "CLOUD_SQL_DB=ipno-database-production" >> $GITHUB_OUTPUT + else + echo "DEPLOY_ENV=--staging" >> $GITHUB_OUTPUT + echo "NAMESPACE=ipno-staging" >> $GITHUB_OUTPUT + echo "DOCKER_PRETAG=backend-staging" >> $GITHUB_OUTPUT + echo "CELERY_NUM_WORKER=2" >> $GITHUB_OUTPUT + echo "CLOUD_SQL_DB=ipno-database-staging" >> $GITHUB_OUTPUT + fi + + - name: Set image tag + id: set-tag + run: | + echo "tag=${{ steps.set-env.outputs.DOCKER_PRETAG }}-${{ github.run_number }}" >> $GITHUB_OUTPUT + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCLOUD_SERVICE_KEY_BASE64 }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker + run: gcloud auth configure-docker + + - name: Build image + run: docker build -t ipno-backend . + + - name: Tag and push image + run: | + BRANCH_SLUG="${{ github.ref_name }}" + BRANCH_SLUG="${BRANCH_SLUG//\//-}" + + docker tag ipno-backend us.gcr.io/${{ env.GOOGLE_PROJECT_ID }}/ipno-backend:${{ steps.set-tag.outputs.tag }} + docker tag ipno-backend us.gcr.io/${{ env.GOOGLE_PROJECT_ID }}/ipno-backend:$BRANCH_SLUG + docker tag ipno-backend us.gcr.io/${{ env.GOOGLE_PROJECT_ID }}/ipno-backend:${{ steps.set-env.outputs.DOCKER_PRETAG }}-latest + + docker push us.gcr.io/${{ env.GOOGLE_PROJECT_ID }}/ipno-backend:${{ steps.set-tag.outputs.tag }} + docker push us.gcr.io/${{ env.GOOGLE_PROJECT_ID }}/ipno-backend:$BRANCH_SLUG + docker push us.gcr.io/${{ env.GOOGLE_PROJECT_ID }}/ipno-backend:${{ steps.set-env.outputs.DOCKER_PRETAG }}-latest + + migrate: + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCLOUD_SERVICE_KEY_BASE64 }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + with: + install_components: 'gke-gcloud-auth-plugin,kubectl' + + - name: Get GKE credentials + run: | + gcloud container clusters get-credentials ${{ env.GOOGLE_CLUSTER_NAME }} \ + --zone ${{ env.GOOGLE_COMPUTE_ZONE }} \ + --project ${{ env.GOOGLE_PROJECT_ID }} + + - name: Run migrations + env: + CLOUD_SQL_DATABASE: ${{ needs.build-and-push.outputs.cloud-sql-db }} + run: | + bin/run_spot_job.sh ${{ needs.build-and-push.outputs.deploy-env }} ${{ needs.build-and-push.outputs.image-tag }} migrate + + collect-static: + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCLOUD_SERVICE_KEY_BASE64 }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + with: + install_components: 'gke-gcloud-auth-plugin,kubectl' + + - name: Get GKE credentials + run: | + gcloud container clusters get-credentials ${{ env.GOOGLE_CLUSTER_NAME }} \ + --zone ${{ env.GOOGLE_COMPUTE_ZONE }} \ + --project ${{ env.GOOGLE_PROJECT_ID }} + + - name: Collect static + env: + CLOUD_SQL_DATABASE: ${{ needs.build-and-push.outputs.cloud-sql-db }} + run: | + bin/run_spot_job.sh ${{ needs.build-and-push.outputs.deploy-env }} ${{ needs.build-and-push.outputs.image-tag }} collectstatic --no-input + + deploy: + needs: [build-and-push, migrate, collect-static] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCLOUD_SERVICE_KEY_BASE64 }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + with: + install_components: 'gke-gcloud-auth-plugin,kubectl' + + - name: Get GKE credentials + run: | + gcloud container clusters get-credentials ${{ env.GOOGLE_CLUSTER_NAME }} \ + --zone ${{ env.GOOGLE_COMPUTE_ZONE }} \ + --project ${{ env.GOOGLE_PROJECT_ID }} + + - name: Create initial project config + env: + CLOUD_SQL_DATABASE: ${{ needs.build-and-push.outputs.cloud-sql-db }} + run: | + bin/run_spot_job.sh ${{ needs.build-and-push.outputs.deploy-env }} ${{ needs.build-and-push.outputs.image-tag }} init_project_config + + - name: Deploy celery + env: + BACKEND_IMAGE_TAG: ${{ needs.build-and-push.outputs.image-tag }} + CELERY_NUM_WORKER: ${{ needs.build-and-push.outputs.celery-workers }} + CLOUD_SQL_DATABASE: ${{ needs.build-and-push.outputs.cloud-sql-db }} + run: | + cat kubernetes/celery.yml | envsubst | kubectl apply -n ${{ needs.build-and-push.outputs.namespace }} -f - + + - name: Deploy application + env: + BACKEND_IMAGE_TAG: ${{ needs.build-and-push.outputs.image-tag }} + CLOUD_SQL_DATABASE: ${{ needs.build-and-push.outputs.cloud-sql-db }} + run: | + cat kubernetes/ipno.yml | envsubst | kubectl apply -n ${{ needs.build-and-push.outputs.namespace }} -f - + + - name: Setup cronjobs + env: + CLOUD_SQL_DATABASE: ${{ needs.build-and-push.outputs.cloud-sql-db }} + DAILY_TIME: ${{ github.ref_name == 'main' && secrets.PROD_DAILY_TIME || secrets.STAGING_DAILY_TIME }} + run: | + bin/run_cronjob.sh ${{ needs.build-and-push.outputs.deploy-env }} ${{ needs.build-and-push.outputs.image-tag }} run_daily_tasks "$DAILY_TIME" + + - name: Verify deployment + run: | + kubectl rollout status deployment/ipno-backend -n ${{ needs.build-and-push.outputs.namespace }} --timeout=300s diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..8b253902 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,110 @@ +name: Test + +on: + push: + branches-ignore: + - main + - staging + - develop + pull_request: + branches: + - main + - staging + - develop + +env: + POSTGRES_USER: ipno + POSTGRES_DB: ipno + POSTGRES_PASSWORD: password + POSTGRES_HOST: localhost + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:13.7 + env: + POSTGRES_USER: ipno + POSTGRES_DB: ipno + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + elasticsearch: + image: elasticsearch:7.10.1 + env: + discovery.type: single-node + ports: + - 9200:9200 + options: >- + --health-cmd "curl -f http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + redis: + image: redis:7.0.5 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.8' + cache: 'pip' + + - name: Install native packages + run: | + sudo apt-get update + sudo apt-get -y install ghostscript python3-wand + + - name: Install dependencies + run: | + pip install "pip<24.1" + pip install -r requirements/dev.txt + + - name: Install spacy model + run: | + python -m spacy download en_core_web_sm + + - name: Lint + run: bin/lint.sh + + - name: Run tests + env: + IPNO_API_KEY: ${{ secrets.IPNO_API_KEY }} + DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }} + ELASTICSEARCH_HOST: localhost:9200 + CELERY_BROKER_URL: redis://localhost:6379 + run: | + mkdir -p test-results + python -m pytest --cov-report term --cov=ipno ipno --junitxml=test-results/junit.xml + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: test-results/ + + - name: Coveralls + env: + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + run: python -m coveralls + continue-on-error: true diff --git a/Dockerfile b/Dockerfile index b3268935..45909663 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,14 +2,18 @@ FROM python:3.8.5 ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=$PYTHONPATH:/code/ipno -RUN apt-get update && apt-get -y install ghostscript netcat +# Use Debian archive for Buster (deprecated but still available) +RUN sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list && \ + sed -i 's|security.debian.org|archive.debian.org/|g' /etc/apt/sources.list && \ + sed -i '/stretch-updates/d' /etc/apt/sources.list && \ + apt-get update && apt-get -y install ghostscript netcat COPY policy.xml /etc/ImageMagick-6/policy.xml WORKDIR /code ADD requirements /code/requirements -RUN pip install -U pip setuptools +RUN pip install -U "pip<24.1" setuptools RUN pip install -r requirements/dev.txt diff --git a/bin/deploy_staging.sh b/bin/deploy_staging.sh new file mode 100755 index 00000000..7729eb21 --- /dev/null +++ b/bin/deploy_staging.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -e + +echo "================================================" +echo " LLEAD Backend - Manual Staging Deployment" +echo "================================================" +echo "" + +# GKE Configuration +export GOOGLE_PROJECT_ID="excellent-zoo-300106" +export GOOGLE_CLUSTER_NAME="ipno" +export GOOGLE_COMPUTE_ZONE="us-central1-a" + +# Staging-specific configuration +export NAMESPACE_ENV="ipno-staging" +export CLOUD_SQL_DATABASE="ipno-database-staging" +export CELERY_NUM_WORKER=2 + +# Check if image tag is provided +if [ -z "$1" ]; then + echo "Usage: $0 [skip-migrations]" + echo "" + echo "Examples:" + echo " $0 backend-staging-latest # Deploy latest staging image" + echo " $0 backend-staging-123 # Deploy specific build" + echo " $0 backend-staging-latest skip # Deploy without migrations" + echo "" + echo "Available images:" + gcloud container images list-tags us.gcr.io/$GOOGLE_PROJECT_ID/ipno-backend \ + --filter="tags:backend-staging*" \ + --limit=10 \ + --format="table(tags,timestamp)" 2>/dev/null || echo " (Run 'gcloud auth login' if you see authentication errors)" + exit 1 +fi + +export BACKEND_IMAGE_TAG="$1" +SKIP_MIGRATIONS="$2" + +echo "Configuration:" +echo " Project: $GOOGLE_PROJECT_ID" +echo " Cluster: $GOOGLE_CLUSTER_NAME" +echo " Namespace: $NAMESPACE_ENV" +echo " Database: $CLOUD_SQL_DATABASE" +echo " Image Tag: $BACKEND_IMAGE_TAG" +echo " Celery Workers: $CELERY_NUM_WORKER" +echo "" + +# Verify authentication +echo "Checking authentication..." +if ! gcloud auth list --filter=status:ACTIVE --format="value(account)" | grep -q .; then + echo "Error: Not authenticated with Google Cloud. Run 'gcloud auth login'" + exit 1 +fi + +# Configure kubectl +echo "Configuring kubectl..." +gcloud container clusters get-credentials $GOOGLE_CLUSTER_NAME \ + --zone $GOOGLE_COMPUTE_ZONE \ + --project $GOOGLE_PROJECT_ID \ + --quiet + +# Set namespace context +kubectl config set-context --current --namespace=$NAMESPACE_ENV + +# Verify image exists +echo "" +echo "Verifying image exists..." +if ! gcloud container images describe us.gcr.io/$GOOGLE_PROJECT_ID/ipno-backend:$BACKEND_IMAGE_TAG &>/dev/null; then + echo "Warning: Image us.gcr.io/$GOOGLE_PROJECT_ID/ipno-backend:$BACKEND_IMAGE_TAG may not exist" + read -p "Continue anyway? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +# Run migrations (unless skipped) +if [ "$SKIP_MIGRATIONS" != "skip" ]; then + echo "" + echo "Running database migrations..." + if ! bin/run_spot_job.sh --staging $BACKEND_IMAGE_TAG migrate; then + echo "Error: Migration failed!" + read -p "Continue with deployment anyway? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + fi + + echo "" + echo "Collecting static files..." + bin/run_spot_job.sh --staging $BACKEND_IMAGE_TAG collectstatic --no-input || true +fi + +# Deploy Celery +echo "" +echo "Deploying Celery workers..." +cat kubernetes/celery.yml | envsubst | kubectl apply -n $NAMESPACE_ENV -f - + +# Deploy Backend +echo "" +echo "Deploying backend application..." +cat kubernetes/ipno.yml | envsubst | kubectl apply -n $NAMESPACE_ENV -f - + +echo "" +echo "================================================" +echo " Deployment initiated!" +echo "================================================" +echo "" +echo "Monitor deployment status:" +echo " kubectl get pods -n $NAMESPACE_ENV" +echo " kubectl rollout status deployment/ipno-backend -n $NAMESPACE_ENV" +echo " kubectl rollout status deployment/celery -n $NAMESPACE_ENV" +echo "" +echo "View logs:" +echo " kubectl logs -f deployment/ipno-backend -n $NAMESPACE_ENV" +echo " kubectl logs -f deployment/celery -n $NAMESPACE_ENV" +echo "" +echo "Rollback if needed:" +echo " kubectl rollout undo deployment/ipno-backend -n $NAMESPACE_ENV" +echo " kubectl rollout undo deployment/celery -n $NAMESPACE_ENV" +echo "" diff --git a/bin/staging_helper.sh b/bin/staging_helper.sh new file mode 100755 index 00000000..9b728322 --- /dev/null +++ b/bin/staging_helper.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Helper script for common staging operations + +NAMESPACE="ipno-staging" +PROJECT="excellent-zoo-300106" + +case "$1" in + status|st) + echo "=== Staging Status ===" + kubectl get deployments -n $NAMESPACE + echo "" + kubectl get pods -n $NAMESPACE + ;; + + logs) + SERVICE="${2:-ipno-backend}" + echo "Streaming logs from $SERVICE..." + kubectl logs -f deployment/$SERVICE -n $NAMESPACE + ;; + + exec|shell) + POD=$(kubectl get pods -n $NAMESPACE -l app=ipno-backend -o jsonpath='{.items[0].metadata.name}') + echo "Connecting to pod: $POD" + kubectl exec -it $POD -n $NAMESPACE -- /bin/bash + ;; + + django|manage) + shift + IMAGE_TAG="${1:-backend-staging-latest}" + shift + COMMAND="$@" + if [ -z "$COMMAND" ]; then + echo "Usage: $0 django [image_tag] " + echo "Example: $0 django backend-staging-latest shell" + exit 1 + fi + bin/run_spot_job.sh --staging $IMAGE_TAG $COMMAND + ;; + + restart) + SERVICE="${2:-all}" + if [ "$SERVICE" = "all" ]; then + echo "Restarting all services..." + kubectl rollout restart deployment/ipno-backend -n $NAMESPACE + kubectl rollout restart deployment/celery -n $NAMESPACE + else + echo "Restarting $SERVICE..." + kubectl rollout restart deployment/$SERVICE -n $NAMESPACE + fi + ;; + + rollback) + SERVICE="${2:-ipno-backend}" + echo "Rolling back $SERVICE..." + kubectl rollout undo deployment/$SERVICE -n $NAMESPACE + ;; + + images|img) + echo "Recent staging images:" + gcloud container images list-tags us.gcr.io/$PROJECT/ipno-backend \ + --filter="tags:backend-staging*" \ + --limit=10 \ + --format="table(tags,timestamp)" + ;; + + secrets) + echo "Available secrets in staging:" + kubectl get secrets -n $NAMESPACE + echo "" + echo "To view a secret value:" + echo " kubectl get secret -n $NAMESPACE -o jsonpath='{.data.}' | base64 -d" + ;; + + configmap|config) + echo "ConfigMaps in staging:" + kubectl get configmap ipno -n $NAMESPACE -o yaml + ;; + + cronjobs|cron) + echo "Cronjobs in staging:" + kubectl get cronjobs -n $NAMESPACE + ;; + + connect|auth) + echo "Configuring kubectl for GKE..." + gcloud container clusters get-credentials ipno \ + --zone us-central1-a \ + --project $PROJECT + kubectl config set-context --current --namespace=$NAMESPACE + echo "Connected to staging namespace" + ;; + + *) + echo "LLEAD Staging Helper" + echo "" + echo "Usage: $0 [options]" + echo "" + echo "Commands:" + echo " status, st - Show deployment and pod status" + echo " logs [service] - Stream logs (default: ipno-backend)" + echo " exec, shell - Open shell in backend pod" + echo " django - Run Django management command" + echo " restart [service|all] - Restart deployments (default: all)" + echo " rollback [service] - Rollback deployment (default: ipno-backend)" + echo " images, img - List recent staging images" + echo " secrets - List secrets in staging" + echo " configmap, config - Show config maps" + echo " cronjobs, cron - List cronjobs" + echo " connect, auth - Configure kubectl connection" + echo "" + echo "Examples:" + echo " $0 status" + echo " $0 logs celery" + echo " $0 django shell" + echo " $0 restart ipno-backend" + echo " $0 images" + exit 1 + ;; +esac diff --git a/ipno/config/settings/base.py b/ipno/config/settings/base.py index 833e6559..b8bbf51c 100644 --- a/ipno/config/settings/base.py +++ b/ipno/config/settings/base.py @@ -303,4 +303,4 @@ CSV_DATA_PATH = "./ipno/csv_data" -IPNO_API_KEY = env.str("IPNO_API_KEY") +IPNO_API_KEY = env.str("IPNO_API_KEY", default="") diff --git a/ipno/config/settings/staging.py b/ipno/config/settings/staging.py index 373bd889..e2fc891f 100644 --- a/ipno/config/settings/staging.py +++ b/ipno/config/settings/staging.py @@ -10,8 +10,11 @@ "https://staging.llead.co", ] +# Use GOOGLE_APPLICATION_CREDENTIALS env var set by Kubernetes (mounted from backend-gsa secret) GS_CREDENTIALS = service_account.Credentials.from_service_account_file( - f"{BASE_DIR}/gcloud-credentials.json" # NOQA + os.getenv( + "GOOGLE_APPLICATION_CREDENTIALS", f"{BASE_DIR}/gcloud-credentials.json" + ) # NOQA ) DEFAULT_FILE_STORAGE = "storages.backends.gcloud.GoogleCloudStorage" diff --git a/ipno/officers/queries/officer_timeline_query.py b/ipno/officers/queries/officer_timeline_query.py index dc7ab8c7..956ebce6 100644 --- a/ipno/officers/queries/officer_timeline_query.py +++ b/ipno/officers/queries/officer_timeline_query.py @@ -45,7 +45,13 @@ class OfficerTimelineQuery(object): def __init__(self, officer): - self.all_officers = officer.person.officers.all() + # Handle officers without person association + if officer.person: + self.all_officers = officer.person.officers.all() + else: + # If no person, just use the single officer + self.all_officers = [officer] + self.termination_left_reasons = set( Event.objects.filter(left_reason__icontains="terminat").values_list( "left_reason", flat=True diff --git a/ipno/officers/serializers/officer_timeline_serializers.py b/ipno/officers/serializers/officer_timeline_serializers.py index 1c01d47f..c05411b0 100644 --- a/ipno/officers/serializers/officer_timeline_serializers.py +++ b/ipno/officers/serializers/officer_timeline_serializers.py @@ -50,7 +50,11 @@ def get_kind(self, obj): def _get_person_officers(self, obj): if not hasattr(obj, "person_officers"): - person_officers = obj.officer.person.officers.all() + # Handle officers without person association + if obj.officer.person: + person_officers = obj.officer.person.officers.all() + else: + person_officers = [obj.officer] setattr(obj, "person_officers", person_officers) return obj.person_officers