diff --git a/.flake8 b/.flake8 index c0e7ee10b..951d63f65 100644 --- a/.flake8 +++ b/.flake8 @@ -1,8 +1,8 @@ [flake8] -exclude = */docs/*,*/.tox/*,*/.venv/*,*/.pycharm_helpers/*,*/migrations/*,docs/*,fabfile.py,*/__init__.py,django_project/core/settings/*.py +exclude = */docs/*,*/.tox/*,*/.venv/*,*/.pycharm_helpers/*,*/migrations/*,docs/*,fabfile.py,*/__init__.py,django_project/core/settings/*.py,venv/* max-line-length = 79 # E12x continuation line indentation # E251 no spaces around keyword / parameter equals # E303 too many blank lines (3) -ignore = E125,E126,E251,E303,W504,W60,F405 +ignore = E125,E126,E251,E303,W504,W60,F405,E501 diff --git a/.github/workflows/build-push-images-latest.yaml b/.github/workflows/build-push-images-latest.yaml index 684759eb5..8fd163aef 100644 --- a/.github/workflows/build-push-images-latest.yaml +++ b/.github/workflows/build-push-images-latest.yaml @@ -70,10 +70,10 @@ jobs: type=gha,scope=prod cache-to: type=gha,scope=prod - - name: Run docker-compose services + - name: Run docker compose services working-directory: deployment run: | - echo "Override docker-compose for testing purposes" + echo "Override docker compose for testing purposes" cp .env.example .env cp docker-compose.test.yml docker-compose.override.yml make up @@ -84,14 +84,14 @@ jobs: - name: Run Coverage test working-directory: deployment run: | - cat << EOF | docker-compose exec -T devweb bash + cat << EOF | docker compose exec -T devweb bash python manage.py makemigrations python manage.py migrate python manage.py collectstatic --noinput --verbosity 0 coverage run manage.py test coverage xml EOF - docker cp projecta_devweb_1:/home/web/django_project/coverage.xml ../coverage.xml + docker cp projecta-devweb-1:/home/web/django_project/coverage.xml ../coverage.xml # - name: Upload coverage to codecov # uses: codecov/codecov-action@v2 diff --git a/deployment/.env.example b/deployment/.env.example index feca9a5a5..277e04857 100644 --- a/deployment/.env.example +++ b/deployment/.env.example @@ -43,6 +43,32 @@ HTTPS_PORT=443 TEST_HTTP_PORT=61202 SSH_PORT=61203 +# Volumes +MEDIA_VOLUME=./media +STATIC_VOLUME=./static +BACKUPS_VOLUME=./backups + +# VALID_DOMAIN +VALID_DOMAIN=["localhost", "changelog.kartoza.com", "staging.changelog.kartoza.com","changelog.qgis.org", "staging.changelog.qgis.org"] + +# Email +EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST='' +EMAIL_PORT=25 +EMAIL_USE_TLS=True +EMAIL_HOST_USER='' +EMAIL_HOST_PASSWORD='' +EMAIL_SUBJECT_PREFIX='' +MAILDOMAIN='' +SERVER_EMAIL='' +ADMIN_EMAIL='' +DEFAULT_FROM_EMAIL='' + +# Stripe +STRIPE_LIVE_SECRET_KEY='sk_live_key' +STRIPE_LIVE_PUBLIC_KEY='pk_live_key' +STRIPE_LIVE_MODE=True +DJSTRIPE_WEBHOOK_SECRET='whsec_' # SENTRY SENTRY_DSN='' -SENTRY_RATE=0.2 \ No newline at end of file +SENTRY_RATE=0.2 diff --git a/deployment/Makefile b/deployment/Makefile index 69adeba48..dddd11c6a 100644 --- a/deployment/Makefile +++ b/deployment/Makefile @@ -11,7 +11,7 @@ build: @echo "------------------------------------------------------------------" @echo "Building in production mode" @echo "------------------------------------------------------------------" - @docker-compose build uwsgi + @docker compose build uwsgi # Add synonymous target up: web @@ -21,13 +21,11 @@ web: @echo "------------------------------------------------------------------" @echo "Running in production mode" @echo "------------------------------------------------------------------" - @docker-compose up -d web + @docker compose up -d web @# Dont confuse this with the dbbackup make command below @# This one runs the postgis-backup cron container @# We add --no-recreate so that it does not destroy & recreate the db container - @docker-compose up --no-recreate --no-deps -d dbbackups - @docker-compose up -d btsync-db - @docker-compose up -d btsync-media + @docker compose up --no-recreate --no-deps -d dbbackups permissions: # Probably we want something more granular here.... @@ -48,14 +46,14 @@ db: @echo "------------------------------------------------------------------" @echo "Running db in production mode" @echo "------------------------------------------------------------------" - @docker-compose up -d db + @docker compose up -d db wait-db: - @docker-compose exec -T db su - postgres -c "until pg_isready; do sleep 5; done" + @docker compose exec -T db su - postgres -c "until pg_isready; do sleep 5; done" create-test-db: - @docker-compose exec -T db su - postgres -c "psql -c 'create database test_db;'" - @docker-compose exec -T db su - postgres -c "psql -d test_db -c 'create extension postgis;'" + @docker compose exec -T db su - postgres -c "psql -c 'create database test_db;'" + @docker compose exec -T db su - postgres -c "psql -d test_db -c 'create extension postgis;'" nginx: @echo @@ -65,7 +63,7 @@ nginx: @echo "In a production environment you will typically use nginx running" @echo "on the host rather if you have a multi-site host." @echo "------------------------------------------------------------------" - @docker-compose up -d nginx + @docker compose up -d nginx @echo "Site should now be available at http://localhost" migrate: @@ -79,26 +77,26 @@ migrate: @# We add the '-' prefix to the next line as the migration may fail @# but we want to continue anyway. @#We need to migrate accounts first as it has a reference to user model - -@docker-compose exec uwsgi python manage.py migrate auth - @docker-compose exec uwsgi python manage.py migrate + -@docker compose exec uwsgi python manage.py migrate auth + @docker compose exec uwsgi python manage.py migrate update-migrations: @echo @echo "------------------------------------------------------------------" @echo "Running update migrations in production mode" @echo "------------------------------------------------------------------" - @docker-compose exec uwsgi python manage.py makemigrations + @docker compose exec uwsgi python manage.py makemigrations collectstatic: @echo @echo "------------------------------------------------------------------" @echo "Collecting static in production mode" @echo "------------------------------------------------------------------" - #@docker-compose run uwsgi python manage.py collectstatic --noinput + #@docker compose run uwsgi python manage.py collectstatic --noinput #We need to run collect static in the same context as the running # uwsgi container it seems so I use docker exec here # no -it flag so we can run over remote shell - @docker-compose exec uwsgi python manage.py collectstatic --noinput + @docker compose exec uwsgi python manage.py collectstatic --noinput reload: @echo @@ -106,23 +104,21 @@ reload: @echo "Reload django project in production mode" @echo "------------------------------------------------------------------" # no -it flag so we can run over remote shell - @docker-compose exec uwsgi touch /tmp/django.pid + @docker compose exec uwsgi touch /tmp/django.pid status: @echo @echo "------------------------------------------------------------------" @echo "Show status for all containers" @echo "------------------------------------------------------------------" - @docker-compose ps + @docker compose ps kill: @echo @echo "------------------------------------------------------------------" @echo "Killing in production mode" @echo "------------------------------------------------------------------" - @docker-compose kill - @docker-compose kill btsync-db - @docker-compose kill btsync-media + @docker compose kill rm: dbbackup rm-only @@ -136,59 +132,59 @@ rm-only: kill @echo "------------------------------------------------------------------" @echo "Removing production instance!!! " @echo "------------------------------------------------------------------" - @docker-compose down + @docker compose down logs: @echo @echo "------------------------------------------------------------------" @echo "Showing uwsgi logs in production mode" @echo "------------------------------------------------------------------" - @docker-compose logs -f --tail=50 uwsgi + @docker compose logs -f --tail=50 uwsgi dblogs: @echo @echo "------------------------------------------------------------------" @echo "Showing db logs in production mode" @echo "------------------------------------------------------------------" - @docker-compose logs -f --tail=50 db + @docker compose logs -f --tail=50 db nginxlogs: @echo @echo "------------------------------------------------------------------" @echo "Showing nginx logs in production mode" @echo "------------------------------------------------------------------" - @docker-compose logs -f --tail=50 web + @docker compose logs -f --tail=50 web shell: @echo @echo "------------------------------------------------------------------" @echo "Shelling in in production mode" @echo "------------------------------------------------------------------" - @docker-compose exec uwsgi /bin/bash + @docker compose exec uwsgi /bin/bash superuser: @echo @echo "------------------------------------------------------------------" @echo "Creating a superuser in production mode" @echo "------------------------------------------------------------------" - @docker-compose exec uwsgi python manage.py createsuperuser + @docker compose exec uwsgi python manage.py createsuperuser dbbash: @echo @echo "------------------------------------------------------------------" @echo "Bashing in to production database" @echo "------------------------------------------------------------------" - @docker-compose exec db /bin/bash + @docker compose exec db /bin/bash dbsnapshot: @echo @echo "------------------------------------------------------------------" @echo "Grab a quick snapshot of the database and place in the host filesystem" @echo "------------------------------------------------------------------" - @docker-compose exec -e COMPOSE_PROJECT_NAME -e DATABASE_NAME db /bin/bash -c 'PGPASSWORD="$${PASS}" pg_dump -Fc -h localhost -U "$${USERNAME}" -f "/tmp/$${COMPOSE_PROJECT_NAME}-snapshot.dmp" $${DATABASE_NAME}' - @source .env; CONTAINER=`docker-compose ps -q db`; \ + @docker compose exec -e COMPOSE_PROJECT_NAME -e DATABASE_NAME db /bin/bash -c 'PGPASSWORD="$${PASS}" pg_dump -Fc -h localhost -U "$${USERNAME}" -f "/tmp/$${COMPOSE_PROJECT_NAME}-snapshot.dmp" $${DATABASE_NAME}' + @source .env; CONTAINER=`docker compose ps -q db`; \ docker cp "$${CONTAINER}:/tmp/$${COMPOSE_PROJECT_NAME}-snapshot.dmp" . - @docker-compose exec -e COMPOSE_PROJECT_NAME db /bin/bash -c 'rm /tmp/$${COMPOSE_PROJECT_NAME}-snapshot.dmp' + @docker compose exec -e COMPOSE_PROJECT_NAME db /bin/bash -c 'rm /tmp/$${COMPOSE_PROJECT_NAME}-snapshot.dmp' @ls -lahtr *.dmp dbschema: @@ -196,14 +192,14 @@ dbschema: @echo "------------------------------------------------------------------" @echo "Print the database schema to stdio" @echo "------------------------------------------------------------------" - @docker-compose exec -e DATABASE_NAME db /bin/bash -c 'PGPASSWORD="$${PASS}" pg_dump -s -h localhost -U "$${USERNAME}" -d "$${DATABASE_NAME}"' + @docker compose exec -e DATABASE_NAME db /bin/bash -c 'PGPASSWORD="$${PASS}" pg_dump -s -h localhost -U "$${USERNAME}" -d "$${DATABASE_NAME}"' dbshell: @echo @echo "------------------------------------------------------------------" @echo "Shelling in in production database" @echo "------------------------------------------------------------------" - @docker-compose exec -e DATABASE_NAME db /bin/bash -c 'PGPASSWORD="$${PASS}" psql -h localhost -U "$${USERNAME}" -d "$${DATABASE_NAME}"' + @docker compose exec -e DATABASE_NAME db /bin/bash -c 'PGPASSWORD="$${PASS}" psql -h localhost -U "$${USERNAME}" -d "$${DATABASE_NAME}"' dbrestore: @echo @@ -212,17 +208,17 @@ dbrestore: @echo "------------------------------------------------------------------" @# - prefix causes command to continue even if it fails @echo "stopping uwsgi container" - @docker-compose stop uwsgi + @docker compose stop uwsgi @source .env; echo "dropping $$DATABASE_NAME" - @docker-compose exec -e DATABASE_NAME db bash -c 'su - postgres -c "dropdb $${DATABASE_NAME}"' + -@docker compose exec -e DATABASE_NAME db bash -c 'su - postgres -c "dropdb --force $${DATABASE_NAME}"' @source .env; echo "creating $$DATABASE_NAME" - @docker-compose exec -e DATABASE_NAME db bash -c 'su - postgres -c "createdb -O docker -T template_postgis $${DATABASE_NAME}"' + -@docker compose exec -e DATABASE_NAME db bash -c 'su - postgres -c "createdb -O docker -T template1 $${DATABASE_NAME}"' @source .env; echo "restoring $$DATABASE_NAME" @# Because we pipe from one docker command to another and we are going @# to execute this Make command from a remote server at times, we need to using use interactive mode (-i) @# in the first command and not use terminal (-t) in the second. Please do not change these! - @docker-compose exec -e DATABASE_NAME db /bin/bash -c 'pg_restore /backups/latest.dmp | su - postgres -c "psql $${DATABASE_NAME}"' - @docker-compose start uwsgi + -@docker compose exec -e DATABASE_NAME db bash -c 'su - postgres -c "pg_restore -c /backups/latest.dmp -d gis"' + @docker compose start uwsgi @echo "starting uwsgi container" dbbackup: @@ -234,9 +230,9 @@ dbbackup: @echo "------------------------------------------------------------------" @# - prefix causes command to continue even if it fails @# Explicitly don't use -it so we can call this make target over a remote ssh session - @docker-compose exec dbbackups /backups.sh - @docker-compose exec dbbackups cat /var/log/cron.log | tail -2 | head -1 | awk '{print $4}' - @docker-compose exec -e COMPOSE_PROJECT_NAME -w /backups db /bin/bash -c 'ln -sf `date +%Y`/`date +%B`/PG_$${COMPOSE_PROJECT_NAME}_gis.`date +%d-%B-%Y`.dmp latest.dmp' + @docker compose exec dbbackups /backups.sh + @docker compose exec dbbackups cat /var/log/cron.log | tail -2 | head -1 | awk '{print $4}' + @docker compose exec -e COMPOSE_PROJECT_NAME -w /backups db /bin/bash -c 'ln -sf `date +%Y`/`date +%B`/PG_$${COMPOSE_PROJECT_NAME}_gis.`date +%d-%B-%Y`.dmp latest.dmp' @source .env; echo "Backup should be at: backups/`date +%Y`/`date +%B`/PG_$${COMPOSE_PROJECT_NAME}_gis.`date +%d-%B-%Y`.dmp" sentry: @@ -244,21 +240,21 @@ sentry: @echo "--------------------------" @echo "Running sentry production mode" @echo "--------------------------" - @docker-compose up -d sentry + @docker compose up -d sentry maillogs: @echo @echo "------------------------------------------------------------------" @echo "Showing smtp logs in production mode" @echo "------------------------------------------------------------------" - @docker-compose exec smtp tail -f /var/log/mail.log + @docker compose exec smtp tail -f /var/log/mail.log mailerrorlogs: @echo @echo "------------------------------------------------------------------" @echo "Showing smtp error logs in production mode" @echo "------------------------------------------------------------------" - @docker-compose exec smtp tail -f /var/log/mail.err + @docker compose exec smtp tail -f /var/log/mail.err mediasync: @echo @@ -267,8 +263,6 @@ mediasync: @echo "------------------------------------------------------------------" @rsync -av --progress changelog.kartoza.com:/home/projecta/deployment/media/ media @rsync -av --progress changelog.kartoza.com:/home/projecta/django_project/core/settings/secret.py ../django_project/core/settings/ - @rsync -av --progress changelog.kartoza.com:/home/projecta/deployment/btsync-db.env btsync-db.env.PRODUCTION - @rsync -av --progress changelog.kartoza.com:/home/projecta/deployment/btsync-media.env btsync-media.env.PRODUCTION dbsync: @echo @@ -316,14 +310,14 @@ devweb: db @echo "------------------------------------------------------------------" @echo "Running in DEVELOPMENT mode" @echo "------------------------------------------------------------------" - @docker-compose up --no-deps -d devweb + @docker compose up --no-deps -d devweb devweb-runserver: devweb @echo @echo "------------------------------------------------------------------" @echo "Running in DEVELOPMENT mode" @echo "------------------------------------------------------------------" - @docker-compose exec devweb python manage.py runserver 0.0.0.0:8080 + @docker compose exec devweb python manage.py runserver 0.0.0.0:8080 test-local: devweb flake8 coverage @@ -332,7 +326,7 @@ build-devweb: db @echo "------------------------------------------------------------------" @echo "Building devweb" @echo "------------------------------------------------------------------" - @docker-compose build devweb + @docker compose build devweb # Run pep8 style checking #http://pypi.python.org/pypi/pep8 @@ -352,22 +346,22 @@ coverage: @echo "-----------" @echo "coverage local test" @echo "-----------" - @docker-compose exec devweb coverage run manage.py test --settings=core.settings.test_docker - @docker-compose exec devweb coverage report + @docker compose exec devweb coverage run manage.py test --settings=core.settings.test_docker + @docker compose exec devweb coverage report update-translation-strings: @echo @echo "------------------------------------------------------------------" @echo "Update strings for translation." @echo "------------------------------------------------------------------" - @docker-compose exec uwsgi python manage.py makemessages + @docker compose exec uwsgi python manage.py makemessages compile-translation-strings: @echo @echo "------------------------------------------------------------------" @echo "Compile strings for translation." @echo "------------------------------------------------------------------" - @docker-compose exec uwsgi python manage.py compilemessages + @docker compose exec uwsgi python manage.py compilemessages push-translation-source: @echo diff --git a/deployment/docker-compose.override.example.yml b/deployment/docker-compose.override.example.yml index 0bbe695f4..eb6b990d6 100644 --- a/deployment/docker-compose.override.example.yml +++ b/deployment/docker-compose.override.example.yml @@ -1,7 +1,6 @@ ## docker-compose override recipe sample ## Intended to be used to override default recipe for development environment ## Copy paste it as docker-compose.override.yml to use -version: '3' volumes: postgres-data: driver_opts: diff --git a/deployment/docker-compose.test.yml b/deployment/docker-compose.test.yml index dd1a1547f..c76196681 100644 --- a/deployment/docker-compose.test.yml +++ b/deployment/docker-compose.test.yml @@ -1,4 +1,3 @@ -version: '3' services: uwsgi: &uwsgi-common image: ${APP_IMAGE}:prod diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 535f9c78b..a154a7e2b 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -6,7 +6,6 @@ # docker-compose up -d web # # See accompanying Make commands for easy collectstatic etc. -version: '3' volumes: postgres-data: db-backups: @@ -15,30 +14,33 @@ volumes: reports-data: nginx-conf: services: - smtp: - image: catatnight/postfix - hostname: postfix - environment: - # You could change this to something more suitable - - maildomain=kartoza.com - - smtp_user=noreply:docker - restart: unless-stopped + # smtp: + # image: catatnight/postfix + # hostname: postfix + # environment: + # # You could change this to something more suitable + # - maildomain=kartoza.com + # - smtp_user=noreply:docker + # restart: unless-stopped db: - image: kartoza/postgis:9.6-2.4 + image: kartoza/postgis:16-3.4 volumes: - postgres-data:/var/lib/postgresql - db-backups:/backups environment: - - USERNAME=${DATABASE_USERNAME} - - PASS=${DATABASE_PASSWORD} + - POSTGRES_USER=${DATABASE_USERNAME} + - POSTGRES_PASS=${DATABASE_PASSWORD} - ALLOW_IP_RANGE=0.0.0.0/0 restart: unless-stopped ports: - "7543:5432" uwsgi: &uwsgi-common - image: kartoza/projecta-uwsgi + build: + context: ${PWD}/../ + dockerfile: deployment/docker/Dockerfile + target: prod environment: - DATABASE_NAME=${DATABASE_NAME} - DATABASE_USERNAME=${DATABASE_USERNAME} @@ -47,6 +49,22 @@ services: - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} - VIRTUAL_HOST=${VIRTUAL_HOST} - VIRTUAL_PORT=${VIRTUAL_PORT} + - VALID_DOMAIN=${VALID_DOMAIN} + - EMAIL_BACKEND=${EMAIL_BACKEND} + - EMAIL_HOST=${EMAIL_HOST} + - EMAIL_PORT=${EMAIL_PORT} + - EMAIL_USE_TLS=${EMAIL_USE_TLS} + - EMAIL_HOST_USER=${EMAIL_HOST_USER:-automation} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD} + - EMAIL_SUBJECT_PREFIX=${EMAIL_SUBJECT_PREFIX} + - MAILDOMAIN=${MAILDOMAIN} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL} + - STRIPE_LIVE_SECRET_KEY=${STRIPE_LIVE_SECRET_KEY} + - STRIPE_LIVE_PUBLIC_KEY=${STRIPE_LIVE_PUBLIC_KEY} + - STRIPE_LIVE_MODE=${STRIPE_LIVE_MODE} + - DJSTRIPE_WEBHOOK_SECRET=${DJSTRIPE_WEBHOOK_SECRET} + - SERVER_EMAIL=${SERVER_EMAIL} + - ADMIN_EMAIL=${ADMIN_EMAIL} - SENTRY_DSN=${SENTRY_DSN} - SENTRY_RATE=${SENTRY_RATE} volumes: @@ -55,6 +73,7 @@ services: - reports-data:/home/web/reports links: - db:db + # - smtp:smtp restart: unless-stopped user: root logging: @@ -64,7 +83,7 @@ services: max-file: "10" dbbackups: - image: kartoza/pg-backup:9.6 + image: kartoza/pg-backup:16-3.4 hostname: pg-backups volumes: - db-backups:/backups @@ -76,10 +95,10 @@ services: - DUMPPREFIX=${DUMPPREFIX} # These are all defaults anyway, but setting explicitly in # case we ever want to ever use different credentials - - PGUSER=${DATABASE_USERNAME} - - PGPASSWORD=${DATABASE_PASSWORD} - - PGPORT=5432 - - PGHOST=${DATABASE_HOST} + - POSTGRES_USER=${DATABASE_USERNAME} + - POSTGRES_PASS=${DATABASE_PASSWORD} + - POSTGRES_PORT=5432 + - POSTGRES_HOST=${DATABASE_HOST} - PGDATABASE=${DATABASE_NAME} restart: unless-stopped @@ -104,19 +123,10 @@ services: # from prod environment if wanted devweb: <<: *uwsgi-common + build: + context: ${PWD}/../ + dockerfile: dockerize/docker/Dockerfile + target: dev - btsync-db: - # BTSync backups for database dumps - image: kartoza/btsync - volumes: - # We mount RW so that we can use remove peer to clean up old backups off the server - - db-backups:/web:rw - - btsync-media: - # BTSync backups for django media - image: kartoza/btsync - volumes: - # We mount RO as we do not really want peers to change this data - - media-data:/web:ro diff --git a/deployment/docker/Dockerfile b/deployment/docker/Dockerfile index a69acc9d8..207bda32b 100644 --- a/deployment/docker/Dockerfile +++ b/deployment/docker/Dockerfile @@ -1,6 +1,6 @@ #--------- Generic stuff all our Dockerfiles should start with so we get caching ------------ # Note this base image is based on debian -FROM python:3.10 as prod +FROM python:3.12-slim as prod MAINTAINER Tim Sutton RUN export DEBIAN_FRONTEND=noninteractive @@ -9,7 +9,7 @@ RUN dpkg-divert --local --rename --add /sbin/initctl # Pandoc needed to generate rst dumps, uic compressor needed for django-pipeline RUN apt-get update -y && \ - apt-get -y install python3-gdal python3-geoip sudo curl rpl && \ + apt-get -y install python3-gdal python3-geoip sudo curl rpl wget && \ apt-get -y --force-yes install yui-compressor gettext && \ apt-get -y --purge autoremove make libc-dev musl-dev g++ && \ apt-get install -y nodejs npm && \ @@ -18,14 +18,15 @@ RUN apt-get update -y && \ apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/* /root/.npm /root/.cache && \ rm -rf ~/.cache/pip -RUN wget https://github.com/jgm/pandoc/releases/download/1.17.1/pandoc-1.17.1-2-amd64.deb -RUN dpkg -i pandoc-1.17.1-2-amd64.deb && rm pandoc-1.17.1-2-amd64.deb +RUN wget https://github.com/jgm/pandoc/releases/download/3.2/pandoc-3.2-1-amd64.deb +RUN dpkg -i pandoc-3.2-1-amd64.deb && rm pandoc-3.2-1-amd64.deb # Added because of issue with building cryptography.io using pip # This flag disabled rust build, but only for this particular version # In the future, we may have to include rust toolchain in the Dockerfile ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 +RUN apt-get update -y && apt-get -y install git build-essential libpangocairo-1.0-0 ADD deployment/docker/REQUIREMENTS.txt /REQUIREMENTS.txt ADD deployment/docker/uwsgi.conf /uwsgi.conf ADD django_project /home/web/django_project diff --git a/deployment/docker/REQUIREMENTS-dev.txt b/deployment/docker/REQUIREMENTS-dev.txt index 50267d864..5ef552784 100644 --- a/deployment/docker/REQUIREMENTS-dev.txt +++ b/deployment/docker/REQUIREMENTS-dev.txt @@ -1,8 +1,8 @@ django-nose coverage -pep8>=1.5.7,<1.6 +pep8 pylint -flake8==3.7.9 +flake8 factory_boy # documentation diff --git a/deployment/docker/REQUIREMENTS.txt b/deployment/docker/REQUIREMENTS.txt index 201566abb..d87dc5809 100644 --- a/deployment/docker/REQUIREMENTS.txt +++ b/deployment/docker/REQUIREMENTS.txt @@ -1,50 +1,52 @@ -Django==3.2.13 - -psycopg2-binary==2.8.6 -django-countries==7.3.2 -django-crispy-forms==1.8.1 -django-braces==1.15.0 -django-model-utils==4.2.0 -django-pipeline==2.0.8 -git+https://github.com/dimasciput/django-pure-pagination.git -django-reversion==5.0.0 -markdown==3.1.1 - -easy-thumbnails==2.8.1 -django-widget-tweaks==1.3 -raven==6.10.0 -requests==2.27.1 -django-hashedfilenamestorage==2.3 -# pypandoc is an interface for pandoc converter -pypandoc==1.4 -beautifulsoup4==4.11.1 -# disqus -django-disqus==0.5 # custom slugify -awesome-slugify==1.6.5 -#translation management tool -django-rosetta==0.9.8 +awesome-slugify~=1.6 +beautifulsoup4~=4.12 +crispy-bootstrap3~=2024.1 +Django~=4.2 +git+https://github.com/Xpirix/dj-stripe.git@django4_update +django-allauth~=0.63 +django-braces~=1.15 +# disqus +django-disqus~=0.5 +django-colorfield~=0.11 +django-countries~=7.6 +django-crispy-forms~=2.1 +django-datatables-view~=1.20 # youtube embed -django-embed-video==1.4.3 -django-grappelli==2.13.2 -pytz==2020.5 -django-allauth==0.50.0 -# support special characters -Unidecode==0.4.20 -Pillow==9.1.0 -reportlab==3.6.9 -xhtml2pdf==0.2.7 -django-modeltranslation==0.16.1 -django-colorfield==0.3.2 -WeasyPrint==0.42.3 -djangorestframework==3.11.2 -django-simple-history==3.0.0 -dj-stripe==2.1.1 -django-preferences==1.0.0 -pinax-notifications==6.0.0 -pinax-templates==2.0.2 +django-embed-video~=1.4 +easy-thumbnails~=2.8 +django-grappelli~=4.0 +# Update for Django 4, the original package is broken +git+https://github.com/Xpirix/django-hashedfilenamestorage.git +django-modeltranslation~=0.18 +django-model-utils~=4.5 +django-pipeline~=3.1 +django-preferences~=1.0 +git+https://github.com/dimasciput/django-pure-pagination.git +django-reversion~=5.0 +#translation management tool +django-rosetta~=0.10 +django-simple-history~=3.5 # rich text editor -django-tinymce==3.4.0 -django-datatables-view==1.19.1 - -sentry-sdk~=2.2 \ No newline at end of file +django-tinymce~=4.0 +django-widget-tweaks~=1.5 +djangorestframework~=3.15 +markdown~=3.6 +Pillow~=10.3 +# Update for Django 4, the original package is broken +git+https://github.com/Xpirix/pinax-notifications.git +pinax-templates~=3.0 +# pypandoc is an interface for pandoc converter +pypandoc~=1.13 +psycopg2-binary~=2.9 +pytz~=2024.1 +raven~=6.10 +reportlab~=4.0 +requests~=2.32 +# support special characters +Unidecode~=0.4 +WeasyPrint~=62.1 +xhtml2pdf~=0.2 +sentry-sdk~=2.2 +setuptools~=75.1 +pyjwt~=2.8 \ No newline at end of file diff --git a/deployment/sites-enabled/default.conf b/deployment/sites-enabled/default.conf index 70c91fe52..0189f1e57 100644 --- a/deployment/sites-enabled/default.conf +++ b/deployment/sites-enabled/default.conf @@ -33,7 +33,7 @@ server { } # max upload size, adjust to taste - client_max_body_size 15M; + client_max_body_size 100M; # Django media location /media { # your Django project's media files - amend as required diff --git a/django_project/.version b/django_project/.version index 2bf1c1ccf..4a36342fc 100644 --- a/django_project/.version +++ b/django_project/.version @@ -1 +1 @@ -2.3.1 +3.0.0 diff --git a/django_project/base/admin.py b/django_project/base/admin.py index e70b39833..2e2a3432f 100644 --- a/django_project/base/admin.py +++ b/django_project/base/admin.py @@ -15,7 +15,7 @@ from django.contrib.flatpages.admin import FlatPageAdmin from django.contrib.flatpages.models import FlatPage from preferences.admin import PreferencesAdmin -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ import reversion from .models import ( Project, diff --git a/django_project/base/forms.py b/django_project/base/forms.py index 93cd90142..83607a802 100644 --- a/django_project/base/forms.py +++ b/django_project/base/forms.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from django.contrib.flatpages.forms import FlatpageForm from django.forms import inlineformset_factory -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from crispy_forms.helper import FormHelper from crispy_forms.layout import ( Layout, diff --git a/django_project/base/migrations/0008_alter_project_accent_color.py b/django_project/base/migrations/0008_alter_project_accent_color.py new file mode 100644 index 000000000..3b58538b8 --- /dev/null +++ b/django_project/base/migrations/0008_alter_project_accent_color.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.13 on 2024-05-23 13:03 + +import colorfield.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0007_project_external_reviewer_invitation'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='accent_color', + field=colorfield.fields.ColorField(blank=True, default='#FF0000', help_text='A color represent the project color', image_field=None, max_length=25, null=True, samples=None), + ), + ] diff --git a/django_project/base/models/custom_domain.py b/django_project/base/models/custom_domain.py index c7576f05a..e15c5a5e0 100644 --- a/django_project/base/models/custom_domain.py +++ b/django_project/base/models/custom_domain.py @@ -1,7 +1,7 @@ # coding=utf-8 from django.contrib.auth.models import User from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from base.models import Project, Organisation diff --git a/django_project/base/models/organisation.py b/django_project/base/models/organisation.py index f66b55fc0..4d256326a 100644 --- a/django_project/base/models/organisation.py +++ b/django_project/base/models/organisation.py @@ -1,7 +1,7 @@ # coding=utf-8 from django.contrib.auth.models import User from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class Organisation(models.Model): diff --git a/django_project/base/models/project.py b/django_project/base/models/project.py index 647cda8e2..4ca634905 100644 --- a/django_project/base/models/project.py +++ b/django_project/base/models/project.py @@ -8,7 +8,7 @@ from django.utils.text import slugify from django.conf.global_settings import MEDIA_ROOT from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from changes.models.version import Version from core.settings.contrib import STOP_WORDS from django.contrib.auth.models import User diff --git a/django_project/base/templates/custom_domain/list.html b/django_project/base/templates/custom_domain/list.html index a2ad6ba70..b37f005b4 100644 --- a/django_project/base/templates/custom_domain/list.html +++ b/django_project/base/templates/custom_domain/list.html @@ -62,13 +62,13 @@

- {% ifequal num_domains 0 %} + {% if num_domains == 0 %} {% if unapproved %}

All organisation are approved.

{% else %}

No organisation are defined.

{% endif %} - {% endifequal %} + {% endif %} {% for domain in domains %}
  • diff --git a/django_project/base/templates/organisation/list.html b/django_project/base/templates/organisation/list.html index 637fa1abb..37d10993a 100644 --- a/django_project/base/templates/organisation/list.html +++ b/django_project/base/templates/organisation/list.html @@ -60,13 +60,13 @@

    - {% ifequal num_organisations 0 %} + {% if num_organisations == 0 %} {% if unapproved %}

    All organisation are approved.

    {% else %}

    No organisation are defined.

    {% endif %} - {% endifequal %} + {% endif %} {% for organisation in organisations %}
  • diff --git a/django_project/base/templates/project/list.html b/django_project/base/templates/project/list.html index b9660b47a..796afcc4e 100644 --- a/django_project/base/templates/project/list.html +++ b/django_project/base/templates/project/list.html @@ -89,12 +89,12 @@

    {% endif %}

    - {% ifequal num_projects 0 %} + {% if num_projects == 0 %}

    No projects are defined, but you can create one here.

    - {% endifequal %} + {% endif %} {% for project in projects %} {% include 'project/includes/project-panel.html' %} {% endfor %} diff --git a/django_project/base/templatetags/custom_markup.py b/django_project/base/templatetags/custom_markup.py index 60d4fd134..e33093f94 100644 --- a/django_project/base/templatetags/custom_markup.py +++ b/django_project/base/templatetags/custom_markup.py @@ -2,7 +2,7 @@ from django import template from django.contrib.staticfiles import finders from django.template.defaultfilters import stringfilter -from django.utils.encoding import force_text as force_unicode +from django.utils.encoding import force_str as force_unicode from django.utils.safestring import mark_safe register = template.Library() diff --git a/django_project/base/urls.py b/django_project/base/urls.py index 1c996a265..b6e013abd 100644 --- a/django_project/base/urls.py +++ b/django_project/base/urls.py @@ -1,6 +1,6 @@ # coding=utf-8 """Urls for changelog application.""" -from django.conf.urls import url +from django.urls import re_path as url from django.views.static import serve from django.conf import settings @@ -46,110 +46,110 @@ urlpatterns = [ # basic app views - url(regex='^$', + url(r'^$', view=ProjectListView.as_view(), name='home'), - url(regex='^profile/$', + url(r'^profile/$', view=UserDetailView.as_view(), name='user-profile'), - url(regex='^edit-profile/(?P[\w-]+)/$', + url(r'^edit-profile/(?P[\w-]+)/$', view=UserUpdateView.as_view(), name='edit-profile'), # Custom domain management - url(regex='^domain-not-found/$', + url(r'^domain-not-found/$', view=DomainNotFound.as_view(), name='domain-not-found'), - url(regex='^register-domain/$', + url(r'^register-domain/$', view=RegisterDomainView.as_view(), name='register-domain'), - url(regex='^domain-success/$', + url(r'^domain-success/$', view=DomainThankYouView.as_view(), name='domain-registered'), - url(regex='^domain-list/$', + url(r'^domain-list/$', view=DomainListView.as_view(), name='domain-list'), - url(regex='^pending-list-domain/$', + url(r'^pending-list-domain/$', view=PendingDomainListView.as_view(), name='domain-pending-list'), - url(regex='^domain-approve/(?P[\w-]+)/$', + url(r'^domain-approve/(?P[\w-]+)/$', view=ApproveDomainView.as_view(), name='domain-approve'), - url(regex='^domain/(?P[\w-]+)/delete/$', + url(r'^domain/(?P[\w-]+)/delete/$', view=DomainDeleteView.as_view(), name='domain-delete'), - url(regex='^domain/(?P[\w-]+)/update/$', + url(r'^domain/(?P[\w-]+)/update/$', view=DomainUpdateView.as_view(), name='domain-update'), # Organisation management - url(regex='^create-organisation/$', + url(r'^create-organisation/$', view=CreateOrganisationView.as_view(), name='create-organisation'), - url(regex='^list-organisation/$', + url(r'^list-organisation/$', view=OrganisationListView.as_view(), name='list-organisation'), - url(regex='^pending-list-organisation/$', + url(r'^pending-list-organisation/$', view=PendingOrganisationListView.as_view(), name='pending-list-organisation'), - url(regex='^approve-organisation/(?P[\w-]+)/$', + url(r'^approve-organisation/(?P[\w-]+)/$', view=ApproveOrganisationView.as_view(), name='approve-organisation'), - url(regex='^organisation/(?P[\w-]+)/delete/$', + url(r'^organisation/(?P[\w-]+)/delete/$', view=OrganisationDeleteView.as_view(), name='organisation-delete'), - url(regex='^organisation/(?P[\w-]+)/update/$', + url(r'^organisation/(?P[\w-]+)/update/$', view=OrganisationUpdateView.as_view(), name='organisation-update'), # Project management - url(regex='^pending-project/list/$', + url(r'^pending-project/list/$', view=PendingProjectListView.as_view(), name='pending-project-list'), - url(regex='^approve-project/(?P[\w-]+)/$', + url(r'^approve-project/(?P[\w-]+)/$', view=ApproveProjectView.as_view(), name='project-approve'), - url(regex='^project/list/$', + url(r'^project/list/$', view=ProjectListView.as_view(), name='project-list'), - url(regex='^project/preview-certificate/$', + url(r'^project/preview-certificate/$', view=preview_certificate, name='preview-certificate-project'), - url(regex='^(?P[\w-]+)/$', + url(r'^(?P[\w-]+)/$', view=ProjectDetailView.as_view(), name='project-detail'), - url(regex='^(?P[\w-]+)/ballots/$', + url(r'^(?P[\w-]+)/ballots/$', view=ProjectBallotListView.as_view(), name='project-ballot-list'), - url(regex='^project/(?P[\w-]+)/delete/$', + url(r'^project/(?P[\w-]+)/delete/$', view=ProjectDeleteView.as_view(), name='project-delete'), - url(regex='^project/create/$', + url(r'^project/create/$', view=ProjectCreateView.as_view(), name='project-create'), - url(regex='^project/(?P[\w-]+)/update/$', + url(r'^project/(?P[\w-]+)/update/$', view=ProjectUpdateView.as_view(), name='project-update'), - url(regex='^project/github-repo/$', + url(r'^project/github-repo/$', view=GithubProjectView.as_view(), name='github-repo-view'), - url(regex='^project/get-github-repo/$', + url(r'^project/get-github-repo/$', view=GithubListView.as_view(), name='get-github-repo'), - url(regex='^project/get-github-repo-org/(?P[\w-]+)/$', + url(r'^project/get-github-repo-org/(?P[\w-]+)/$', view=GithubListView.as_view(), name='get-github-repo-org'), - url(regex='^project/get-github-orgs/$', + url(r'^project/get-github-orgs/$', view=GithubOrgsView.as_view(), name='get-github-orgs'), - url(regex='^project/submit-github-repo/$', + url(r'^project/submit-github-repo/$', view=GithubSubmitView.as_view(), name='submit-github-repo'), - url(regex='^(?P[\w-]+)/sponsorship-programme/$', + url(r'^(?P[\w-]+)/sponsorship-programme/$', view=project_sponsor_programme, name='sponsor-programme'), - url(regex='^stripe-intent/(?P[\d-]+)/$', + url(r'^stripe-intent/(?P[\d-]+)/$', view=StripeIntent.as_view(), name='stripe-intent'), diff --git a/django_project/base/views/project.py b/django_project/base/views/project.py index 4c85a0334..3f8748ed3 100644 --- a/django_project/base/views/project.py +++ b/django_project/base/views/project.py @@ -163,7 +163,7 @@ def get_object(self, queryset=None): return obj -class ProjectDeleteView(LoginRequiredMixin, ProjectMixin, DeleteView): +class ProjectDeleteView(LoginRequiredMixin, DeleteView): context_object_name = 'project' template_name = 'project/delete.html' diff --git a/django_project/certification/migrations/0025_alter_historicalcertifyingorganisation_options_and_more.py b/django_project/certification/migrations/0025_alter_historicalcertifyingorganisation_options_and_more.py new file mode 100644 index 000000000..78cc4b186 --- /dev/null +++ b/django_project/certification/migrations/0025_alter_historicalcertifyingorganisation_options_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.13 on 2024-05-23 13:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certification', '0024_auto_20220605_0612'), + ] + + operations = [ + migrations.AlterModelOptions( + name='historicalcertifyingorganisation', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical certifying organisation', 'verbose_name_plural': 'historical certifying organisations'}, + ), + migrations.AlterModelOptions( + name='historicalcertifyingorganisationcertificate', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical certifying organisation certificate', 'verbose_name_plural': 'historical certifying organisation certificates'}, + ), + migrations.AlterModelOptions( + name='historicalchecklist', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical checklist', 'verbose_name_plural': 'historical checklists'}, + ), + migrations.AlterField( + model_name='historicalcertifyingorganisation', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name='historicalcertifyingorganisationcertificate', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name='historicalchecklist', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/django_project/certification/models/attendee.py b/django_project/certification/models/attendee.py index 73b48bf1f..556e66be0 100644 --- a/django_project/certification/models/attendee.py +++ b/django_project/certification/models/attendee.py @@ -5,7 +5,7 @@ from django.urls import reverse from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.utils.text import slugify from django.contrib.auth.models import User from core.settings.contrib import STOP_WORDS diff --git a/django_project/certification/models/certificate.py b/django_project/certification/models/certificate.py index c8277a608..a96fc9db5 100644 --- a/django_project/certification/models/certificate.py +++ b/django_project/certification/models/certificate.py @@ -6,7 +6,7 @@ from django.urls import reverse from django.db import models from django.contrib.auth.models import User -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from .course import Course from .attendee import Attendee diff --git a/django_project/certification/models/certificate_type.py b/django_project/certification/models/certificate_type.py index 8647a4014..20fcc5e16 100644 --- a/django_project/certification/models/certificate_type.py +++ b/django_project/certification/models/certificate_type.py @@ -1,7 +1,7 @@ """Certificate type model for certification app""" from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from base.models.project import Project diff --git a/django_project/certification/models/certifying_organisation.py b/django_project/certification/models/certifying_organisation.py index bdd6f20b0..c64481d6b 100644 --- a/django_project/certification/models/certifying_organisation.py +++ b/django_project/certification/models/certifying_organisation.py @@ -10,7 +10,7 @@ from django.core.validators import validate_email from django.db import models from django.utils.text import slugify -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from core.settings.contrib import STOP_WORDS from unidecode import unidecode from django.contrib.auth.models import User diff --git a/django_project/certification/models/checklist.py b/django_project/certification/models/checklist.py index 7a37e7a78..e206695c0 100644 --- a/django_project/certification/models/checklist.py +++ b/django_project/certification/models/checklist.py @@ -1,5 +1,5 @@ from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from simple_history.models import HistoricalRecords ORGANIZATION_OWNER = 'organization_owner' diff --git a/django_project/certification/models/course.py b/django_project/certification/models/course.py index 46bbfc8bd..09f5c044d 100644 --- a/django_project/certification/models/course.py +++ b/django_project/certification/models/course.py @@ -10,7 +10,7 @@ from django.db import models from django.contrib.auth.models import User from django.utils.text import slugify -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ import logging from unidecode import unidecode from core.settings.contrib import STOP_WORDS diff --git a/django_project/certification/models/course_convener.py b/django_project/certification/models/course_convener.py index 6dc2c0750..27deb8af2 100644 --- a/django_project/certification/models/course_convener.py +++ b/django_project/certification/models/course_convener.py @@ -9,7 +9,7 @@ from django.db import models from django.contrib.auth.models import User from django.utils.text import slugify -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from core.settings.contrib import STOP_WORDS from unidecode import unidecode from certification.models.certifying_organisation import CertifyingOrganisation diff --git a/django_project/certification/models/course_type.py b/django_project/certification/models/course_type.py index b3ac310e8..7e5863efc 100644 --- a/django_project/certification/models/course_type.py +++ b/django_project/certification/models/course_type.py @@ -6,7 +6,7 @@ from django.urls import reverse from django.db import models from django.contrib.auth.models import User -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from core.settings.contrib import STOP_WORDS from unidecode import unidecode from django.utils.text import slugify diff --git a/django_project/certification/models/organisation_certificate.py b/django_project/certification/models/organisation_certificate.py index f5b792e6d..539624a04 100644 --- a/django_project/certification/models/organisation_certificate.py +++ b/django_project/certification/models/organisation_certificate.py @@ -8,7 +8,7 @@ from django.urls import reverse from django.db import models from django.contrib.auth.models import User -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from simple_history.models import HistoricalRecords from .certifying_organisation import CertifyingOrganisation diff --git a/django_project/certification/models/status.py b/django_project/certification/models/status.py index f18a32bc5..c9b39b7e3 100644 --- a/django_project/certification/models/status.py +++ b/django_project/certification/models/status.py @@ -2,7 +2,7 @@ """Model for status of the certifying organisation.""" from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from base.models.project import Project diff --git a/django_project/certification/models/training_center.py b/django_project/certification/models/training_center.py index c76a85fd2..6e9f73843 100644 --- a/django_project/certification/models/training_center.py +++ b/django_project/certification/models/training_center.py @@ -4,7 +4,7 @@ """ from django.urls import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User from django.contrib.gis.db import models from .certifying_organisation import ( diff --git a/django_project/certification/serializers/rest_framework_gis/fields.py b/django_project/certification/serializers/rest_framework_gis/fields.py index cf815c780..499585c1e 100644 --- a/django_project/certification/serializers/rest_framework_gis/fields.py +++ b/django_project/certification/serializers/rest_framework_gis/fields.py @@ -4,7 +4,7 @@ from django.contrib.gis.geos import GEOSGeometry, GEOSException from django.contrib.gis.gdal import GDALException from django.core.exceptions import ValidationError -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from rest_framework.fields import Field, SerializerMethodField diff --git a/django_project/certification/templates/certifying_organisation/detail.html b/django_project/certification/templates/certifying_organisation/detail.html index 5077c2b09..fc5230fd1 100644 --- a/django_project/certification/templates/certifying_organisation/detail.html +++ b/django_project/certification/templates/certifying_organisation/detail.html @@ -572,7 +572,7 @@

    Courses