From a246fa1e1e977b4a778c5a21027f2d7818a177a2 Mon Sep 17 00:00:00 2001
From: andreighh <61791875+andreighh@users.noreply.github.com>
Date: Wed, 7 Oct 2026 10:46:42 +0300
Subject: [PATCH 1/4] Fix search crash, stale keyword flag and TED/UNGM config
issues
- Escape user search terms and keywords before building highlight
regexes (special characters caused re.error / 500 on search)
- Reset Tender.has_keywords when no keywords match on save
- Read TED_NOTICE_URL from its own env var instead of TED_URL
- Make TEDWorker.archives an instance attribute so archives are not
shared between worker instances
- Fail TED archive downloads on HTTP errors instead of saving the
error page as a .tar.gz
- Derive UNGM endpoint URLs and Host header from UNGM_ENDPOINT_URI;
drop unused ENDPOINT_URI constants
---
app/management/commands/add_award.py | 3 +--
app/management/commands/base/notify.py | 2 --
app/management/commands/update_ungm.py | 2 --
app/models.py | 7 +++----
app/parsers/ted.py | 10 +++++-----
app/server_requests.py | 13 ++++++++-----
app/views/tenders.py | 11 +++++++----
scratch/settings.py | 2 +-
8 files changed, 25 insertions(+), 25 deletions(-)
diff --git a/app/management/commands/add_award.py b/app/management/commands/add_award.py
index 813b61f..0a77007 100644
--- a/app/management/commands/add_award.py
+++ b/app/management/commands/add_award.py
@@ -7,12 +7,11 @@
from time import sleep
from random import randint
from app.models import Tender, Award, Vendor
-from app.server_requests import PAYLOAD
+from app.server_requests import PAYLOAD, WINNERS_ENDPOINT_URI
import logging
from app.management.commands.base.params import BaseParamsUI
logger = logging.getLogger(__name__)
-WINNERS_ENDPOINT_URI = 'https://www.ungm.org/Public/ContractAward'
CSS_TITLE = 'Title'
CSS_REFERENCE = 'Reference'
diff --git a/app/management/commands/base/notify.py b/app/management/commands/base/notify.py
index 8e1e82c..6b2d99c 100644
--- a/app/management/commands/base/notify.py
+++ b/app/management/commands/base/notify.py
@@ -12,8 +12,6 @@
from app.management.commands.base.params import BaseParamsUI
from app.utils import emails_to_notify
-ENDPOINT_URI = "https://www.ungm.org"
-
User = get_user_model()
diff --git a/app/management/commands/update_ungm.py b/app/management/commands/update_ungm.py
index cbd821c..bd76848 100644
--- a/app/management/commands/update_ungm.py
+++ b/app/management/commands/update_ungm.py
@@ -7,8 +7,6 @@
from app.notifications import send_error_email
from app.utils import log_tenders_update, TenderSource
-ENDPOINT_URI = 'https://www.ungm.org'
-
class Command(BaseCommand, BaseParamsUI):
help = 'Gets all UNGM tenders from the past n days'
diff --git a/app/models.py b/app/models.py
index 94dabfc..61fc79a 100644
--- a/app/models.py
+++ b/app/models.py
@@ -130,7 +130,7 @@ def marked_keyword_title(self):
if not keywords:
return title
- regex = r"(" + r"|".join(keywords) + r")"
+ regex = r"(" + r"|".join(map(re.escape, keywords)) + r")"
return re.sub(regex, r"\1", title, flags=re.IGNORECASE)
@cached_property
@@ -140,7 +140,7 @@ def marked_keyword_description(self):
if not keywords:
return description
- regex = r"(" + r"|".join(keywords) + r")"
+ regex = r"(" + r"|".join(map(re.escape, keywords)) + r")"
return re.sub(regex, r"\1", description, flags=re.IGNORECASE)
def find_keywords(self, fields):
@@ -161,8 +161,7 @@ def is_favorite_of(self, user):
def save(self, *args, **kwargs):
keywords = list(self.find_keywords(fields))
- if keywords:
- self.has_keywords = True
+ self.has_keywords = bool(keywords)
super().save(*args, **kwargs)
self.keywords.set(keywords)
diff --git a/app/parsers/ted.py b/app/parsers/ted.py
index 4d243c7..ac8ec0b 100644
--- a/app/parsers/ted.py
+++ b/app/parsers/ted.py
@@ -43,9 +43,8 @@ def quit_or_close(ftp):
class TEDWorker:
- archives = []
-
def __init__(self, last_ted_update=None):
+ self.archives = []
self.path = get_archives_path()
if not last_ted_update:
last_ted_update = date.today()
@@ -132,10 +131,11 @@ def download_archive(self, archive):
os.makedirs(self.path)
file_path = os.path.join(self.path, archive.full_oj_s)
url = urljoin(self.get_archive_url, archive.full_oj_s)
+ time.sleep(randint(2, 5))
+ response = requests.get(url)
+ print(response.status_code, file_path)
+ response.raise_for_status()
with open(file_path, "wb") as f:
- time.sleep(randint(2, 5))
- response = requests.get(url)
- print(response.status_code, file_path)
f.write(response.content)
self.archives.append(file_path)
diff --git a/app/server_requests.py b/app/server_requests.py
index a77a465..bd5984b 100644
--- a/app/server_requests.py
+++ b/app/server_requests.py
@@ -7,14 +7,17 @@
from random import randint
from time import sleep
+from urllib.parse import urlparse
from urllib3.exceptions import NewConnectionError
+from django.conf import settings
+
from app.models import UNSPSCCode
-LIVE_ENDPOINT_URI = 'https://www.ungm.org'
-TENDERS_ENDPOINT_URI = 'https://www.ungm.org/Public/Notice'
-WINNERS_ENDPOINT_URI = 'https://www.ungm.org/Public/ContractAward'
-SEARCH_UNSPSCS_URI = 'https://www.ungm.org/UNSPSC/Search'
+LIVE_ENDPOINT_URI = settings.UNGM_ENDPOINT_URI
+TENDERS_ENDPOINT_URI = LIVE_ENDPOINT_URI + '/Public/Notice'
+WINNERS_ENDPOINT_URI = LIVE_ENDPOINT_URI + '/Public/ContractAward'
+SEARCH_UNSPSCS_URI = LIVE_ENDPOINT_URI + '/UNSPSC/Search'
PAYLOAD = {
@@ -65,7 +68,7 @@
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'en-US,en;q=0.5',
'Content-Type': 'application/json; charset=UTF-8',
- 'Host': 'www.ungm.org',
+ 'Host': urlparse(LIVE_ENDPOINT_URI).netloc,
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:31.0)'
' Gecko/20100101 Firefox/31.0',
'X-Requested-With': 'XMLHttpRequest',
diff --git a/app/views/tenders.py b/app/views/tenders.py
index 3848642..87e1d0f 100644
--- a/app/views/tenders.py
+++ b/app/views/tenders.py
@@ -387,12 +387,15 @@ def get_context_data(self, **kwargs):
} for award in result_awards.to_queryset()
]
- regex = re.compile(rf'(\b({pk})\b(\s*({pk})\b)*)', re.I)
+ terms = '|'.join(
+ re.escape(term) for term in self.kwargs['pk'].split('+') if term)
+ if terms:
+ regex = re.compile(rf'(\b({terms})\b(\s*({terms})\b)*)', re.I)
- tender_fields = ['title', 'description']
+ tender_fields = ['title', 'description']
- SearchView.update_fields(context['tenders'], tender_fields, regex)
- SearchView.update_award_fields(context['awards'], regex)
+ SearchView.update_fields(context['tenders'], tender_fields, regex)
+ SearchView.update_award_fields(context['awards'], regex)
return context
diff --git a/scratch/settings.py b/scratch/settings.py
index 3cd50d0..6bd46e9 100644
--- a/scratch/settings.py
+++ b/scratch/settings.py
@@ -144,7 +144,7 @@
# TED
TED_URL = env("TED_URL", "ted.europa.eu")
-TED_NOTICE_URL = env("TED_URL", "en/notice/-/detail/")
+TED_NOTICE_URL = env("TED_NOTICE_URL", "en/notice/-/detail/")
TED_CALENDAR_URL = env("TED_CALENDAR_URL", "en/release-calendar/-/download/file/CSV/")
TED_DAILY = env("TED_DAILY", "packages/daily/")
TED_FTP_USER = env("TED_FTP_USER", "guest")
From cbf2cff040ba603f663b92c8ba5f46821ca8bf18 Mon Sep 17 00:00:00 2001
From: andreighh <61791875+andreighh@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:27:55 +0300
Subject: [PATCH 2/4] Add packages on ghcr and CI
---
.github/workflows/docker.yml | 49 +++++++++++++
.github/workflows/tests.yml | 84 ++++++++++++++++++++++
.travis.yml | 38 ----------
CLAUDE.md | 2 +-
README.md | 3 +-
Release.md | 136 +++++++++++++++++++++++++++++++++++
docker-compose.yml | 6 +-
requirements-dev.txt | 2 -
scratch/settings.py | 2 +-
scratch/test_settings.py | 21 ------
10 files changed, 276 insertions(+), 67 deletions(-)
create mode 100644 .github/workflows/docker.yml
create mode 100644 .github/workflows/tests.yml
delete mode 100644 .travis.yml
create mode 100644 Release.md
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..5f5af81
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,49 @@
+name: Docker image
+
+# Builds the image and pushes it to GHCR when a version tag (e.g. 1.0.62) is
+# pushed. Can also be started by hand from the Actions tab.
+on:
+ push:
+ tags:
+ - '[0-9]+.[0-9]+.[0-9]+'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+env:
+ IMAGE: ghcr.io/${{ github.repository }}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: docker/setup-buildx-action@v4
+
+ - uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Version tag -> ghcr.io/eaudeweb/scratch:1.0.62 and :latest
+ - id: meta
+ uses: docker/metadata-action@v6
+ with:
+ images: ${{ env.IMAGE }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=sha
+
+ - uses: docker/build-push-action@v7
+ with:
+ context: .
+ platforms: linux/amd64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..aee2cba
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,84 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: [master]
+ push:
+ branches: [master]
+
+permissions:
+ contents: read
+
+# A new push to the same branch or pull request cancels the previous run
+concurrency:
+ group: tests-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ # Same major versions as docker-compose.yml
+ services:
+ postgres:
+ image: postgres:15-alpine
+ env:
+ POSTGRES_DB: scratch
+ POSTGRES_USER: scratch
+ POSTGRES_PASSWORD: scratch
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U scratch"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+ elasticsearch:
+ image: docker.elastic.co/elasticsearch/elasticsearch:7.17.8
+ env:
+ discovery.type: single-node
+ xpack.security.enabled: "false"
+ ES_JAVA_OPTS: -Xms512m -Xmx512m
+ ports:
+ - 9200:9200
+ options: >-
+ --health-cmd "curl -fs http://localhost:9200/_cluster/health"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 20
+ redis:
+ image: redis:alpine
+ ports:
+ - 6379:6379
+
+ env:
+ POSTGRES_HOST: localhost
+ POSTGRES_DB: scratch
+ POSTGRES_USER: scratch
+ POSTGRES_PASSWORD: scratch
+ ELASTICSEARCH_HOST: localhost:9200
+ REDIS_HOST: localhost
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.11'
+ cache: pip
+ cache-dependency-path: requirements*.txt
+
+ # Headers needed to build python-ldap and psycopg2
+ - name: Install system packages
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends libldap-dev libsasl2-dev libpq-dev
+
+ - name: Install dependencies
+ run: pip install -r requirements-dev.txt
+
+ - name: Check
+ run: python manage.py check
+
+ - name: Run tests
+ run: python manage.py test app/tests --settings=scratch.test_settings
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index ed6488c..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-file: .travis.yml
-language: python
-python:
- - "3.6"
-addons:
- postgresql: "10"
- apt:
- packages:
- - postgresql-10
- - postgresql-client-10
-env:
- global:
- - DEBUG=True
- - BASE_URL=localhost:8000
- - PGPORT=5432
- - ES_VERSION=6.6.1
- - ES_DOWNLOAD_URL=https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-${ES_VERSION}.tar.gz
- - ELASTICSEARCH_HOST=127.0.0.1
- - ELASTICSEARCH_AUTH=elastic:changeme
-services:
- - postgresql
-before_script:
- - psql -c 'create database travis_ci_test;' -U postgres
- - sudo sysctl -w vm.max_map_count=262144
- - sudo service elasticsearch start
- - mkdir logging
- - chmod -R 777 logging
-install:
- - wget ${ES_DOWNLOAD_URL}
- - tar -xzf elasticsearch-${ES_VERSION}.tar.gz
- - ./elasticsearch-${ES_VERSION}/bin/elasticsearch &
- - pip install -r requirements-dev.txt
-script:
- - wget -q --waitretry=1 --retry-connrefused -T 10 -O - http://127.0.0.1:9200
- - ./manage.py check
- - coverage run --source='.' ./manage.py test app/tests --settings=scratch.test_settings
-after_success:
- - coveralls
diff --git a/CLAUDE.md b/CLAUDE.md
index b7a44f0..10599bb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,7 +36,7 @@ python manage.py test app.tests.test_ted_parser.TestClassName.test_method --sett
- Test fixtures (sample TED XML, UNGM/IUCN HTML, PDFs/DOCX) live in `app/tests/parser_files/`; model factories are in `app/factories.py` (factory_boy).
- Tests touching `Keyword` should extend `app.tests.base.BaseTestCase`, which disconnects the Keyword post_save/post_delete signals (otherwise they enqueue a django-q task that re-saves every tender).
-- CI (`.travis.yml`) runs `./manage.py check` then `coverage run --source='.' ./manage.py test app/tests --settings=scratch.test_settings`.
+- CI (`.github/workflows/tests.yml`, on pull requests to and pushes to `master`) runs `./manage.py check` then `./manage.py test app/tests --settings=scratch.test_settings` against Postgres, Elasticsearch and Redis service containers. `.github/workflows/docker.yml` builds and pushes the image to GHCR on version tags; see `Release.md`.
There is no configured linter/formatter.
diff --git a/README.md b/README.md
index 70498a4..aec0d08 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Scratch  [](https://coveralls.io/github/eaudeweb/scratch?branch=master) [](https://hub.docker.com/r/eaudeweb/scratch/builds)
+# Scratch [](https://github.com/eaudeweb/scratch/actions/workflows/tests.yml) [](https://github.com/eaudeweb/scratch/actions/workflows/docker.yml)
### Python Django Scratch
@@ -46,6 +46,7 @@
python manage.py runserver 0.0.0.0:8000
```
### [Cachet Setup Info](Cachet.md)
+### [Making a release](Release.md)
## Running with NGINX
If you want to use NGINX to serve the project you need to do the following:
diff --git a/Release.md b/Release.md
new file mode 100644
index 0000000..1504bc9
--- /dev/null
+++ b/Release.md
@@ -0,0 +1,136 @@
+# Releasing Scratch
+
+Releases are Docker images built by GitHub Actions and published to
+`ghcr.io/eaudeweb/scratch`. Production is a single `docker-compose.yml` on the
+server that runs the `web`, `async` and `cron` services from that image (the
+crontab is baked into the image too). Deploying is manual.
+
+```
+git tag 1.0.62 ──> GitHub Actions ──> ghcr.io/eaudeweb/scratch:1.0.62 ──> manual pull on prod
+```
+
+## 1. Before tagging
+
+- Make sure the **Tests** workflow (`.github/workflows/tests.yml`) is green on
+ the pull request. It runs the test suite on every pull request to `master` and
+ every push to `master`, so you don't need to run it yourself.
+- If you changed a scraper, run its update command once locally against a short
+ range (e.g. `python manage.py update_ungm --days_ago 1`). Keep it to one run so
+ the sources don't rate limit or block us.
+- Check for new migrations (`python manage.py makemigrations --check --dry-run`)
+ and note whether the release has any, you'll need it when deploying.
+- Merge the work into `master`, and check that the Tests run on `master` is green
+ before tagging.
+
+## 2. Tag the release
+
+Versions are plain `MAJOR.MINOR.PATCH`, without a `v` prefix. The last image on
+Docker Hub was `1.0.61`, so GHCR releases start at `1.0.62`. To see the latest
+tag, fetch them first (clones don't always have all of them):
+
+```bash
+git fetch --tags
+git tag --sort=-v:refname | head -3
+```
+
+Tag the merge commit on `master` and push the tag:
+
+```bash
+git checkout master && git pull
+git tag 1.0.62
+git push origin 1.0.62
+```
+
+Pushing the tag starts the **Docker image** workflow
+(`.github/workflows/docker.yml`). Follow it in the repository's Actions tab. When
+it is green, the image is available as:
+
+- `ghcr.io/eaudeweb/scratch:1.0.62`
+- `ghcr.io/eaudeweb/scratch:latest`
+- `ghcr.io/eaudeweb/scratch:sha-`
+
+The workflow can also be started by hand from the Actions tab (*Run workflow*).
+A manual run from a branch only produces the `sha-` tag, which is useful
+for trying a build without making a release.
+
+## 3. Deploy to production
+
+On the server, in the directory that holds the compose file:
+
+1. Set the new version in the `.env` file next to the compose file:
+
+ ```bash
+ SCRATCH_VERSION=1.0.62
+ ```
+
+ The compose file uses it for the `web`, `async` and `cron` images
+ (`image: ghcr.io/eaudeweb/scratch:${SCRATCH_VERSION}`), so the compose file
+ itself doesn't change between releases. Docker Compose reads `.env` from the
+ project directory automatically. Use a version number rather than `latest`, so
+ it's always clear what is deployed and a rollback is a one-line edit.
+
+2. Pull and restart:
+
+ ```bash
+ docker compose pull
+ docker compose up -d
+ ```
+
+ `pull` downloads the new image while the old containers keep running, so a
+ wrong tag or a registry problem fails before anything is restarted. `up -d`
+ then recreates only the services whose image changed.
+
+3. Check that it came up:
+
+ ```bash
+ docker compose ps
+ docker compose logs --tail=50 web async cron
+ ```
+
+ The Cachet status page should report all components as operational.
+
+### Migrations, static files and the search index
+
+`docker-entrypoint.sh` runs these steps when `web` starts, depending on its
+environment variables (set in the compose file or `docker/app.env`):
+
+| Variable | When `yes`, on every start of `web` |
+|---|---|
+| `DJANGO_MIGRATE` | `manage.py migrate` |
+| `DJANGO_COLLECT_STATIC` | `manage.py collectstatic` |
+| `DJANGO_INDEX_CONTENT` | `manage.py search_index -f --rebuild` (rebuilds every Elasticsearch index, can take a while) |
+
+If migrations are not run automatically and the release has any, run them after
+`up -d`:
+
+```bash
+docker compose exec web python manage.py migrate
+```
+
+## Rolling back
+
+Set `SCRATCH_VERSION` in `.env` back to the previous version and run the same
+`docker compose pull` and `docker compose up -d`. Migrations are not reversed by
+this. If the release added any, check that the old code still works with the new
+schema, or migrate back explicitly
+(`python manage.py migrate app `) before switching images.
+
+## One-time setup (only when we switched from dockerhub to GitHub packages)
+
+- **Organization settings:** GitHub Actions must be allowed to create packages
+ for the `eaudeweb` organization. The first tag push creates the `scratch`
+ package.
+- **Access from production:** either make the `scratch` package public (the
+ repository already is), or log in once on the server with a personal access
+ token that has the `read:packages` scope:
+
+ ```bash
+ echo | docker login ghcr.io -u --password-stdin
+ ```
+
+- **Production compose file:** switch `image:` from `eaudeweb/scratch:...` (Docker
+ Hub) to `ghcr.io/eaudeweb/scratch:${SCRATCH_VERSION}` for `web`, `async` and
+ `cron`.
+
+Images up to `1.0.61` are on Docker Hub as `eaudeweb/scratch`. Docker Hub no
+longer receives new versions.
diff --git a/docker-compose.yml b/docker-compose.yml
index 006c0f4..d7ff099 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,7 +2,7 @@ version: '2'
services:
web:
- image: eaudeweb/scratch:latest
+ image: ghcr.io/eaudeweb/scratch:${SCRATCH_VERSION:-latest}
container_name: scratch.app
environment:
- DJANGO_MIGRATE=yes
@@ -25,7 +25,7 @@ services:
- 8000:8000
async:
- image: eaudeweb/scratch:latest
+ image: ghcr.io/eaudeweb/scratch:${SCRATCH_VERSION:-latest}
container_name: scratch.async
env_file:
- docker/app.env
@@ -45,7 +45,7 @@ services:
command: ['qcluster']
cron:
- image: eaudeweb/scratch:latest
+ image: ghcr.io/eaudeweb/scratch:${SCRATCH_VERSION:-latest}
container_name: scratch.cron
environment:
- TZ=Europe/Bucharest
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 44dfc5c..b8dcc81 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,4 +1,2 @@
-r requirements.txt
-coverage==4.5.4
-coveralls==1.8.2
factory_boy==3.2.1
diff --git a/scratch/settings.py b/scratch/settings.py
index 6bd46e9..3611989 100644
--- a/scratch/settings.py
+++ b/scratch/settings.py
@@ -90,7 +90,7 @@
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
- "HOST": "db",
+ "HOST": env("POSTGRES_HOST", "db"),
"PORT": 5432,
"NAME": env("POSTGRES_DB", "scratch"),
"USER": env("POSTGRES_USER", "scratch"),
diff --git a/scratch/test_settings.py b/scratch/test_settings.py
index 18414db..7d7765a 100644
--- a/scratch/test_settings.py
+++ b/scratch/test_settings.py
@@ -1,22 +1 @@
from .settings import *
-from getenv import env
-
-
-if 'TRAVIS' in os.environ:
- DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.postgresql',
- 'NAME': 'travis_ci_test',
- 'USER': 'postgres',
- 'PASSWORD': '',
- 'HOST': 'localhost',
- 'PORT': os.getenv('PGPORT'),
- },
- }
-
- ELASTICSEARCH_DSL = {
- 'default': {
- 'hosts': env('ELASTICSEARCH_TEST_HOST'),
- 'http_auth': env('ELASTICSEARCH_TEST_AUTH'),
- },
- }
From d93e109dd0bf73a7c73f7deaa29ab450d570b378 Mon Sep 17 00:00:00 2001
From: andreighh <61791875+andreighh@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:50:47 +0300
Subject: [PATCH 3/4] Make it work despite outdated packages
---
requirements.txt | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/requirements.txt b/requirements.txt
index bdd71bd..06c7931 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@ python-dateutil>=1.5
beautifulsoup4==4.11.1
Django==4.1.13
django-auth-ldap==3.0.0
-django-elasticsearch-dsl==7.2.2
+django-elasticsearch-dsl==7.3
datefinder==0.7.3
pdfminer==20191125
docx-parser==1.0.1
@@ -10,10 +10,12 @@ docx2txt==0.8
django-getenv==1.3.2
django-q==1.3.9
django-sql-explorer==2.5.0
-django-widget-tweaks==1.4.11
+django-widget-tweaks==1.5.0
elasticsearch-dsl==7.4.0
psycopg2==2.9.5
redis==3.5.3
requests==2.31.0
sentry_sdk==1.14.0
tika==1.24
+# django-q 1.3.9 imports pkg_resources, which setuptools 82 removed
+setuptools<82
From 7b572b00a024e8244c00cdbab733429a72615fa9 Mon Sep 17 00:00:00 2001
From: andreighh <61791875+andreighh@users.noreply.github.com>
Date: Thu, 8 Oct 2026 12:07:35 +0300
Subject: [PATCH 4/4] Cachet logging issues
---
app/tests/test_error.py | 4 ++--
app/utils.py | 4 ++++
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/app/tests/test_error.py b/app/tests/test_error.py
index 586d39d..9d9db6e 100644
--- a/app/tests/test_error.py
+++ b/app/tests/test_error.py
@@ -10,7 +10,7 @@ class UpdateErrorTestCase(BaseTestCase):
@patch.object(
TEDWorker, "download_latest_archives", side_effect=Exception("Test error")
)
- @patch("app.notifications.send_error_email")
+ @patch("app.management.commands.update_ted.send_error_email")
def test_ted_update_error(self, send_error_email, _):
with self.assertRaises(Exception):
call_command("update_ted")
@@ -19,7 +19,7 @@ def test_ted_update_error(self, send_error_email, _):
@patch.object(
UNGMWorker, "parse_latest_notices", side_effect=Exception("Test error")
)
- @patch("app.notifications.send_error_email")
+ @patch("app.management.commands.update_ungm.send_error_email")
def test_ungm_update_error(self, send_error_email, _):
with self.assertRaises(Exception):
call_command("update_ungm", days_ago=1)
diff --git a/app/utils.py b/app/utils.py
index 6a1f556..2e290bf 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -21,6 +21,10 @@ def emails_to_notify():
def log_tenders_update(tender_source: TenderSource):
def decorator(fn):
def wrapper(*args, **kwargs):
+ if not settings.APP_URL:
+ # Cachet is not configured
+ return fn(*args, **kwargs)
+
headers = {
"accept": "application/json",
"X-Cachet-Application": "Demo",