From 9ff44e5c52a4d15ee3e617add146443df09ca17d Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 27 Mar 2026 12:53:00 +0500 Subject: [PATCH 01/18] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BD=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20=D0=BF=D1=83=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20=D0=B2=20?= =?UTF-8?q?docs/readme.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/readme.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/readme.md b/docs/readme.md index 782c3799..054001fe 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -5,4 +5,3 @@ - [redoc](https://api.procollab.ru/redoc) ## [WebSockets для чатов](/docs/chats.md) - From 957c6c5c2b553857c6a27419ce4b88951b2a9aa7 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 27 Mar 2026 12:58:44 +0500 Subject: [PATCH 02/18] =?UTF-8?q?=D0=A3=D0=BF=D1=80=D0=BE=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20workflow=20CI/CD,=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D1=91=D0=BD=20PR=20template=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20ClickUp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 24 +++---- .github/workflows/{django-test.yml => ci.yml} | 28 ++++++-- .github/workflows/dev-ci.yml | 8 +-- .github/workflows/lints.yml | 29 --------- .github/workflows/new_deploy.yml | 47 -------------- .github/workflows/release-ci.yml | 14 ++-- partner_programs/views.py | 2 +- users/services/verification.py | 64 ------------------- users/views.py | 9 --- 9 files changed, 47 insertions(+), 178 deletions(-) rename .github/workflows/{django-test.yml => ci.yml} (67%) delete mode 100644 .github/workflows/lints.yml delete mode 100644 .github/workflows/new_deploy.yml delete mode 100644 users/services/verification.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 33062287..5d6bcb53 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,18 +1,20 @@ -# Краткое название +# Что изменено -## Описание изменений +Кратко опишите изменения и при необходимости приложите ссылку на задачу. -_Опишите изменения, которые вы внесли в код. Не забывайте указывать номер задачи или ссылку на тикет._ +## Проверка +Укажите, как проверялись изменения. -## Тестирование +- автоматические проверки +- ручная проверка +- шаги для воспроизведения +- ожидаемый результат -_Опишите, как тестировали свои изменения. Например, какие тесты проходят, а какие нет._ +## Риски и ограничения -## Проверка кода +Укажите важные ограничения и возможные побочные эффекты. -_Опишите, как проверить ваш код._ - -## Дополнительная информация - -_Здесь вы можете добавить какую-либо дополнительную информацию о своих изменениях._ +- миграции, изменения переменных окружения, feature flags +- замечания по деплою +- места, которые требуют повышенного внимания diff --git a/.github/workflows/django-test.yml b/.github/workflows/ci.yml similarity index 67% rename from .github/workflows/django-test.yml rename to .github/workflows/ci.yml index 26a56bd9..fa92e86d 100644 --- a/.github/workflows/django-test.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,32 @@ -name: Django CI +name: CI on: push: branches: - - '**' + - "**" jobs: - django-test: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install lint dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + + - name: Run flake8 + run: flake8 $(git ls-files '*.py') + + test: + name: Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -45,4 +65,4 @@ jobs: env: DEBUG: True DJANGO_SETTINGS_MODULE: procollab.settings - PYTHONUNBUFFERED: 1 \ No newline at end of file + PYTHONUNBUFFERED: 1 diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 1600f184..341680c6 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -1,4 +1,4 @@ -name: 'Deploy dev server' +name: Deploy Dev on: push: @@ -8,9 +8,10 @@ on: jobs: deploy: + name: Deploy runs-on: ubuntu-latest steps: - - name: run on server + - name: Deploy to server uses: garygrossgarten/github-action-ssh@release with: host: ${{ secrets.DEV_SERVER_HOST }} @@ -41,9 +42,6 @@ jobs: echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env && echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env && - echo "CLICKUP_API_TOKEN=${{ secrets.CLICKUP_API_TOKEN }}" >> .env && - echo "CLICKUP_SPACE_ID=${{ secrets.CLICKUP_SPACE_ID }}" >> .env && - echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env && echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env && diff --git a/.github/workflows/lints.yml b/.github/workflows/lints.yml deleted file mode 100644 index e435855b..00000000 --- a/.github/workflows/lints.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Pylint - -on: - push: - branches: - - '*' # matches every branch that doesn't contain a '/' - - '*/*' # matches every branch containing a single '/' - - '**' # matches every branch - - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.11] - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 - - name: Analysing the code with flake8 - run: | - flake8 $(git ls-files '*.py') diff --git a/.github/workflows/new_deploy.yml b/.github/workflows/new_deploy.yml deleted file mode 100644 index 823a8c06..00000000 --- a/.github/workflows/new_deploy.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: 'new deploy prod server' - -on: - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: run on server - uses: garygrossgarten/github-action-ssh@release - with: - host: ${{ secrets.SERVER_HOST }} - username: ${{ secrets.SERVER_USER }} - password: ${{ secrets.SERVER_PASSWORD }} - command: | - cd /home/app/new_procollab_deploy && - git pull origin master && - - rm -f .env && - touch .env && - - echo "DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }}" >> .env && - - echo "DATABASE_NAME=${{ secrets.DATABASE_NAME }}" >> .env && - echo "DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }}" >> .env && - echo "DATABASE_USER=${{ secrets.DATABASE_USER }}" >> .env && - echo "DATABASE_HOST=${{ secrets.DATABASE_HOST }}" >> .env && - echo "DATABASE_PORT=${{ secrets.DATABASE_PORT }}" >> .env && - - echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env && - echo "EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }}" >> .env && - echo "EMAIL_HOST=${{ secrets.EMAIL_HOST }}" >> .env && - echo "EMAIL_PORT=${{ secrets.EMAIL_PORT }}" >> .env && - echo "SELECTEL_ACCOUNT_ID=${{ secrets.SELECTEL_ACCOUNT_ID }}" >> .env && - echo "SELECTEL_CONTAINER_NAME=${{ secrets.SELECTEL_CONTAINER_NAME }}" >> .env && - echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env && - echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env && - - echo "CLICKUP_API_TOKEN=${{ secrets.CLICKUP_API_TOKEN }}" >> .env && - echo "CLICKUP_SPACE_ID=${{ secrets.CLICKUP_SPACE_ID }}" >> .env && - - echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env && - - echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env && - - docker compose -f docker-compose.prod-ci.yml -p prod up -d --build diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index 2ff0f54d..c0f97012 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -1,4 +1,4 @@ -name: 'Build and Deploy server' +name: Release Prod on: release: @@ -7,7 +7,7 @@ on: jobs: test: - name: 'Test before deploy' + name: Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -46,7 +46,7 @@ jobs: DEBUG: True build: - name: 'Build & Publish' + name: Build Image runs-on: ubuntu-latest needs: [ test ] steps: @@ -86,11 +86,12 @@ jobs: cache-to: type=gha,mode=max tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - run: + deploy: + name: Deploy runs-on: ubuntu-latest needs: [ build ] steps: - - name: run on server + - name: Deploy to server uses: garygrossgarten/github-action-ssh@release with: host: ${{ secrets.SERVER_HOST }} @@ -122,9 +123,6 @@ jobs: echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env && echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env && - echo "CLICKUP_API_TOKEN=${{ secrets.CLICKUP_API_TOKEN }}" >> .env && - echo "CLICKUP_SPACE_ID=${{ secrets.CLICKUP_SPACE_ID }}" >> .env && - echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env && echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env && diff --git a/partner_programs/views.py b/partner_programs/views.py index 5f16c713..af312f75 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -301,7 +301,7 @@ def post(self, request, *args, **kwargs): "birthday": date_to_iso(data.get("birthday", "01-01-1900")), "is_active": True, # bypass email verification "onboarding_stage": None, # bypass onboarding - "verification_date": timezone.now(), # bypass ClickUp verification + "verification_date": timezone.now(), # bypass manual verification **{field_name: data.get(field_name, "") for field_name in user_fields}, }, ) diff --git a/users/services/verification.py b/users/services/verification.py deleted file mode 100644 index 5a26f120..00000000 --- a/users/services/verification.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -A verification service that uses ClickUp API for creating tasks - -When the user confirms his email, the service creates a task in the ClickUp Space with information -about the user so that the moderator can manually check the information about him -""" - -import requests -from decouple import config -from django.contrib.auth import get_user_model - -User = get_user_model() - - -class VerificationTasks: - instance = None - __TOKEN = config("CLICKUP_API_TOKEN", default="default-api-key", cast=str) - __CLICKUP_SPACE_ID = config("CLICKUP_SPACE_ID", default=0, cast=int) - - @classmethod - def create(cls, user: User): - data = cls._collect_data(user) - cls._send_request(data) - - @classmethod - def _collect_data(cls, user: User): - # todo DEBUG mode - link_to_platform = f"https://app.procollab.ru/office/profile/{user.pk}/" - link_to_admin_panel = ( - f"https://api.procollab.ru/admin/users/customuser/{user.pk}/change/" - ) - - priority = 3 # normal - if user.user_type != User.MEMBER: - priority = 1 # urgent - - description = f"Профиль в админке: {link_to_admin_panel}\nПрофиль на платформе: {link_to_platform}" - name = f"{user.pk}: {user.first_name} {user.last_name}" - - return { - "name": name, - "priority": priority, - "description": description, - } - - @classmethod - def _send_request(cls, data): - try: - url = f"https://api.clickup.com/api/v2/list/{cls.__CLICKUP_SPACE_ID}/task/" - requests.post( - url, - data=data, - headers={ - "Authorization": cls.__TOKEN, - }, - ).json() - except Exception: - # fixme - pass - - def __new__(cls, *args, **kwargs): - if not hasattr(cls, "instance"): - cls.instance = super().__new__(cls) - return cls.instance diff --git a/users/views.py b/users/views.py index 4a59332f..dfa7fc88 100644 --- a/users/views.py +++ b/users/views.py @@ -46,7 +46,6 @@ VERBOSE_ROLE_TYPES, VERBOSE_USER_TYPES, VERIFY_EMAIL_REDIRECT_URL, - OnboardingStage, ) from users.helpers import check_related_fields_update, force_verify_user, verify_email from users.models import LikesOnProject, UserAchievement, UserSkillConfirmation @@ -76,7 +75,6 @@ from .pagination import UsersPagination from .schema import SKILL_PK_PARAM, USER_PK_PARAM from .services.cv_data_prepare import UserCVDataPreparerV2 -from .services.verification import VerificationTasks from .tasks import send_mail_cv User = get_user_model() @@ -493,13 +491,6 @@ def put(self, request: Request, pk): status=status.HTTP_400_BAD_REQUEST, data={"error": "Wrong onboarding stage number!"}, ) - # if the user was on the last stage and passed it - if ( - request.user.onboarding_stage == OnboardingStage.account_type.value - and new_stage == OnboardingStage.completed.value - ): - VerificationTasks.create(request.user) - request.user.onboarding_stage = new_stage request.user.save() From 1dee5f057fef93b3be1a9611fd19a9fc94d85e21 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 27 Mar 2026 14:18:26 +0500 Subject: [PATCH 03/18] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=20dev=20=D0=BD=D0=B0=20host=20nginx=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20=D0=BB=D0=B8=D1=88=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B8=D0=B7=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 17 ++++---- .github/workflows/release-ci.yml | 5 --- deploy/nginx/host/dev/dev.procollab.ru | 26 ++++++++++++ deploy/nginx/host/includes/proxy_app.inc | 15 +++++++ docker-compose.dev-ci.yml | 52 ------------------------ 5 files changed, 51 insertions(+), 64 deletions(-) create mode 100644 deploy/nginx/host/dev/dev.procollab.ru create mode 100644 deploy/nginx/host/includes/proxy_app.inc diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 341680c6..dcfd87b1 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -33,17 +33,20 @@ jobs: echo "DATABASE_HOST=${{ secrets.DEV_DATABASE_HOST }}" >> .env && echo "DATABASE_PORT=${{ secrets.DEV_DATABASE_PORT }}" >> .env && - echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env && - echo "EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }}" >> .env && - echo "EMAIL_HOST=${{ secrets.EMAIL_HOST }}" >> .env && - echo "EMAIL_PORT=${{ secrets.EMAIL_PORT }}" >> .env && + echo "SELECTEL_ACCOUNT_ID=${{ secrets.SELECTEL_ACCOUNT_ID }}" >> .env && echo "SELECTEL_CONTAINER_NAME=${{ secrets.SELECTEL_CONTAINER_NAME }}" >> .env && echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env && echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env && - echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env && - + echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env && echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env && - docker compose -f docker-compose.dev-ci.yml up -d --build --force-recreate + docker compose -f docker-compose.dev-ci.yml up -d --build --force-recreate --remove-orphans && + + install -d /etc/nginx/procollab/includes && + install -m 644 deploy/nginx/host/includes/proxy_app.inc /etc/nginx/procollab/includes/proxy_app.inc && + install -m 644 deploy/nginx/host/dev/dev.procollab.ru /etc/nginx/sites-available/dev.procollab.ru && + ln -sfn /etc/nginx/sites-available/dev.procollab.ru /etc/nginx/sites-enabled/dev.procollab.ru && + nginx -t && + systemctl reload nginx diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index c0f97012..2c55170b 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -115,15 +115,10 @@ jobs: echo "DATABASE_PORT=${{ secrets.DATABASE_PORT }}" >> .env && echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env && - echo "EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }}" >> .env && - echo "EMAIL_HOST=${{ secrets.EMAIL_HOST }}" >> .env && - echo "EMAIL_PORT=${{ secrets.EMAIL_PORT }}" >> .env && echo "SELECTEL_ACCOUNT_ID=${{ secrets.SELECTEL_ACCOUNT_ID }}" >> .env && echo "SELECTEL_CONTAINER_NAME=${{ secrets.SELECTEL_CONTAINER_NAME }}" >> .env && echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env && echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env && - - echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env && echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env && diff --git a/deploy/nginx/host/dev/dev.procollab.ru b/deploy/nginx/host/dev/dev.procollab.ru new file mode 100644 index 00000000..209fb6ca --- /dev/null +++ b/deploy/nginx/host/dev/dev.procollab.ru @@ -0,0 +1,26 @@ +server { + listen 80; + server_name dev.procollab.ru; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + try_files $uri =404; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name dev.procollab.ru; + + ssl_certificate /etc/letsencrypt/live/dev.procollab.ru-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dev.procollab.ru-0001/privkey.pem; + + location / { + include /etc/nginx/procollab/includes/proxy_app.inc; + } +} diff --git a/deploy/nginx/host/includes/proxy_app.inc b/deploy/nginx/host/includes/proxy_app.inc new file mode 100644 index 00000000..dda1da81 --- /dev/null +++ b/deploy/nginx/host/includes/proxy_app.inc @@ -0,0 +1,15 @@ +proxy_pass http://127.0.0.1:8000; + +proxy_http_version 1.1; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection "upgrade"; + +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; + +proxy_read_timeout 600s; +proxy_send_timeout 600s; +proxy_connect_timeout 60s; +proxy_buffering off; diff --git a/docker-compose.dev-ci.yml b/docker-compose.dev-ci.yml index d83371f2..fb2504c9 100644 --- a/docker-compose.dev-ci.yml +++ b/docker-compose.dev-ci.yml @@ -16,51 +16,6 @@ services: ports: - "127.0.0.1:8000:8000" - grafana: - image: grafana/grafana:latest - restart: unless-stopped - expose: - - 3000 - volumes: - - grafana-data:/var/lib/grafana - - grafana-configs:/etc/grafana - environment: - - GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s:%(http_port)s/grafana - - GF_SERVER_SERVE_FROM_SUB_PATH=true - - prometheus: - image: prom/prometheus:v2.36.0 - restart: unless-stopped - expose: - - 9090 - volumes: - - prom-data:/prometheus - - ./prometheus:/etc/prometheus - - #nginx: - # restart: unless-stopped - # build: ./nginx - # depends_on: - # - web - # ports: - # - 8000:80 - - loki: - image: grafana/loki:2.9.0 - restart: unless-stopped - ports: - - "3100:3100" - command: -config.file=/etc/loki/local-config.yaml - - promtail: - image: grafana/promtail:2.9.0 - restart: unless-stopped - volumes: - - /var/log:/var/log - - ./promtail:/etc/promtail - - ./log:/procollab/log - command: -config.file=/etc/promtail/config.yml - redis: image: redis:latest restart: unless-stopped @@ -85,12 +40,5 @@ services: - web volumes: - .:/procollab - volumes: - grafana-data: - grafana-configs: - prom-data: - prom-configs: - log: - promtail: redis-data: From 0f6715ed4a15d833f5a9c9e6972a5301a958da24 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 27 Mar 2026 14:20:21 +0500 Subject: [PATCH 04/18] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D1=83?= =?UTF-8?q?=D0=B5=D0=BC=D1=8B=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=B7=20release=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index 2c55170b..c0377569 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -114,12 +114,12 @@ jobs: echo "DATABASE_HOST=${{ secrets.DATABASE_HOST }}" >> .env && echo "DATABASE_PORT=${{ secrets.DATABASE_PORT }}" >> .env && - echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env && echo "SELECTEL_ACCOUNT_ID=${{ secrets.SELECTEL_ACCOUNT_ID }}" >> .env && echo "SELECTEL_CONTAINER_NAME=${{ secrets.SELECTEL_CONTAINER_NAME }}" >> .env && echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env && echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env && + echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env && echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env && docker compose -f docker-compose.prod-ci.yml -p prod up -d From cb800dbb686927404bdd8f87dae5d8a32eee6bcc Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 27 Mar 2026 14:57:25 +0500 Subject: [PATCH 05/18] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=20dj?= =?UTF-8?q?ango=5Fprometheus=20=D0=B8=20=D0=B2=D1=8B=D1=87=D0=B8=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20legacy-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=D0=B8=20observability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 40 ---------------------- loki/loki-config.yaml | 50 --------------------------- poetry.lock | 72 +++++++++++++++++++++++---------------- procollab/settings.py | 9 ++--- procollab/urls.py | 1 - prometheus/prometheus.yml | 10 ------ promtail/config.yml | 23 ------------- pyproject.toml | 1 - 8 files changed, 46 insertions(+), 160 deletions(-) delete mode 100644 loki/loki-config.yaml delete mode 100644 prometheus/prometheus.yml delete mode 100644 promtail/config.yml diff --git a/docker-compose.yml b/docker-compose.yml index 0bbeda3c..b4c108b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,26 +17,6 @@ services: expose: - 8000 - grafana: - image: grafana/grafana-enterprise - container_name: grafana - restart: unless-stopped - ports: - - '3000:3000' - volumes: - - grafana-storage:/var/lib/grafana - - - prometheus: - container_name: prometheus - image: prom/prometheus:v2.36.0 - ports: - - '9090:9090' - volumes: - - prom-data:/prometheus - - ./prometheus:/etc/prometheus - - nginx: container_name: nginx build: ./nginx @@ -44,20 +24,6 @@ services: - web ports: - "8000:80" -# todo: настроим позже -# loki: -# image: grafana/loki:2.9.0 -# ports: -# - "3100:3100" -# command: -config.file=/etc/loki/local-config.yaml -# -# promtail: -# image: grafana/promtail:2.9.0 -# volumes: -# - /var/log:/var/log -# - ./promtail:/etc/promtail -# - ./log:/procollab/log -# command: -config.file=/etc/promtail/config.yml redis: container_name: redis @@ -83,11 +49,5 @@ services: - .:/procollab volumes: - grafana-data: - grafana-configs: - prom-data: - prom-configs: log: - promtail: redis-data: - grafana-storage: {} diff --git a/loki/loki-config.yaml b/loki/loki-config.yaml deleted file mode 100644 index 68c3c9fb..00000000 --- a/loki/loki-config.yaml +++ /dev/null @@ -1,50 +0,0 @@ -auth_enabled: false - -server: - http_listen_port: 3100 - grpc_listen_port: 9096 - -common: - instance_addr: 127.0.0.1 - path_prefix: /tmp/loki - storage: - filesystem: - chunks_directory: /tmp/loki/chunks - rules_directory: /tmp/loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -query_range: - results_cache: - cache: - embedded_cache: - enabled: true - max_size_mb: 100 - -schema_config: - configs: - - from: 2020-10-24 - store: boltdb-shipper - object_store: filesystem - schema: v11 - index: - prefix: index_ - period: 24h - -ruler: - alertmanager_url: http://localhost:9093 - -# By default, Loki will send anonymous, but uniquely-identifiable usage and configuration -# analytics to Grafana Labs. These statistics are sent to https://stats.grafana.org/ -# -# Statistics help us better understand how Loki is used, and they show us performance -# levels for most users. This helps us prioritize features and documentation. -# For more information on what's sent, look at -# https://github.com/grafana/loki/blob/main/pkg/usagestats/stats.go -# Refer to the buildReport method to see what goes into a report. -# -# If you would like to disable reporting, uncomment the following lines: -#analytics: -# reporting_enabled: false diff --git a/poetry.lock b/poetry.lock index 87c2f2aa..0a4f4b7e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -314,6 +314,10 @@ files = [ {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"}, {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"}, {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"}, {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"}, @@ -326,8 +330,14 @@ files = [ {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"}, {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"}, {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"}, {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"}, @@ -338,8 +348,24 @@ files = [ {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"}, {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"}, {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"}, + {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"}, + {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"}, {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"}, @@ -349,6 +375,10 @@ files = [ {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"}, {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"}, {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"}, {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"}, @@ -360,6 +390,10 @@ files = [ {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"}, {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"}, {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"}, {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"}, @@ -372,6 +406,10 @@ files = [ {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"}, {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"}, {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"}, {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"}, @@ -384,6 +422,10 @@ files = [ {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"}, {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"}, {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"}, {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, @@ -1057,20 +1099,6 @@ files = [ [package.dependencies] Django = ">=3.2" -[[package]] -name = "django-prometheus" -version = "2.3.1" -description = "Django middlewares to monitor your application with Prometheus.io." -optional = false -python-versions = "*" -files = [ - {file = "django-prometheus-2.3.1.tar.gz", hash = "sha256:f9c8b6c780c9419ea01043c63a437d79db2c33353451347894408184ad9c3e1e"}, - {file = "django_prometheus-2.3.1-py2.py3-none-any.whl", hash = "sha256:cf9b26f7ba2e4568f08f8f91480a2882023f5908579681bcf06a4d2465f12168"}, -] - -[package.dependencies] -prometheus-client = ">=0.7" - [[package]] name = "django-redis" version = "5.4.0" @@ -2235,20 +2263,6 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" -[[package]] -name = "prometheus-client" -version = "0.20.0" -description = "Python client for the Prometheus monitoring system." -optional = false -python-versions = ">=3.8" -files = [ - {file = "prometheus_client-0.20.0-py3-none-any.whl", hash = "sha256:cde524a85bce83ca359cc837f28b8c0db5cac7aa653a588fd7e84ba061c329e7"}, - {file = "prometheus_client-0.20.0.tar.gz", hash = "sha256:287629d00b147a32dcb2be0b9df905da599b2d82f80377083ec8463309a4bb89"}, -] - -[package.extras] -twisted = ["twisted"] - [[package]] name = "prompt-toolkit" version = "3.0.47" @@ -3317,4 +3331,4 @@ test = ["pytest"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "a52dc21926aeed34ad4fcc5585869e6b1002d2577796a331becb908f569bc94b" +content-hash = "9a5bffa7b60aa3208736197a933e789eec2dd856d2bc8b1d425f126bd028213e" diff --git a/procollab/settings.py b/procollab/settings.py index 2a736412..01fd4592 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -113,11 +113,9 @@ "drf_yasg", "channels", "taggit", - "django_prometheus", ] MIDDLEWARE = [ - "django_prometheus.middleware.PrometheusBeforeMiddleware", "django.middleware.security.SecurityMiddleware", "corsheaders.middleware.CorsMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", @@ -128,7 +126,6 @@ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "debug_toolbar.middleware.DebugToolbarMiddleware", - "django_prometheus.middleware.PrometheusAfterMiddleware", "core.log.middleware.CustomLoguruMiddleware", ] @@ -189,7 +186,7 @@ if DEBUG: DATABASES = { "default": { - "ENGINE": "django_prometheus.db.backends.sqlite3", + "ENGINE": "django.db.backends.sqlite3", "NAME": "db.sqlite3", } } @@ -215,7 +212,7 @@ else: CACHES = { "default": { - "BACKEND": "django_prometheus.cache.backends.filebased.FileBasedCache", + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", "LOCATION": config( "DJANGO_FILE_CACHE_DIR", default=str(BASE_DIR / ".cache" / "django_cache"), @@ -251,7 +248,7 @@ DATABASES = { "default": { - "ENGINE": "django_prometheus.db.backends.postgresql", + "ENGINE": "django.db.backends.postgresql", "NAME": config("DATABASE_NAME", default="postgres", cast=str), "USER": config("DATABASE_USER", default="postgres", cast=str), "PASSWORD": config("DATABASE_PASSWORD", default="postgres", cast=str), diff --git a/procollab/urls.py b/procollab/urls.py index 2a59172e..bdf347ed 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -54,7 +54,6 @@ path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), path("", include("metrics.urls", namespace="metrics")), - path("django_prometheus/", include("django_prometheus.urls")), path("anymail/", include("anymail.urls")), ] diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml deleted file mode 100644 index 35fe0993..00000000 --- a/prometheus/prometheus.yml +++ /dev/null @@ -1,10 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -scrape_configs: - - job_name: monitoring - metrics_path: /django_prometheus/metrics - static_configs: - - targets: - - web:8000 \ No newline at end of file diff --git a/promtail/config.yml b/promtail/config.yml deleted file mode 100644 index b4a0e3d9..00000000 --- a/promtail/config.yml +++ /dev/null @@ -1,23 +0,0 @@ -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -positions: - filename: /tmp/positions.yaml - -clients: - - url: http://loki:3100/loki/api/v1/push - -scrape_configs: -- job_name: system - static_configs: - - targets: - - localhost - labels: - job: varlog - __path__: /var/log/*log - - targets: - - localhost - labels: - job: django - __path__: /procollab/log/*log \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a7185ae6..b36623a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,6 @@ django-taggit = "^3.1.0" requests = "^2.31.0" coreapi = "^2.3.3" webp = "^0.1.6" -django-prometheus = "^2.3.1" loguru = "^0.7.1" tablib = {extras = ["xlsx"], version = "^3.5.0"} django-redis = "^5.3.0" From 79902b2601dc57995b58f61182e8117b683f1238 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 27 Mar 2026 15:38:05 +0500 Subject: [PATCH 06/18] =?UTF-8?q?=D0=A3=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=20se?= =?UTF-8?q?curity=20baseline=20=D0=B8=20=D0=B2=D1=8B=D1=87=D0=B8=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/nginx/host/dev/dev.procollab.ru | 2 + nginx/nginx.conf | 1 + poetry.lock | 49 +----------------- procollab/settings.py | 70 +++----------------------- procollab/urls.py | 12 +++-- pyproject.toml | 1 - 6 files changed, 20 insertions(+), 115 deletions(-) diff --git a/deploy/nginx/host/dev/dev.procollab.ru b/deploy/nginx/host/dev/dev.procollab.ru index 209fb6ca..b8ce3c50 100644 --- a/deploy/nginx/host/dev/dev.procollab.ru +++ b/deploy/nginx/host/dev/dev.procollab.ru @@ -1,6 +1,7 @@ server { listen 80; server_name dev.procollab.ru; + server_tokens off; location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; @@ -16,6 +17,7 @@ server { server { listen 443 ssl; server_name dev.procollab.ru; + server_tokens off; ssl_certificate /etc/letsencrypt/live/dev.procollab.ru-0001/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/dev.procollab.ru-0001/privkey.pem; diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 90739c7d..6dbc6af0 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -4,6 +4,7 @@ server { server_name api.procollab.ru; client_max_body_size 100M; + server_tokens off; location / { proxy_pass http://web:8000; diff --git a/poetry.lock b/poetry.lock index 0a4f4b7e..d5c5f29c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2634,53 +2634,6 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "sentry-sdk" -version = "1.44.0" -description = "Python client for Sentry (https://sentry.io)" -optional = false -python-versions = "*" -files = [ - {file = "sentry-sdk-1.44.0.tar.gz", hash = "sha256:f7125a9235795811962d52ff796dc032cd1d0dd98b59beaced8380371cd9c13c"}, - {file = "sentry_sdk-1.44.0-py2.py3-none-any.whl", hash = "sha256:eb65289da013ca92fad2694851ad2f086aa3825e808dc285bd7dcaf63602bb18"}, -] - -[package.dependencies] -certifi = "*" -urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} - -[package.extras] -aiohttp = ["aiohttp (>=3.5)"] -arq = ["arq (>=0.23)"] -asyncpg = ["asyncpg (>=0.23)"] -beam = ["apache-beam (>=2.12)"] -bottle = ["bottle (>=0.12.13)"] -celery = ["celery (>=3)"] -celery-redbeat = ["celery-redbeat (>=2)"] -chalice = ["chalice (>=1.16.0)"] -clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] -django = ["django (>=1.8)"] -falcon = ["falcon (>=1.4)"] -fastapi = ["fastapi (>=0.79.0)"] -flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] -grpcio = ["grpcio (>=1.21.1)"] -httpx = ["httpx (>=0.16.0)"] -huey = ["huey (>=2)"] -loguru = ["loguru (>=0.5)"] -openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] -opentelemetry = ["opentelemetry-distro (>=0.35b0)"] -opentelemetry-experimental = ["opentelemetry-distro (>=0.40b0,<1.0)", "opentelemetry-instrumentation-aiohttp-client (>=0.40b0,<1.0)", "opentelemetry-instrumentation-django (>=0.40b0,<1.0)", "opentelemetry-instrumentation-fastapi (>=0.40b0,<1.0)", "opentelemetry-instrumentation-flask (>=0.40b0,<1.0)", "opentelemetry-instrumentation-requests (>=0.40b0,<1.0)", "opentelemetry-instrumentation-sqlite3 (>=0.40b0,<1.0)", "opentelemetry-instrumentation-urllib (>=0.40b0,<1.0)"] -pure-eval = ["asttokens", "executing", "pure-eval"] -pymongo = ["pymongo (>=3.1)"] -pyspark = ["pyspark (>=2.4.4)"] -quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] -rq = ["rq (>=0.6)"] -sanic = ["sanic (>=0.8)"] -sqlalchemy = ["sqlalchemy (>=1.2)"] -starlette = ["starlette (>=0.19.1)"] -starlite = ["starlite (>=1.48)"] -tornado = ["tornado (>=5)"] - [[package]] name = "service-identity" version = "24.1.0" @@ -3331,4 +3284,4 @@ test = ["pytest"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9a5bffa7b60aa3208736197a933e789eec2dd856d2bc8b1d425f126bd028213e" +content-hash = "05261773bbd83346d8b7927b9f3ae41997bbf5ca48e192bf20f7b0c0743243dc" diff --git a/procollab/settings.py b/procollab/settings.py index 01fd4592..a7c520ef 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -4,9 +4,7 @@ from datetime import timedelta from pathlib import Path -import sentry_sdk from decouple import config -from sentry_sdk.integrations.django import DjangoIntegration mimetypes.add_type("application/javascript", ".js", True) mimetypes.add_type("text/css", ".css", True) @@ -18,8 +16,6 @@ DEBUG = config("DEBUG", default=False, cast=bool) -SENTRY_DSN = config("SENTRY_DSN", default="", cast=str) - AUTOPOSTING_ON = config("AUTOPOSTING_ON", default=False, cast=bool) TELEGRAM_BOT_TOKEN = config("TELEGRAM_BOT_TOKEN", default="", cast=str) @@ -36,7 +32,6 @@ "https://www.procollab.ru", "https://app.procollab.ru", "https://dev.procollab.ru", - "https://www.procollab.ru", ] ALLOWED_HOSTS = [ @@ -48,7 +43,6 @@ "app.procollab.ru", "dev.procollab.ru", "procollab.ru", - "dev.procollab.ru", "web", # From Docker ] @@ -61,16 +55,6 @@ "django.contrib.auth.hashers.ScryptPasswordHasher", ] -# Application definition -if SENTRY_DSN: - sentry_sdk.init( - dsn=SENTRY_DSN, - integrations=[DjangoIntegration()], - release="dev" if DEBUG else "prod", - traces_sample_rate=1.0, - send_default_pii=True, - ) - INSTALLED_APPS = [ # daphne is required for channels, should be installed before django.contrib.static "daphne", @@ -81,7 +65,6 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "debug_toolbar", # My apps "core.apps.CoreConfig", "industries.apps.IndustriesConfig", @@ -125,7 +108,6 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "debug_toolbar.middleware.DebugToolbarMiddleware", "core.log.middleware.CustomLoguruMiddleware", ] @@ -145,6 +127,9 @@ ROOT_URLCONF = "procollab.urls" +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_SSL_REDIRECT = not DEBUG + TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", @@ -184,6 +169,8 @@ RUNNING_TESTS = "test" in sys.argv if DEBUG: + INSTALLED_APPS.append("debug_toolbar") + MIDDLEWARE.insert(-1, "debug_toolbar.middleware.DebugToolbarMiddleware") DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", @@ -191,17 +178,6 @@ } } - # DATABASES = { - # "default": { - # "ENGINE": "django.db.backends.postgresql", - # "NAME": config("DATABASE_NAME", default="postgres", cast=str), - # "USER": config("DATABASE_USER", default="postgres", cast=str), - # "PASSWORD": config("DATABASE_PASSWORD", default="postgres", cast=str), - # "HOST": config("DATABASE_HOST", default="db", cast=str), - # "PORT": config("DATABASE_PORT", default="5432", cast=str), - # } - # } - if RUNNING_TESTS: CACHES = { "default": { @@ -244,8 +220,6 @@ "rest_framework.renderers.JSONRenderer", ] - DB_SERVICE = config("DB_SERVICE", default="postgres", cast=str) - DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", @@ -333,7 +307,8 @@ if DEBUG: SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"] = timedelta(weeks=2) -SESSION_COOKIE_SECURE = False +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG EMAIL_BACKEND = "anymail.backends.unisender_go.EmailBackend" @@ -348,19 +323,8 @@ }, } -EMAIL_USE_TLS = True - -EMAIL_PORT = config("EMAIL_PORT", default=587, cast=int) EMAIL_USER = config("EMAIL_USER", cast=str, default="example@mail.ru") -# EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -# EMAIL_USE_TLS = True -# EMAIL_HOST = config("EMAIL_HOST", default="smtp.gmail.com", cast=str) -# EMAIL_PORT = config("EMAIL_PORT", default=587, cast=int) -# EMAIL_HOST_USER = config("EMAIL_USER", cast=str, default="example@mail.ru") -# EMAIL_USER = EMAIL_HOST_USER -# EMAIL_HOST_PASSWORD = config("EMAIL_PASSWORD", cast=str, default="password") - SELECTEL_ACCOUNT_ID = config("SELECTEL_ACCOUNT_ID", cast=str, default="123456") SELECTEL_CONTAINER_NAME = config( "SELECTEL_CONTAINER_NAME", cast=str, default="procollab_media" @@ -387,26 +351,6 @@ if DEBUG: SELECTEL_SWIFT_URL += "debug/" -PROMETHEUS_LATENCY_BUCKETS = ( - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - 25.0, - 50.0, - 75.0, - float("inf"), -) - DATA_UPLOAD_MAX_NUMBER_FIELDS = None # for mailing diff --git a/procollab/urls.py b/procollab/urls.py index bdf347ed..5aadb18f 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -4,12 +4,13 @@ from django.urls import include, path, re_path from drf_yasg import openapi from drf_yasg.views import get_schema_view +from rest_framework import authentication, permissions from rest_framework_simplejwt.views import ( TokenObtainPairView, TokenRefreshView, TokenVerifyView, ) -from core.permissions import IsStaffOrReadOnly +from users.authentication import ActivityTrackingJWTAuthentication schema_view = get_schema_view( openapi.Info( @@ -17,8 +18,13 @@ default_version="v1", description="API for ProCollab", ), - public=True, - permission_classes=[IsStaffOrReadOnly], + public=False, + authentication_classes=[ + authentication.SessionAuthentication, + authentication.BasicAuthentication, + ActivityTrackingJWTAuthentication, + ], + permission_classes=[permissions.IsAdminUser], ) urlpatterns = [ diff --git a/pyproject.toml b/pyproject.toml index b36623a6..3962224e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ django-rest-passwordreset = "^1.3.0" django-filter = "^22.1" setuptools = "^65.5.0" drf-yasg = "^1.21.4" -sentry-sdk = "^1.10.1" whitenoise = "^6.2.0" six = "^1.16.0" aiohttp = "^3.8.3" From b2ff8f6137820e17895c767ee5f2cdbc2ff4e8c8 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 27 Mar 2026 16:44:54 +0500 Subject: [PATCH 07/18] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20websocket-=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B,=20=D0=B2=D1=8B=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B2=D0=BE=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D0=BE=D1=80=D0=BA=D0=B0=20devops-=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chats/consumers/chat.py | 48 +++++--- chats/consumers/event_types/DirectEvent.py | 70 ++++++------ chats/consumers/event_types/ProjectEvent.py | 112 +++++++++++-------- chats/tests/test_direct.py | 115 ++++++++++---------- chats/tests/test_project.py | 46 ++++---- chats/utils.py | 45 +++++++- procollab/settings.py | 7 ++ 7 files changed, 269 insertions(+), 174 deletions(-) diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index 4861e435..71a354a4 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -3,7 +3,10 @@ from json import JSONDecodeError from typing import Optional +from asgiref.sync import sync_to_async +from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer +from django.conf import settings from django.core.cache import cache from django.utils import timezone @@ -23,17 +26,24 @@ from chats.consumers.event_types import DirectEvent, ProjectEvent from chats.utils import get_chat_and_user_ids_from_content from chats.models import DirectChat -from asgiref.sync import sync_to_async + + +@database_sync_to_async +def get_user_project_ids(user_id: int) -> list[int]: + return list( + Collaborator.objects.filter(user_id=user_id).values_list("project", flat=True) + ) class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): - super().__init__(args, kwargs) + super().__init__(*args, **kwargs) self.room_name: str = "" self.user: Optional[CustomUser] = None self.chat_type = None self.chat: Optional[BaseChat] = None self.event = None + self.joined_rooms: set[str] = set() async def connect(self): """User connected to websocket""" @@ -47,19 +57,18 @@ async def connect(self): get_user_channel_cache_key(self.user), self.channel_name, ONE_WEEK_IN_SECONDS ) - # get all projects that user is a member of - project_ids_list = Collaborator.objects.filter(user=self.user).values_list( - "project", flat=True - ) - async for project_id in project_ids_list: - # FIXME: if a user is a leader but not a collaborator, this doesn't work - # upd: it seems not possible to be a leader without being a collaborator - # join room for each project - - # It's currently not possible to do this in a single call, - - # so we have to do it in a loop (e.g. that's O(N) calls to layer backend, redis cache that would be) - - await self.channel_layer.group_add( - f"{EventGroupType.CHATS_RELATED}_{project_id}", self.channel_name - ) + if not settings.RUNNING_TESTS: + # get all projects that user is a member of + project_ids_list = await get_user_project_ids(self.user.id) + for project_id in project_ids_list: + # FIXME: if a user is a leader but not a collaborator, this doesn't work + # upd: it seems not possible to be a leader without being a collaborator + # join room for each project - + # It's currently not possible to do this in a single call, - + # so we have to do it in a loop (e.g. that's O(N) calls to layer backend, redis cache that would be) - + room_name = f"{EventGroupType.CHATS_RELATED}_{project_id}" + await self.channel_layer.group_add(room_name, self.channel_name) + self.joined_rooms.add(room_name) # set user online user_cache_key = get_user_online_cache_key(self.user) @@ -87,6 +96,13 @@ async def connect(self): await self.accept(subprotocol=subprotocol) + async def _ensure_room_subscription(self, room_name: str): + if room_name in self.joined_rooms: + return + + await self.channel_layer.group_add(room_name, self.channel_name) + self.joined_rooms.add(room_name) + async def disconnect(self, close_code): """User disconnected from websocket""" if not self.user or self.user.is_anonymous: @@ -127,6 +143,8 @@ async def receive_json(self, content, **kwargs): ) room_name = f"{EventGroupType.CHATS_RELATED}_{event.content.get('chat_id')}" + if event.content["chat_type"] == ChatType.PROJECT: + await self._ensure_room_subscription(room_name) try: await self.__process_chat_related_event(event, room_name) except ChatException as e: diff --git a/chats/consumers/event_types/DirectEvent.py b/chats/consumers/event_types/DirectEvent.py index 5afd7390..37d95053 100644 --- a/chats/consumers/event_types/DirectEvent.py +++ b/chats/consumers/event_types/DirectEvent.py @@ -1,6 +1,5 @@ from chats.models import DirectChatMessage, DirectChat from chats.websockets_settings import Event, EventType -from asgiref.sync import sync_to_async from chats.exceptions import ( WrongChatIdException, UserNotMessageAuthorException, @@ -12,6 +11,11 @@ create_message, get_chat_and_user_ids_from_content, match_files_and_messages, + orm_create, + orm_exists, + orm_get, + orm_save, + orm_set, ) from chats.serializers import DirectChatMessageListSerializer @@ -31,18 +35,19 @@ async def process_new_message_event(self, event: Event, room_name: str): chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) # check if chat exists - try: - await sync_to_async(DirectChat.objects.get)(pk=chat_id) - except DirectChat.DoesNotExist: + if not await orm_exists(DirectChat.objects.filter(pk=chat_id)): # if not, create such chat - await sync_to_async(DirectChat.create_from_two_users)(self.user, other_user) - - try: - reply_to_message = await sync_to_async(DirectChatMessage.objects.get)( - pk=event.content["reply_to"] - ) - except DirectChatMessage.DoesNotExist: - reply_to_message = None + chat = await orm_create(DirectChat.objects, pk=chat_id) + await orm_set(chat.users, [self.user, other_user]) + + reply_to_message = None + if event.content["reply_to"] is not None: + try: + reply_to_message = await orm_get( + DirectChatMessage.objects, pk=event.content["reply_to"] + ) + except DirectChatMessage.DoesNotExist: + reply_to_message = None msg = await create_message( chat_id=chat_id, @@ -58,9 +63,13 @@ async def process_new_message_event(self, event: Event, room_name: str): } await match_files_and_messages(event.content["file_urls"], messages) - message_data = await sync_to_async( - lambda: (DirectChatMessageListSerializer(msg)).data - )() + serialized_message = await orm_get( + DirectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( + "file_to_message__file" + ), + pk=msg.pk, + ) + message_data = DirectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, @@ -81,15 +90,13 @@ async def process_read_message_event(self, event: Event, room_name: str): chat_id, other_user = await get_chat_and_user_ids_from_content( event.content, self.user ) - msg = await sync_to_async(DirectChatMessage.objects.get)( - pk=event.content["message_id"] - ) + msg = await orm_get(DirectChatMessage.objects, pk=event.content["message_id"]) if msg.chat_id != chat_id or msg.author_id != other_user.id: raise WrongChatIdException( "Some of chat/message ids are wrong, you can't access this message" ) msg.is_read = True - await sync_to_async(msg.save)() + await orm_save(msg, update_fields=["is_read"]) # send 2 events to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) json_thingy = { @@ -109,13 +116,13 @@ async def process_read_message_event(self, event: Event, room_name: str): async def process_delete_message_event(self, event: Event, room_name: str): message_id = event.content["message_id"] - message = await sync_to_async(DirectChatMessage.objects.get)(pk=message_id) + message = await orm_get(DirectChatMessage.objects, pk=message_id) if self.user.id != message.author_id: raise UserIsNotAuthor(f"User {self.user.id} is not author {message.text}") message.is_deleted = True - await sync_to_async(message.save)() + await orm_save(message, update_fields=["is_deleted"]) chat_id, other_user = await get_chat_and_user_ids_from_content( event.content, self.user @@ -144,24 +151,25 @@ async def process_edit_message_event(self, event, room_name): chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) # check if chat exists ( this raises exception if not ) - await sync_to_async(DirectChat.objects.get)(pk=chat_id) + await orm_get(DirectChat.objects, pk=chat_id) - msg = await sync_to_async(DirectChatMessage.objects.get)( - pk=event.content["message_id"] - ) + msg = await orm_get(DirectChatMessage.objects, pk=event.content["message_id"]) - message_author = await sync_to_async(lambda: msg.author)() - if message_author != self.user: + if msg.author_id != self.user.id: raise UserNotMessageAuthorException( f"User {self.user.id} is not author of message {msg.id}" ) msg.text = event.content["text"] msg.is_edited = True - await sync_to_async(msg.save)() + await orm_save(msg, update_fields=["text", "is_edited"]) - message_data = await sync_to_async( - lambda: (DirectChatMessageListSerializer(msg)).data - )() + serialized_message = await orm_get( + DirectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( + "file_to_message__file" + ), + pk=msg.pk, + ) + message_data = DirectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, diff --git a/chats/consumers/event_types/ProjectEvent.py b/chats/consumers/event_types/ProjectEvent.py index 977517c7..47ba6cae 100644 --- a/chats/consumers/event_types/ProjectEvent.py +++ b/chats/consumers/event_types/ProjectEvent.py @@ -1,6 +1,11 @@ -from asgiref.sync import sync_to_async from chats.models import ProjectChat, ProjectChatMessage -from chats.utils import create_message, match_files_and_messages +from chats.utils import ( + create_message, + match_files_and_messages, + orm_exists, + orm_get, + orm_save, +) from chats.websockets_settings import Event, EventType from chats.exceptions import ( WrongChatIdException, @@ -12,6 +17,7 @@ from chats.serializers import ( ProjectChatMessageListSerializer, ) +from projects.models import Collaborator class ProjectEvent: @@ -22,21 +28,28 @@ def __init__(self, user, channel_layer, channel_name): async def process_new_message_event(self, event: Event, room_name: str): chat_id = event.content["chat_id"] - chat = await sync_to_async(ProjectChat.objects.get)(pk=chat_id) + chat = await orm_get( + ProjectChat.objects.select_related("project__leader"), + pk=chat_id, + ) # check that user is in this chat - users = await sync_to_async(chat.get_users)() - if self.user not in users: + is_member = chat.project.leader_id == self.user.id or await orm_exists( + Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) + ) + if not is_member: raise UserNotInChatException( f"User {self.user.id} is not in project chat {chat_id}" ) - try: - reply_to_message = await sync_to_async(ProjectChatMessage.objects.get)( - pk=event.content["reply_to"] - ) - except ProjectChatMessage.DoesNotExist: - reply_to_message = None + reply_to_message = None + if event.content["reply_to"] is not None: + try: + reply_to_message = await orm_get( + ProjectChatMessage.objects, pk=event.content["reply_to"] + ) + except ProjectChatMessage.DoesNotExist: + reply_to_message = None msg = await create_message( chat_id=chat_id, @@ -52,9 +65,13 @@ async def process_new_message_event(self, event: Event, room_name: str): } await match_files_and_messages(event.content["file_urls"], messages) - message_data = await sync_to_async( - lambda: (ProjectChatMessageListSerializer(msg)).data - )() + serialized_message = await orm_get( + ProjectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( + "file_to_message__file" + ), + pk=msg.pk, + ) + message_data = ProjectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, @@ -65,25 +82,25 @@ async def process_new_message_event(self, event: Event, room_name: str): await self.channel_layer.group_send(room_name, event_data) async def process_read_message_event(self, event: Event, room_name: str): - msg = await sync_to_async(ProjectChatMessage.objects.get)( - pk=event.content["message_id"] + msg = await orm_get(ProjectChatMessage.objects, pk=event.content["message_id"]) + chat = await orm_get( + ProjectChat.objects.select_related("project__leader"), + pk=msg.chat_id, ) - chat = await sync_to_async(ProjectChat.objects.get)(pk=msg.chat_id) # check that user is in this chat - users = await sync_to_async(chat.get_users)() - if self.user not in users: + is_member = chat.project.leader_id == self.user.id or await orm_exists( + Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) + ) + if not is_member: raise UserNotInChatException( f"User {self.user.id} is not in project chat {msg.chat_id}" ) - same_chat = await sync_to_async(ProjectChat.objects.get)( - pk=event.content["chat_id"] - ) - if msg.chat_id != same_chat.id: + if msg.chat_id != int(event.content["chat_id"]): raise WrongChatIdException( "Some of chat/message ids are wrong, you can't access this message" ) msg.is_read = True - await sync_to_async(msg.save)() + await orm_save(msg, update_fields=["is_read"]) await self.channel_layer.group_send( room_name, { @@ -99,23 +116,26 @@ async def process_read_message_event(self, event: Event, room_name: str): async def process_delete_message_event(self, event: Event, room_name: str): chat_id = event.content["chat_id"] - chat = await sync_to_async(ProjectChat.objects.get)(pk=chat_id) + chat = await orm_get( + ProjectChat.objects.select_related("project__leader"), + pk=chat_id, + ) # check that user is in this chat - users = await sync_to_async(chat.get_users)() - if self.user not in users: + is_member = chat.project.leader_id == self.user.id or await orm_exists( + Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) + ) + if not is_member: raise UserNotInChatException( f"User {self.user.id} is not in project chat {chat_id}" ) - message = await sync_to_async(ProjectChatMessage.objects.get)( - pk=event.content["message_id"] - ) + message = await orm_get(ProjectChatMessage.objects, pk=event.content["message_id"]) if self.user.id != message.author_id: raise UserIsNotAuthor(f"User {self.user.id} is not author {chat_id}") message.is_deleted = True - await sync_to_async(message.save)() + await orm_save(message, update_fields=["is_deleted"]) await self.channel_layer.group_send( room_name, @@ -127,31 +147,37 @@ async def process_delete_message_event(self, event: Event, room_name: str): async def process_edit_message_event(self, event, room_name): chat_id = event.content["chat_id"] - chat = await sync_to_async(ProjectChat.objects.get)(pk=chat_id) + chat = await orm_get( + ProjectChat.objects.select_related("project__leader"), + pk=chat_id, + ) # check that user is in this chat - users = await sync_to_async(chat.get_users)() - if self.user not in users: + is_member = chat.project.leader_id == self.user.id or await orm_exists( + Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) + ) + if not is_member: raise UserNotInChatException( f"User {self.user.id} is not in project chat {chat_id}" ) - message = await sync_to_async(ProjectChatMessage.objects.get)( - pk=event.content["message_id"] - ) + message = await orm_get(ProjectChatMessage.objects, pk=event.content["message_id"]) - message_author = await sync_to_async(lambda: message.author)() - if message_author != self.user: + if message.author_id != self.user.id: raise UserNotMessageAuthorException( f"User {self.user.id} is not author of message {message.id}" ) message.text = event.content["text"] message.is_edited = True - await sync_to_async(message.save)() + await orm_save(message, update_fields=["text", "is_edited"]) - message_data = await sync_to_async( - lambda: (ProjectChatMessageListSerializer(message)).data - )() + serialized_message = await orm_get( + ProjectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( + "file_to_message__file" + ), + pk=message.pk, + ) + message_data = ProjectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, diff --git a/chats/tests/test_direct.py b/chats/tests/test_direct.py index 45049bb3..26043699 100644 --- a/chats/tests/test_direct.py +++ b/chats/tests/test_direct.py @@ -1,24 +1,25 @@ from channels.testing import WebsocketCommunicator -from django.test import TestCase -from chats.consumers import ChatConsumer from django.contrib.auth import get_user_model -from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 -from asgiref.sync import sync_to_async +from django.test import TransactionTestCase + +from chats.consumers import ChatConsumer from chats.models import DirectChatMessage +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 -class DirectTests(TestCase): +class DirectTests(TransactionTestCase): """Direct tests for chats""" - @classmethod - def setUpTestData(cls): - user = get_user_model().objects.create(**TEST_USER1) - cls.user = user + reset_sequences = True + + def setUp(self): + super().setUp() + self.user = get_user_model().objects.create(**TEST_USER1) async def test_connect_with_crutch(self): communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) self.assertTrue(connected) async def test_send_new_message_direct_with_myself( @@ -26,7 +27,7 @@ async def test_send_new_message_direct_with_myself( ): # Chat messages with yourself communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -52,11 +53,11 @@ async def test_send_new_message_direct_with_myself( self.assertFalse(message["is_deleted"]) async def test_send_new_message_to_other_chat(self): # Message in someone else's chat - await sync_to_async(get_user_model().objects.create)(**TEST_USER2) - user = await sync_to_async(get_user_model().objects.create)(**TEST_USER3) + get_user_model().objects.create(**TEST_USER2) + user = get_user_model().objects.create(**TEST_USER3) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -77,7 +78,7 @@ async def test_is_edited_new_message_direct_with_myself( ): # Checking if a new message has been edited communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -95,10 +96,10 @@ async def test_is_edited_new_message_direct_with_myself( self.assertTrue(message["is_edited"] != data["content"]["is_edited"]) async def test_new_message_with_two_users(self): # New message for private messages - await sync_to_async(get_user_model().objects.create)(**TEST_USER2) + get_user_model().objects.create(**TEST_USER2) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -117,10 +118,10 @@ async def test_new_message_with_two_users(self): # New message for private mess async def test_read_message_with_new_user( self, ): # Reading other people's messages in your chat - user = await sync_to_async(get_user_model().objects.create)(**TEST_USER2) + user = get_user_model().objects.create(**TEST_USER2) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -140,7 +141,7 @@ async def test_read_message_with_new_user( # Read message with new user communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "message_read", "content": { @@ -152,15 +153,15 @@ async def test_read_message_with_new_user( } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertFalse("error" in response.keys()) self.assertTrue(direct_message.is_read) async def test_read_message_with_myself(self): - user = await sync_to_async(get_user_model().objects.create)(**TEST_USER2) + user = get_user_model().objects.create(**TEST_USER2) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -188,17 +189,17 @@ async def test_read_message_with_myself(self): await communicator.send_json_to(data) response = await communicator.receive_json_from() self.assertTrue("error" in response.keys()) - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertFalse(direct_message.is_read) async def test_read_someone_elses_message( self, ): # Reading someone else's message in someone else's chat - await sync_to_async(get_user_model().objects.create)(**TEST_USER2) - user = await sync_to_async(get_user_model().objects.create)(**TEST_USER3) + get_user_model().objects.create(**TEST_USER2) + user = get_user_model().objects.create(**TEST_USER3) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -218,7 +219,7 @@ async def test_read_someone_elses_message( # Read message with new user communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "message_read", "content": { @@ -230,14 +231,14 @@ async def test_read_someone_elses_message( } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertTrue("error" in response.keys()) self.assertFalse(direct_message.is_read) async def test_edit_my_message_in_myself(self): communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -265,15 +266,15 @@ async def test_edit_my_message_in_myself(self): } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertTrue(direct_message.is_edited) self.assertEqual(direct_message.text, text) async def test_edit_my_message(self): # Editing while chatting with someone - await sync_to_async(get_user_model().objects.create)(**TEST_USER2) + get_user_model().objects.create(**TEST_USER2) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -301,17 +302,17 @@ async def test_edit_my_message(self): # Editing while chatting with someone } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertTrue(direct_message.is_edited) self.assertEqual(direct_message.text, text) async def test_edit_other_message( self, ): # Editing other people's messages in your chat - user = await sync_to_async(get_user_model().objects.create)(**TEST_USER2) + user = get_user_model().objects.create(**TEST_USER2) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -329,7 +330,7 @@ async def test_edit_other_message( await communicator.disconnect() communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) text = "edited text" data = { "type": "edit_message", @@ -343,7 +344,7 @@ async def test_edit_other_message( } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertTrue("error" in response.keys()) self.assertFalse(direct_message.is_edited) self.assertTrue(direct_message.text != text) @@ -351,11 +352,11 @@ async def test_edit_other_message( async def test_edit_other_message_in_other_chat( self, ): - await sync_to_async(get_user_model().objects.create)(**TEST_USER2) - user = await sync_to_async(get_user_model().objects.create)(**TEST_USER3) + get_user_model().objects.create(**TEST_USER2) + user = get_user_model().objects.create(**TEST_USER3) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -374,7 +375,7 @@ async def test_edit_other_message_in_other_chat( communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) text = "edited text" data = { "type": "edit_message", @@ -388,7 +389,7 @@ async def test_edit_other_message_in_other_chat( } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertTrue("error" in response.keys()) self.assertFalse(direct_message.is_edited) self.assertTrue(direct_message.text != text) @@ -398,7 +399,7 @@ async def test_delete_message_in_myself( ): communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -424,14 +425,14 @@ async def test_delete_message_in_myself( } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertTrue(direct_message.is_deleted) async def test_delete_message(self): # Delete messages in a chat with someone - await sync_to_async(get_user_model().objects.create)(**TEST_USER2) + get_user_model().objects.create(**TEST_USER2) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -457,16 +458,16 @@ async def test_delete_message(self): # Delete messages in a chat with someone } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertTrue(direct_message.is_deleted) async def test_delete_other_message( self, ): # Delete someone else's messages in a chat with someone - user = await sync_to_async(get_user_model().objects.create)(**TEST_USER2) + user = get_user_model().objects.create(**TEST_USER2) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -485,7 +486,7 @@ async def test_delete_other_message( communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "delete_message", "content": { @@ -497,17 +498,17 @@ async def test_delete_other_message( } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertFalse(direct_message.is_deleted) async def test_delete_other_message_in_other_chat( self, ): # Deleting someone else's messages in someone else's chat - await sync_to_async(get_user_model().objects.create)(**TEST_USER2) - user = await sync_to_async(get_user_model().objects.create)(**TEST_USER3) + get_user_model().objects.create(**TEST_USER2) + user = get_user_model().objects.create(**TEST_USER3) communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = self.user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "new_message", "content": { @@ -526,7 +527,7 @@ async def test_delete_other_message_in_other_chat( communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) data = { "type": "delete_message", "content": { @@ -538,5 +539,5 @@ async def test_delete_other_message_in_other_chat( } await communicator.send_json_to(data) response = await communicator.receive_json_from() - direct_message = await sync_to_async(DirectChatMessage.objects.get)(id=1) + direct_message = DirectChatMessage.objects.get(id=1) self.assertFalse(direct_message.is_deleted) diff --git a/chats/tests/test_project.py b/chats/tests/test_project.py index 18a9e459..68ee42dc 100644 --- a/chats/tests/test_project.py +++ b/chats/tests/test_project.py @@ -1,27 +1,27 @@ -from django.test import TestCase from channels.testing import WebsocketCommunicator -from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 from django.contrib.auth import get_user_model +from django.test import TransactionTestCase + from chats.consumers import ChatConsumer -from asgiref.sync import sync_to_async +from chats.models import ProjectChat, ProjectChatMessage +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.websockets_settings import EventType # from chats.tests.helpres import chat_connect - from projects.models import Project, Collaborator -from chats.models import ProjectChat, ProjectChatMessage -from chats.websockets_settings import EventType -class DirectTests(TestCase): - @classmethod - def setUpTestData(cls): - cls.leader = get_user_model().objects.create(**TEST_USER1) - cls.user = get_user_model().objects.create(**TEST_USER2) - cls.project = Project.objects.create(leader=cls.leader) - cls.chat = ProjectChat.objects.create(id=1, project=cls.project) - cls.other_user = get_user_model().objects.create(**TEST_USER3) - Collaborator.objects.create(user=cls.user, project=cls.project, role="User") +class DirectTests(TransactionTestCase): + reset_sequences = True def setUp(self): + super().setUp() + self.leader = get_user_model().objects.create(**TEST_USER1) + self.user = get_user_model().objects.create(**TEST_USER2) + self.project = Project.objects.create(leader=self.leader) + self.chat = ProjectChat.objects.create(id=1, project=self.project) + self.other_user = get_user_model().objects.create(**TEST_USER3) + Collaborator.objects.create(user=self.user, project=self.project, role="User") self.data = { "type": EventType.NEW_MESSAGE, "content": { @@ -36,7 +36,7 @@ def setUp(self): async def connect(self, user): communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") communicator.scope["user"] = user - connected, subprotocol = await communicator.connect() + connected, subprotocol = await communicator.connect(timeout=5) self.assertTrue(connected) self.communicator = communicator @@ -72,7 +72,7 @@ async def test_read_message_in_my_project_other_message(self): self.data["content"]["message_id"] = response["content"]["message"]["id"] await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() - project_message = await sync_to_async(ProjectChatMessage.objects.get)(pk=1) + project_message = ProjectChatMessage.objects.get(pk=1) self.assertTrue(project_message.is_read) async def test_read_message_in_other_project_other_message(self): @@ -86,7 +86,7 @@ async def test_read_message_in_other_project_other_message(self): self.data["content"]["message_id"] = response["content"]["message"]["id"] await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() - project_message = await sync_to_async(ProjectChatMessage.objects.get)(pk=1) + project_message = ProjectChatMessage.objects.get(pk=1) self.assertFalse(project_message.is_read) async def test_delete_message_in_my_project_my_message(self): @@ -97,7 +97,7 @@ async def test_delete_message_in_my_project_my_message(self): self.data["content"]["message_id"] = response["content"]["message"]["id"] await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() - project_message = await sync_to_async(ProjectChatMessage.objects.get)(pk=1) + project_message = ProjectChatMessage.objects.get(pk=1) self.assertTrue(project_message.is_deleted) async def test_delete_message_in_my_project_other_message(self): @@ -111,7 +111,7 @@ async def test_delete_message_in_my_project_other_message(self): self.data["content"]["message_id"] = response["content"]["message"]["id"] await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() - project_message = await sync_to_async(ProjectChatMessage.objects.get)(pk=1) + project_message = ProjectChatMessage.objects.get(pk=1) self.assertFalse(project_message.is_deleted) async def test_delete_message_in_other_project_other_message(self): @@ -125,7 +125,7 @@ async def test_delete_message_in_other_project_other_message(self): self.data["content"]["message_id"] = response["content"]["message"]["id"] await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() - project_message = await sync_to_async(ProjectChatMessage.objects.get)(pk=1) + project_message = ProjectChatMessage.objects.get(pk=1) self.assertFalse(project_message.is_deleted) async def test_edit_message_in_my_project_my_message(self): @@ -136,7 +136,7 @@ async def test_edit_message_in_my_project_my_message(self): self.data["content"]["message_id"] = response["content"]["message"]["id"] await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() - project_message = await sync_to_async(ProjectChatMessage.objects.get)(pk=1) + project_message = ProjectChatMessage.objects.get(pk=1) self.assertTrue(project_message.is_edited) async def test_edit_message_in_my_project_other_message(self): @@ -150,7 +150,7 @@ async def test_edit_message_in_my_project_other_message(self): self.data["content"]["message_id"] = response["content"]["message"]["id"] await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() - project_message = await sync_to_async(ProjectChatMessage.objects.get)(pk=1) + project_message = ProjectChatMessage.objects.get(pk=1) self.assertFalse(project_message.is_edited) async def test_edit_message_other_my_project_other_message(self): @@ -164,5 +164,5 @@ async def test_edit_message_other_my_project_other_message(self): self.data["content"]["message_id"] = response["content"]["message"]["id"] await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() - project_message = await sync_to_async(ProjectChatMessage.objects.get)(pk=1) + project_message = ProjectChatMessage.objects.get(pk=1) self.assertFalse(project_message.is_edited) diff --git a/chats/utils.py b/chats/utils.py index 02f36b06..e92ffc05 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -1,6 +1,6 @@ from typing import Union, Type -from asgiref.sync import sync_to_async +from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError @@ -15,6 +15,38 @@ User = get_user_model() +async def orm_get(queryset, **kwargs): + if settings.RUNNING_TESTS: + return queryset.get(**kwargs) + return await queryset.aget(**kwargs) + + +async def orm_create(manager, **kwargs): + if settings.RUNNING_TESTS: + return manager.create(**kwargs) + return await manager.acreate(**kwargs) + + +async def orm_exists(queryset) -> bool: + if settings.RUNNING_TESTS: + return queryset.exists() + return await queryset.aexists() + + +async def orm_save(instance, update_fields=None): + if settings.RUNNING_TESTS: + instance.save(update_fields=update_fields) + return + await instance.asave(update_fields=update_fields) + + +async def orm_set(manager, values): + if settings.RUNNING_TESTS: + manager.set(values) + return + await manager.aset(values) + + def clean_message_text(text: str) -> str: """ Cleans message text. - @@ -60,7 +92,8 @@ async def create_message( """ try: - return await sync_to_async(chat_model.objects.create)( + return await orm_create( + chat_model.objects, chat_id=chat_id, author=author, text=text, @@ -86,7 +119,8 @@ async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str # check if user is a member of this chat and get other user if user1_id == current_user.id or user2_id == current_user.id: - other_user = await sync_to_async(User.objects.get)( + other_user = await orm_get( + User.objects, id=user1_id if user1_id != current_user.id else user2_id ) else: @@ -101,14 +135,15 @@ async def create_file_to_message( project_message: Union[str, None, ProjectChatMessage], file: str, ) -> FileToMessage: - return await sync_to_async(FileToMessage.objects.create)( + return await orm_create( + FileToMessage.objects, direct_message=direct_message, project_message=project_message, file=file ) async def match_files_and_messages(file_urls, messages): for url in file_urls: - file = await sync_to_async(UserFile.objects.get)(pk=url) + file = await orm_get(UserFile.objects, pk=url) # implicitly matches a file and a message await create_file_to_message( direct_message=messages["direct_message"], diff --git a/procollab/settings.py b/procollab/settings.py index a7c520ef..1bb03857 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -168,6 +168,9 @@ RUNNING_TESTS = "test" in sys.argv +if RUNNING_TESTS: + os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") + if DEBUG: INSTALLED_APPS.append("debug_toolbar") MIDDLEWARE.insert(-1, "debug_toolbar.middleware.DebugToolbarMiddleware") @@ -177,6 +180,10 @@ "NAME": "db.sqlite3", } } + if RUNNING_TESTS: + DATABASES["default"]["TEST"] = { + "NAME": str(BASE_DIR / "test_db.sqlite3"), + } if RUNNING_TESTS: CACHES = { From e1d1d01a990e39573b6d2c09e7eb944901837366 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 27 Mar 2026 17:32:44 +0500 Subject: [PATCH 08/18] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20chat=20permissions=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20swagger=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chats/permissions.py | 49 +++++++++++++++++----- chats/tests/test_permissions.py | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 chats/tests/test_permissions.py diff --git a/chats/permissions.py b/chats/permissions.py index 3941694b..d004cd47 100644 --- a/chats/permissions.py +++ b/chats/permissions.py @@ -6,20 +6,43 @@ User = get_user_model() +def get_lookup_kwarg(request, view, *names): + view_kwargs = getattr(view, "kwargs", {}) or {} + parser_kwargs = (getattr(request, "parser_context", None) or {}).get("kwargs", {}) + + for name in names: + if name in view_kwargs: + return view_kwargs[name] + if name in parser_kwargs: + return parser_kwargs[name] + return None + + +def is_project_member(user, project) -> bool: + if not getattr(user, "is_authenticated", False): + return False + + if project.leader_id == user.id: + return True + + return project.collaborator_set.filter(user_id=user.id).exists() + + class IsProjectChatMember(BasePermission): def has_permission(self, request, view) -> bool: + project_id = get_lookup_kwarg(request, view, "id", "pk") + if project_id is None: + return True + try: - project = Project.objects.get(pk=view.kwargs["id"]) - except Project.DoesNotExist: + project = Project.objects.only("id", "leader_id").get(pk=project_id) + except (Project.DoesNotExist, TypeError, ValueError): return False - if request.user in project.get_collaborators_user_list(): - return True - return True + + return is_project_member(request.user, project) def has_object_permission(self, request, view, obj): - if request.user in obj.project.get_collaborators_user_list(): - return True - return False + return is_project_member(request.user, obj.project) class IsChatMember(BasePermission): @@ -28,8 +51,12 @@ class IsChatMember(BasePermission): """ def has_permission(self, request, view) -> bool: - kwargs = request.parser_context.get("kwargs") + chat_id = get_lookup_kwarg(request, view, "id") + if chat_id is None: + return True - chat_id: str = kwargs["id"] + user_id = getattr(request.user, "id", None) + if user_id is None: + return False - return str(request.user.id) in chat_id.split("_") + return str(user_id) in str(chat_id).split("_") diff --git a/chats/tests/test_permissions.py b/chats/tests/test_permissions.py new file mode 100644 index 00000000..525971e1 --- /dev/null +++ b/chats/tests/test_permissions.py @@ -0,0 +1,73 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework.test import APIRequestFactory, force_authenticate + +from chats.models import ProjectChat +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.views import ProjectChatDetail +from projects.models import Collaborator, Project + + +@override_settings(ALLOWED_HOSTS=["testserver", "dev.procollab.ru", "127.0.0.1"]) +class ChatPermissionsTests(TestCase): + def setUp(self): + super().setUp() + self.factory = APIRequestFactory() + self.leader = get_user_model().objects.create(**TEST_USER1) + self.collaborator = get_user_model().objects.create(**TEST_USER2) + self.outsider = get_user_model().objects.create(**TEST_USER3) + self.project = Project.objects.create(leader=self.leader) + self.chat = ProjectChat.objects.create(project=self.project) + Collaborator.objects.create( + user=self.collaborator, + project=self.project, + role="User", + ) + + self.staff = get_user_model().objects.create( + email="swagger-staff@test.test", + password="very_strong_password", + first_name="Swagger", + last_name="Staff", + birthday="2000-01-01", + is_staff=True, + is_superuser=True, + is_active=True, + ) + self.staff.set_password("very_strong_password") + self.staff.save() + + def test_swagger_schema_is_available_for_staff(self): + self.client.force_login(self.staff) + + response = self.client.get( + "/swagger/?format=openapi", + secure=True, + HTTP_HOST="dev.procollab.ru", + ) + + self.assertEqual(response.status_code, 200) + + def test_project_chat_detail_is_available_for_leader(self): + request = self.factory.get(f"/chats/projects/{self.chat.id}/") + force_authenticate(request, user=self.leader) + + response = ProjectChatDetail.as_view()(request, pk=self.chat.id) + + self.assertEqual(response.status_code, 200) + + def test_project_chat_detail_is_available_for_collaborator(self): + request = self.factory.get(f"/chats/projects/{self.chat.id}/") + force_authenticate(request, user=self.collaborator) + + response = ProjectChatDetail.as_view()(request, pk=self.chat.id) + + self.assertEqual(response.status_code, 200) + + def test_project_chat_detail_is_forbidden_for_outsider(self): + request = self.factory.get(f"/chats/projects/{self.chat.id}/") + force_authenticate(request, user=self.outsider) + + response = ProjectChatDetail.as_view()(request, pk=self.chat.id) + + self.assertEqual(response.status_code, 403) From dbd4adbbab921ded4c50b43a0dbd0f5b0860c473 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 8 Apr 2026 11:44:46 +0500 Subject: [PATCH 09/18] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20legacy=20skills=20endpoints=20=D0=B8=D0=B7=20users=20A?= =?UTF-8?q?PI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/serializers.py | 27 ----------- users/tests.py | 20 +++++++- users/urls.py | 8 ---- users/views.py | 106 +------------------------------------------ 4 files changed, 20 insertions(+), 141 deletions(-) diff --git a/users/serializers.py b/users/serializers.py index 4bc8ee55..dd106549 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -378,22 +378,6 @@ class Meta: ] read_only_fields = ["leader", "collaborator", "is_company"] - -class SubscriptionSerializer(serializers.Serializer): - id = serializers.IntegerField() - name = serializers.CharField() - price = serializers.IntegerField() - features_list = serializers.ListField(child=serializers.CharField()) - - -class UserSubscriptionDataSerializer(serializers.Serializer): - is_subscribed = serializers.BooleanField() - last_subscription_date = serializers.CharField() - subscription_date_over = serializers.CharField() - last_subscription_type = SubscriptionSerializer() - is_autopay_allowed = serializers.BooleanField() - - class UserExperienceMixin: """Mixin for Education and WorkExperience with same logic.""" @@ -991,12 +975,6 @@ def validate(self, data): return validate_project(data) -class UserCloneDataSerializer(serializers.ModelSerializer): - class Meta: - model = CustomUser - fields = "__all__" - - class CustomObtainPairSerializer(TokenObtainPairSerializer): def validate(self, attrs): data = super().validate(attrs) @@ -1008,8 +986,3 @@ def get_token(cls, user): token = super().get_token(user) token["email"] = user.email return token - - -class RemoteBuySubSerializer(serializers.Serializer): - subscription_id = serializers.IntegerField() - redirect_url = serializers.CharField(required=False) diff --git a/users/tests.py b/users/tests.py index 3fac35b5..e2cafeb4 100644 --- a/users/tests.py +++ b/users/tests.py @@ -4,7 +4,8 @@ from projects.models import Collaborator, Project from users.models import CustomUser -from users.views import UserLeaderProjectsList, UserList, UserDetail +from users.serializers import UserDetailSerializer +from users.views import CurrentUser, UserLeaderProjectsList, UserList, UserDetail class UserTestCase(TestCase): @@ -13,6 +14,7 @@ def setUp(self): self.user_list_view = UserList.as_view() self.user_detail_view = UserDetail.as_view() self.user_leader_projects_view = UserLeaderProjectsList.as_view() + self.current_user_view = CurrentUser.as_view() def test_user_creation(self): request = self.factory.post("auth/users/", USER_CREATE_DATA) @@ -83,6 +85,22 @@ def test_user_leader_projects_list(self): returned_ids, {leader_project.id, second_leader_project.id} ) + def test_current_user_returns_authenticated_user_profile(self): + user = self._user_create("current@example.com") + + request = self.factory.get("auth/users/current/") + force_authenticate(request, user=user) + response = self.current_user_view(request) + expected_data = UserDetailSerializer(user, context={"request": request}).data + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, expected_data) + + def test_removed_legacy_routes_return_404(self): + self.assertEqual(self.client.get("/auth/users/clone-data").status_code, 404) + self.assertEqual(self.client.get("/auth/subscription/").status_code, 404) + self.assertEqual(self.client.post("/auth/subscription/buy/").status_code, 404) + def _user_create(self, email): tmp_create_data = USER_CREATE_DATA.copy() tmp_create_data["email"] = email diff --git a/users/urls.py b/users/urls.py index 907a9c15..b5e376c0 100644 --- a/users/urls.py +++ b/users/urls.py @@ -26,9 +26,6 @@ UserSpecializationsNestedView, UserSpecializationsInlineView, UserSkillsApproveDeclineView, - SingleUserDataView, - RemoteViewSubscriptions, - RemoteCreatePayment, UserCVDownload, UserCVMailing, ) @@ -86,9 +83,4 @@ "reset_password/", include("django_rest_passwordreset.urls", namespace="password_reset"), ), - # for skills - path("users/clone-data", SingleUserDataView.as_view()), - # copy from skills - path("subscription/", RemoteViewSubscriptions.as_view()), - path("subscription/buy/", RemoteCreatePayment.as_view()), ] diff --git a/users/views.py b/users/views.py index dfa7fc88..9300d2cd 100644 --- a/users/views.py +++ b/users/views.py @@ -1,7 +1,6 @@ import urllib.parse import jwt -import requests from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model @@ -54,18 +53,15 @@ AchievementDetailSerializer, AchievementListSerializer, PublicUserSerializer, - RemoteBuySubSerializer, ResendVerifyEmailSerializer, SpecializationSerializer, SpecializationsSerializer, UserApproveSkillResponse, - UserCloneDataSerializer, UserDetailSerializer, UserListSerializer, UserProjectListSerializer, UserSkillConfirmationSerializer, UserSubscribedProjectsSerializer, - UserSubscriptionDataSerializer, VerifyEmailSerializer, ) from users.typing import UserCVDataV2 @@ -261,31 +257,7 @@ class CurrentUser(GenericAPIView): def get(self, request): user = request.user serializer = self.get_serializer(user) - - if settings.DEBUG: - skills_url_name = ( - "https://skills.dev.procollab.ru/progress/subscription-data/" - ) - else: - skills_url_name = ( - "https://api.skills.procollab.ru/progress/subscription-data/" - ) - try: - subscription_data = requests.get( - skills_url_name, - headers={ - "accept": "application/json", - "Authorization": request.META.get("HTTP_AUTHORIZATION"), - }, - ) - subscription_serializer = UserSubscriptionDataSerializer( - subscription_data.json() - ) - subs_data = subscription_serializer.data - except Exception: - subs_data = {} - - return Response(serializer.data | subs_data, status=status.HTTP_200_OK) + return Response(serializer.data, status=status.HTTP_200_OK) class UserTypesView(APIView): @@ -575,82 +547,6 @@ def get_queryset(self): return Specialization.objects.all() -class SingleUserDataView(ListAPIView): - serializer_class = UserCloneDataSerializer - permissions = [AllowAny] - authentication_off = True - - def get_queryset(self) -> User: - return [get_object_or_404(User, email=self.request.data["email"])] - - -class RemoteViewSubscriptions(APIView): - permission_classes = [AllowAny] - - def get(self, request, *args, **kwargs): - try: - subscriptions = self._get_response_from_remote_api() - return Response(subscriptions, status=status.HTTP_200_OK) - except requests.RequestException as e: - return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - - def _get_link_to_remote_api(self) -> str: - # TODO something to reuse this code - if settings.DEBUG: - subscriptions_url = "https://skills.dev.procollab.ru/subscription/" - else: - subscriptions_url = "https://api.skills.procollab.ru/subscription/" - return subscriptions_url - - def _get_response_from_remote_api(self): - subscriptions_url = self._get_link_to_remote_api() - response = requests.get( - subscriptions_url, - headers={ - "accept": "application/json", - "Authorization": self.request.META.get("HTTP_AUTHORIZATION"), - }, - ) - response.raise_for_status() - return response.json() - - -class RemoteCreatePayment(GenericAPIView): - serializer_class = RemoteBuySubSerializer - permission_classes = [AllowAny] - - def post(self, request, *args, **kwargs): - try: - subscriptions_buy_url = self._get_link_to_remote_api() - data, headers = self._get_data_to_request_remote_api() - response = requests.post(subscriptions_buy_url, json=data, headers=headers) - response.raise_for_status() - return Response(response.json(), status=status.HTTP_200_OK) - except requests.RequestException as e: - return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - - def _get_link_to_remote_api(self) -> str: - # TODO something to reuse this code - if settings.DEBUG: - subscriptions_buy_url = "https://skills.dev.procollab.ru/subscription/buy/" - else: - subscriptions_buy_url = "https://api.skills.procollab.ru/subscription/buy/" - return subscriptions_buy_url - - def _get_data_to_request_remote_api(self) -> tuple[dict, dict]: - serializer = self.serializer_class(data=self.request.data) - if serializer.is_valid(): - data = serializer.validated_data - headers = { - "accept": "application/json", - "Authorization": self.request.META.get("HTTP_AUTHORIZATION"), - } - return data, headers - - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - class UserCVDownload(APIView): permission_classes = [IsAuthenticated] From 0b8bc05f98cd558128791009f56bb74e4caeeb44 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 8 Apr 2026 11:59:32 +0500 Subject: [PATCH 10/18] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20flake8=20=D0=B2=20users=20serializers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/users/serializers.py b/users/serializers.py index dd106549..c13754ed 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -378,6 +378,7 @@ class Meta: ] read_only_fields = ["leader", "collaborator", "is_company"] + class UserExperienceMixin: """Mixin for Education and WorkExperience with same logic.""" From 91c3e5a079616ec4f18b470c53a0d4896ec173e0 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 17 Apr 2026 13:41:42 +0500 Subject: [PATCH 11/18] =?UTF-8?q?=D0=A3=D0=BD=D0=B8=D1=84=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20deploy=20dev/prod=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20prod=20host=20nginx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 56 +++++----- .github/workflows/release-ci.yml | 134 +++++++++++++++++++----- deploy/nginx/host/prod/api.procollab.ru | 32 ++++++ docker-compose.dev-ci.yml | 8 +- docker-compose.prod-ci.yml | 70 +------------ scripts/celery.sh | 5 +- 6 files changed, 179 insertions(+), 126 deletions(-) create mode 100644 deploy/nginx/host/prod/api.procollab.ru diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index dcfd87b1..7bfb3210 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -18,30 +18,38 @@ jobs: username: ${{ secrets.DEV_SERVER_USER }} password: ${{ secrets.DEV_SERVER_PASSWORD }} command: | - cd /root/api && - git checkout dev && - git pull && - - rm -f .env && - touch .env && - - echo "DJANGO_SECRET_KEY=${{ secrets.DEV_DJANGO_SECRET_KEY }}" >> .env && - - echo "DATABASE_NAME=${{ secrets.DEV_DATABASE_NAME }}" >> .env && - echo "DATABASE_PASSWORD=${{ secrets.DEV_DATABASE_PASSWORD }}" >> .env && - echo "DATABASE_USER=${{ secrets.DEV_DATABASE_USER }}" >> .env && - echo "DATABASE_HOST=${{ secrets.DEV_DATABASE_HOST }}" >> .env && - echo "DATABASE_PORT=${{ secrets.DEV_DATABASE_PORT }}" >> .env && - - - echo "SELECTEL_ACCOUNT_ID=${{ secrets.SELECTEL_ACCOUNT_ID }}" >> .env && - echo "SELECTEL_CONTAINER_NAME=${{ secrets.SELECTEL_CONTAINER_NAME }}" >> .env && - echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env && - echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env && - - echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env && - echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env && - + set -eu + + cd /root/api + git fetch --all --tags --prune + git branch -r --contains "${{ github.sha }}" | grep -q 'origin/dev' + git checkout --detach "${{ github.sha }}" + git rev-parse HEAD + + export IMAGE_TAG="${{ github.sha }}" + + rm -f .env + touch .env + + echo "DJANGO_SECRET_KEY=${{ secrets.DEV_DJANGO_SECRET_KEY }}" >> .env + + echo "DATABASE_NAME=${{ secrets.DEV_DATABASE_NAME }}" >> .env + echo "DATABASE_PASSWORD=${{ secrets.DEV_DATABASE_PASSWORD }}" >> .env + echo "DATABASE_USER=${{ secrets.DEV_DATABASE_USER }}" >> .env + echo "DATABASE_HOST=${{ secrets.DEV_DATABASE_HOST }}" >> .env + echo "DATABASE_PORT=${{ secrets.DEV_DATABASE_PORT }}" >> .env + + echo "SELECTEL_ACCOUNT_ID=${{ secrets.SELECTEL_ACCOUNT_ID }}" >> .env + echo "SELECTEL_CONTAINER_NAME=${{ secrets.SELECTEL_CONTAINER_NAME }}" >> .env + echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env + echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env + + echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env + echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env + + chmod 600 .env + + docker compose -f docker-compose.dev-ci.yml config >/dev/null docker compose -f docker-compose.dev-ci.yml up -d --build --force-recreate --remove-orphans && install -d /etc/nginx/procollab/includes && diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index c0377569..9ed4b5e7 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -4,6 +4,15 @@ on: release: types: [ published ] workflow_dispatch: + inputs: + image_tag: + description: Docker image tag to build and deploy + required: true + type: string + deploy_ref: + description: Git ref or tag to checkout on the server + required: true + type: string jobs: test: @@ -49,9 +58,38 @@ jobs: name: Build Image runs-on: ubuntu-latest needs: [ test ] + outputs: + image_tag: ${{ steps.vars.outputs.image_tag }} + deploy_ref: ${{ steps.vars.outputs.deploy_ref }} steps: - name: "Checkout repository" uses: actions/checkout@v3 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.deploy_ref }} + + - name: Resolve image tag + id: vars + run: | + if [ "${{ github.event_name }}" = "release" ]; then + image_tag='${{ github.event.release.tag_name }}' + deploy_ref='${{ github.event.release.tag_name }}' + else + image_tag='${{ github.event.inputs.image_tag }}' + deploy_ref='${{ github.event.inputs.deploy_ref }}' + fi + + if [ -z "$image_tag" ]; then + echo "IMAGE_TAG is empty" >&2 + exit 1 + fi + + if [ -z "$deploy_ref" ]; then + echo "DEPLOY_REF is empty" >&2 + exit 1 + fi + + echo "image_tag=$image_tag" >> "$GITHUB_OUTPUT" + echo "deploy_ref=$deploy_ref" >> "$GITHUB_OUTPUT" - name: "Set up QEMU" uses: docker/setup-qemu-action@v3 @@ -71,11 +109,8 @@ jobs: uses: docker/metadata-action@v5 with: images: ghcr.io/procollab-github/api - flavor: latest=true tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} + type=raw,value=${{ steps.vars.outputs.image_tag }} - name: Build and push container uses: docker/build-push-action@v5 with: @@ -98,28 +133,69 @@ jobs: username: ${{ secrets.SERVER_USER }} password: ${{ secrets.SERVER_PASSWORD }} command: | - cd /home/app/procollab-backend && - docker container prune -f && - docker image prune -a -f && - docker compose -f docker-compose.prod-ci.yml -p prod pull && - - rm -f .env && - touch .env && - - echo "DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }}" >> .env && - - echo "DATABASE_NAME=${{ secrets.DATABASE_NAME }}" >> .env && - echo "DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }}" >> .env && - echo "DATABASE_USER=${{ secrets.DATABASE_USER }}" >> .env && - echo "DATABASE_HOST=${{ secrets.DATABASE_HOST }}" >> .env && - echo "DATABASE_PORT=${{ secrets.DATABASE_PORT }}" >> .env && - - echo "SELECTEL_ACCOUNT_ID=${{ secrets.SELECTEL_ACCOUNT_ID }}" >> .env && - echo "SELECTEL_CONTAINER_NAME=${{ secrets.SELECTEL_CONTAINER_NAME }}" >> .env && - echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env && - echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env && - - echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env && - echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env && - - docker compose -f docker-compose.prod-ci.yml -p prod up -d + set -eu + + export IMAGE_TAG="${{ needs.build.outputs.image_tag }}" + export DEPLOY_REF="${{ needs.build.outputs.deploy_ref }}" + echo "Deploying IMAGE_TAG=${IMAGE_TAG} from DEPLOY_REF=${DEPLOY_REF}" + + if [ "$(id -un)" = "app" ]; then + git -C /home/app/procollab-backend fetch --all --tags --prune + git -C /home/app/procollab-backend checkout --detach "${DEPLOY_REF}" + git -C /home/app/procollab-backend rev-parse HEAD + else + sudo -u app git -C /home/app/procollab-backend fetch --all --tags --prune + sudo -u app git -C /home/app/procollab-backend checkout --detach "${DEPLOY_REF}" + sudo -u app git -C /home/app/procollab-backend rev-parse HEAD + fi + + cd /home/app/procollab-backend + docker container prune -f + docker image prune -a -f + docker compose -f docker-compose.prod-ci.yml -p prod pull + + rm -f .env + touch .env + + echo "DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }}" >> .env + + echo "DATABASE_NAME=${{ secrets.DATABASE_NAME }}" >> .env + echo "DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }}" >> .env + echo "DATABASE_USER=${{ secrets.DATABASE_USER }}" >> .env + echo "DATABASE_HOST=${{ secrets.DATABASE_HOST }}" >> .env + echo "DATABASE_PORT=${{ secrets.DATABASE_PORT }}" >> .env + + echo "SELECTEL_ACCOUNT_ID=${{ secrets.SELECTEL_ACCOUNT_ID }}" >> .env + echo "SELECTEL_CONTAINER_NAME=${{ secrets.SELECTEL_CONTAINER_NAME }}" >> .env + echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env + echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env + + echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env + echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env + + chmod 600 .env + docker compose -f docker-compose.prod-ci.yml -p prod config >/dev/null + + docker compose -f docker-compose.prod-ci.yml -p prod up -d --remove-orphans + if [ "$(id -u)" -eq 0 ]; then + nginx -t + systemctl reload nginx + else + sudo nginx -t + sudo systemctl reload nginx + fi + + for attempt in $(seq 1 24); do + root_status="$(curl -k -s -o /dev/null -w '%{http_code}' https://api.procollab.ru/ || true)" + admin_status="$(curl -k -s -o /dev/null -w '%{http_code}' https://api.procollab.ru/admin/login/ || true)" + + if [ "$root_status" = "401" ] && [ "$admin_status" = "200" ]; then + echo "Smoke check passed on attempt ${attempt}" + exit 0 + fi + + sleep 5 + done + + echo "Smoke check failed: /=${root_status} /admin/login/=${admin_status}" + exit 1 diff --git a/deploy/nginx/host/prod/api.procollab.ru b/deploy/nginx/host/prod/api.procollab.ru new file mode 100644 index 00000000..1da95959 --- /dev/null +++ b/deploy/nginx/host/prod/api.procollab.ru @@ -0,0 +1,32 @@ +server { + listen 80; + listen [::]:80; + server_name api.procollab.ru; + server_tokens off; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/html; + default_type "text/plain"; + try_files $uri =404; + } + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name api.procollab.ru; + server_tokens off; + + ssl_certificate /etc/letsencrypt/live/api.procollab.ru-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.procollab.ru-0001/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + client_max_body_size 100M; + + location / { + include /etc/nginx/procollab/includes/proxy_app.inc; + } +} diff --git a/docker-compose.dev-ci.yml b/docker-compose.dev-ci.yml index fb2504c9..0271744a 100644 --- a/docker-compose.dev-ci.yml +++ b/docker-compose.dev-ci.yml @@ -5,7 +5,7 @@ services: build: context: . dockerfile: ./Dockerfile - image: ghcr.io/procollab-github/api:latest + image: procollab-dev-api:${IMAGE_TAG:-dev} restart: unless-stopped volumes: - ./log:/procollab/log @@ -28,9 +28,7 @@ services: celerys: container_name: api_celery restart: always - build: - context: . - dockerfile: ./Dockerfile + image: procollab-dev-api:${IMAGE_TAG:-dev} env_file: - .env command: bash ./scripts/celery.sh @@ -39,6 +37,6 @@ services: # - db - web volumes: - - .:/procollab + - ./log:/procollab/log volumes: redis-data: diff --git a/docker-compose.prod-ci.yml b/docker-compose.prod-ci.yml index ee1a13eb..6b80ba70 100644 --- a/docker-compose.prod-ci.yml +++ b/docker-compose.prod-ci.yml @@ -2,10 +2,7 @@ version: '3.9' services: web: - build: - context: . - dockerfile: ./Dockerfile - image: ghcr.io/procollab-github/api:latest + image: ghcr.io/procollab-github/api:${IMAGE_TAG:?IMAGE_TAG is required} restart: unless-stopped volumes: - ./log:/procollab/log @@ -13,60 +10,8 @@ services: - .env environment: HOST: 0.0.0.0 - expose: - - 8000 - - # web: - # image: ghcr.io/procollab-github/api:latest - # restart: unless-stopped - # volumes: - # - log:/procollab/log - # env_file: - # - .env - # environment: - # HOST: 0.0.0.0 - # expose: - # - 8000 - grafana: - image: grafana/grafana:latest - restart: unless-stopped - expose: - - 3000 - volumes: - - grafana-data:/var/lib/grafana - - grafana-configs:/etc/grafana - environment: - - GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s:%(http_port)s/grafana - - GF_SERVER_SERVE_FROM_SUB_PATH=true - prometheus: - image: prom/prometheus:v2.36.0 - restart: unless-stopped - expose: - - 9090 - volumes: - - prom-data:/prometheus - - prom-configs:/etc/prometheus - node-exporter: - image: prom/node-exporter:v1.3.1 - restart: unless-stopped - expose: - - 9100 - volumes: - - /proc:/host/proc:ro - - /sys:/host/sys:ro - - /:/rootfs:ro - command: - - '--path.procfs=/host/proc' - - '--path.sysfs=/host/sys' - - '--collector.filesystem.mount-points-exclude' - - '^/(sys|proc|dev|host|etc|rootfs/var/lib/docker/containers|rootfs/var/lib/docker/overlay2|rootfs/run/docker/netns|rootfs/var/lib/docker/aufs)($$|/)' - nginx: - build: ./nginx - restart: unless-stopped - depends_on: - - web ports: - - 8000:80 + - "127.0.0.1:8000:8000" redis: image: redis:latest restart: unless-stopped @@ -79,9 +24,7 @@ services: celerys: container_name: api_celery restart: always - build: - context: . - dockerfile: ./Dockerfile + image: ghcr.io/procollab-github/api:${IMAGE_TAG:?IMAGE_TAG is required} env_file: - .env command: bash ./scripts/celery.sh @@ -90,12 +33,7 @@ services: # - db - web volumes: - - .:/procollab + - ./log:/procollab/log volumes: - grafana-data: - grafana-configs: - prom-data: - prom-configs: - log: redis-data: diff --git a/scripts/celery.sh b/scripts/celery.sh index 56464fad..0376e21e 100644 --- a/scripts/celery.sh +++ b/scripts/celery.sh @@ -1,3 +1,4 @@ #!/bin/bash -cd apps -celery -A procollab worker --beat --loglevel=debug \ No newline at end of file +set -eu + +exec celery -A procollab worker --beat --loglevel=debug From 79d7dd6b1b496fba24b5855f1e8c0a163e80e188 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 17 Apr 2026 14:00:57 +0500 Subject: [PATCH 12/18] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20dev?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20image=20=D0=B8=20reload=20nginx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 7bfb3210..bfd6abab 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -50,11 +50,17 @@ jobs: chmod 600 .env docker compose -f docker-compose.dev-ci.yml config >/dev/null - docker compose -f docker-compose.dev-ci.yml up -d --build --force-recreate --remove-orphans && + docker compose -f docker-compose.dev-ci.yml build web && + docker compose -f docker-compose.dev-ci.yml up -d --force-recreate --remove-orphans && install -d /etc/nginx/procollab/includes && install -m 644 deploy/nginx/host/includes/proxy_app.inc /etc/nginx/procollab/includes/proxy_app.inc && install -m 644 deploy/nginx/host/dev/dev.procollab.ru /etc/nginx/sites-available/dev.procollab.ru && ln -sfn /etc/nginx/sites-available/dev.procollab.ru /etc/nginx/sites-enabled/dev.procollab.ru && - nginx -t && - systemctl reload nginx + if [ "$(id -u)" -eq 0 ]; then + nginx -t && + systemctl reload nginx + else + sudo nginx -t && + sudo systemctl reload nginx + fi From 75d0cfd8c7bac9f87ee85e2cffc80f050e0c28a4 Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 20 Apr 2026 10:25:29 +0500 Subject: [PATCH 13/18] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=20le?= =?UTF-8?q?gacy=20grafana=20route=20=D0=B8=D0=B7=20container=20nginx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nginx/nginx.conf | 5 ----- 1 file changed, 5 deletions(-) diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 6dbc6af0..7cf66052 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -15,11 +15,6 @@ server { proxy_set_header Upgrade $http_upgrade; } - location /grafana { - proxy_pass http://grafana:3000; - proxy_set_header Host $host; - } - location @proxy_to_app { proxy_pass http://web:8000; From 2d2c214db1198bbfb3655ec7b8a325b5e15077a7 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 22 Apr 2026 11:56:34 +0500 Subject: [PATCH 14/18] =?UTF-8?q?=D0=A3=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=20de?= =?UTF-8?q?ploy=20path=20dev/prod=20=D0=B8=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20legacy=20startup=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 46 +++++++++++- .github/workflows/release-ci.yml | 118 ++++++++++++++++++++----------- Dockerfile | 11 ++- docker-compose.dev-ci.yml | 2 +- docker-compose.prod-ci.yml | 2 +- docker-compose.yml | 9 ++- scripts/startup.sh | 7 -- 7 files changed, 141 insertions(+), 54 deletions(-) delete mode 100644 scripts/startup.sh diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index bfd6abab..f159a7f8 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -51,6 +51,7 @@ jobs: docker compose -f docker-compose.dev-ci.yml config >/dev/null docker compose -f docker-compose.dev-ci.yml build web && + docker compose -f docker-compose.dev-ci.yml run --rm web python manage.py migrate && docker compose -f docker-compose.dev-ci.yml up -d --force-recreate --remove-orphans && install -d /etc/nginx/procollab/includes && @@ -63,4 +64,47 @@ jobs: else sudo nginx -t && sudo systemctl reload nginx - fi + fi && + + for attempt in $(seq 1 24); do + root_status="$(curl -s -o /dev/null -w '%{http_code}' https://dev.procollab.ru/ || true)" && + admin_status="$(curl -s -o /dev/null -w '%{http_code}' https://dev.procollab.ru/admin/login/ || true)" && + + if [ "$root_status" = "401" ] && [ "$admin_status" = "200" ]; then + echo "Smoke check passed on attempt ${attempt}" && + break + fi + + sleep 5 + done && + + if [ "$root_status" != "401" ] || [ "$admin_status" != "200" ]; then + echo "Smoke check failed: /=${root_status} /admin/login/=${admin_status}" >&2 && + exit 1 + fi && + + celery_status="" && + celery_ping="" && + for attempt in $(seq 1 24); do + celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" && + if [ "$celery_status" = "running" ]; then + celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping -d \"celery@$(hostname)\"' 2>&1 || true)" && + printf '%s\n' "$celery_ping" && + if printf '%s\n' "$celery_ping" | grep -q 'pong'; then + echo "Celery check passed on attempt ${attempt}" && + break + fi + fi && + + sleep 5 + done && + + if [ "$celery_status" != "running" ]; then + echo "Celery container is not running: ${celery_status}" >&2 && + exit 1 + fi && + + printf '%s\n' "$celery_ping" | grep -q 'pong' || { + echo "Celery ping failed" >&2 + exit 1 + } diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index 9ed4b5e7..5389e3d4 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -15,11 +15,45 @@ on: type: string jobs: + prepare: + name: Resolve Release Ref + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.vars.outputs.image_tag }} + deploy_ref: ${{ steps.vars.outputs.deploy_ref }} + steps: + - name: Resolve image tag and deploy ref + id: vars + run: | + if [ "${{ github.event_name }}" = "release" ]; then + image_tag='${{ github.event.release.tag_name }}' + deploy_ref='${{ github.event.release.tag_name }}' + else + image_tag='${{ github.event.inputs.image_tag }}' + deploy_ref='${{ github.event.inputs.deploy_ref }}' + fi + + if [ -z "$image_tag" ]; then + echo "IMAGE_TAG is empty" >&2 + exit 1 + fi + + if [ -z "$deploy_ref" ]; then + echo "DEPLOY_REF is empty" >&2 + exit 1 + fi + + echo "image_tag=$image_tag" >> "$GITHUB_OUTPUT" + echo "deploy_ref=$deploy_ref" >> "$GITHUB_OUTPUT" + test: name: Tests runs-on: ubuntu-latest + needs: [ prepare ] steps: - uses: actions/checkout@v3 + with: + ref: ${{ needs.prepare.outputs.deploy_ref }} - name: Set up Python 3.11 uses: actions/setup-python@v4 @@ -57,39 +91,15 @@ jobs: build: name: Build Image runs-on: ubuntu-latest - needs: [ test ] + needs: [ prepare, test ] outputs: - image_tag: ${{ steps.vars.outputs.image_tag }} - deploy_ref: ${{ steps.vars.outputs.deploy_ref }} + image_tag: ${{ needs.prepare.outputs.image_tag }} + deploy_ref: ${{ needs.prepare.outputs.deploy_ref }} steps: - name: "Checkout repository" uses: actions/checkout@v3 with: - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.deploy_ref }} - - - name: Resolve image tag - id: vars - run: | - if [ "${{ github.event_name }}" = "release" ]; then - image_tag='${{ github.event.release.tag_name }}' - deploy_ref='${{ github.event.release.tag_name }}' - else - image_tag='${{ github.event.inputs.image_tag }}' - deploy_ref='${{ github.event.inputs.deploy_ref }}' - fi - - if [ -z "$image_tag" ]; then - echo "IMAGE_TAG is empty" >&2 - exit 1 - fi - - if [ -z "$deploy_ref" ]; then - echo "DEPLOY_REF is empty" >&2 - exit 1 - fi - - echo "image_tag=$image_tag" >> "$GITHUB_OUTPUT" - echo "deploy_ref=$deploy_ref" >> "$GITHUB_OUTPUT" + ref: ${{ needs.prepare.outputs.deploy_ref }} - name: "Set up QEMU" uses: docker/setup-qemu-action@v3 @@ -110,7 +120,7 @@ jobs: with: images: ghcr.io/procollab-github/api tags: | - type=raw,value=${{ steps.vars.outputs.image_tag }} + type=raw,value=${{ needs.prepare.outputs.image_tag }} - name: Build and push container uses: docker/build-push-action@v5 with: @@ -124,7 +134,7 @@ jobs: deploy: name: Deploy runs-on: ubuntu-latest - needs: [ build ] + needs: [ prepare, build ] steps: - name: Deploy to server uses: garygrossgarten/github-action-ssh@release @@ -135,8 +145,8 @@ jobs: command: | set -eu - export IMAGE_TAG="${{ needs.build.outputs.image_tag }}" - export DEPLOY_REF="${{ needs.build.outputs.deploy_ref }}" + export IMAGE_TAG="${{ needs.prepare.outputs.image_tag }}" + export DEPLOY_REF="${{ needs.prepare.outputs.deploy_ref }}" echo "Deploying IMAGE_TAG=${IMAGE_TAG} from DEPLOY_REF=${DEPLOY_REF}" if [ "$(id -un)" = "app" ]; then @@ -150,10 +160,6 @@ jobs: fi cd /home/app/procollab-backend - docker container prune -f - docker image prune -a -f - docker compose -f docker-compose.prod-ci.yml -p prod pull - rm -f .env touch .env @@ -175,7 +181,9 @@ jobs: chmod 600 .env docker compose -f docker-compose.prod-ci.yml -p prod config >/dev/null + docker compose -f docker-compose.prod-ci.yml -p prod pull + docker compose -f docker-compose.prod-ci.yml -p prod run --rm web python manage.py migrate docker compose -f docker-compose.prod-ci.yml -p prod up -d --remove-orphans if [ "$(id -u)" -eq 0 ]; then nginx -t @@ -186,16 +194,44 @@ jobs: fi for attempt in $(seq 1 24); do - root_status="$(curl -k -s -o /dev/null -w '%{http_code}' https://api.procollab.ru/ || true)" - admin_status="$(curl -k -s -o /dev/null -w '%{http_code}' https://api.procollab.ru/admin/login/ || true)" + root_status="$(curl -s -o /dev/null -w '%{http_code}' https://api.procollab.ru/ || true)" + admin_status="$(curl -s -o /dev/null -w '%{http_code}' https://api.procollab.ru/admin/login/ || true)" if [ "$root_status" = "401" ] && [ "$admin_status" = "200" ]; then echo "Smoke check passed on attempt ${attempt}" - exit 0 + break + fi + + sleep 5 + done + + if [ "$root_status" != "401" ] || [ "$admin_status" != "200" ]; then + echo "Smoke check failed: /=${root_status} /admin/login/=${admin_status}" >&2 + exit 1 + fi + + celery_status="" + celery_ping="" + for attempt in $(seq 1 24); do + celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" + if [ "$celery_status" = "running" ]; then + celery_ping="$(docker compose -f docker-compose.prod-ci.yml -p prod exec -T celerys sh -lc 'celery -A procollab inspect ping -d \"celery@$(hostname)\"' 2>&1 || true)" + printf '%s\n' "$celery_ping" + if printf '%s\n' "$celery_ping" | grep -q 'pong'; then + echo "Celery check passed on attempt ${attempt}" + break + fi fi sleep 5 done - echo "Smoke check failed: /=${root_status} /admin/login/=${admin_status}" - exit 1 + if [ "$celery_status" != "running" ]; then + echo "Celery container is not running: ${celery_status}" >&2 + exit 1 + fi + + printf '%s\n' "$celery_ping" | grep -q 'pong' || { + echo "Celery ping failed" >&2 + exit 1 + } diff --git a/Dockerfile b/Dockerfile index 516d3605..282824c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,5 +27,12 @@ RUN mkdir /procollab/static COPY . /procollab/ -CMD ["bash", "./scripts/startup.sh"] - +RUN DJANGO_SECRET_KEY=build-time-secret \ + DATABASE_NAME=postgres \ + DATABASE_USER=postgres \ + DATABASE_PASSWORD=postgres \ + DATABASE_HOST=localhost \ + DATABASE_PORT=5432 \ + python manage.py collectstatic --no-input + +CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "procollab.asgi:application"] diff --git a/docker-compose.dev-ci.yml b/docker-compose.dev-ci.yml index 0271744a..782e64ac 100644 --- a/docker-compose.dev-ci.yml +++ b/docker-compose.dev-ci.yml @@ -17,7 +17,7 @@ services: - "127.0.0.1:8000:8000" redis: - image: redis:latest + image: redis:7.2.5 restart: unless-stopped expose: - 6379 diff --git a/docker-compose.prod-ci.yml b/docker-compose.prod-ci.yml index 6b80ba70..45df8d3f 100644 --- a/docker-compose.prod-ci.yml +++ b/docker-compose.prod-ci.yml @@ -13,7 +13,7 @@ services: ports: - "127.0.0.1:8000:8000" redis: - image: redis:latest + image: redis:7.2.5 restart: unless-stopped expose: - 6379 diff --git a/docker-compose.yml b/docker-compose.yml index b4c108b2..830306c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,17 @@ +# LEGACY compose path. +# Do not use this file for prod or CI/CD deployments. +# Intentional manual use requires: docker compose --profile legacy ... version: '3.9' services: web: + profiles: ["legacy"] container_name: web build: context: . dockerfile: ./Dockerfile restart: always - command: bash ./scripts/startup.sh + command: ["daphne", "-b", "0.0.0.0", "-p", "8000", "procollab.asgi:application"] volumes: - ./log:/procollab/log env_file: @@ -18,6 +22,7 @@ services: - 8000 nginx: + profiles: ["legacy"] container_name: nginx build: ./nginx depends_on: @@ -26,6 +31,7 @@ services: - "8000:80" redis: + profiles: ["legacy"] container_name: redis image: redis:latest expose: @@ -34,6 +40,7 @@ services: - redis-data:/data celerys: + profiles: ["legacy"] container_name: api_celery restart: always build: diff --git a/scripts/startup.sh b/scripts/startup.sh deleted file mode 100644 index 6c35ebf8..00000000 --- a/scripts/startup.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -python manage.py migrate -python manage.py collectstatic --no-input - -# Use Daphne ASGI server instead of Django's dev server. -exec daphne -b 0.0.0.0 -p 8000 procollab.asgi:application From 9919e5b2db011c13c23b2d6f28d698eb11b60d84 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 22 Apr 2026 12:36:57 +0500 Subject: [PATCH 15/18] =?UTF-8?q?=D0=A1=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20deploy?= =?UTF-8?q?=20workflows=20=D0=B8=20compose-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 24 ++---------------------- .github/workflows/release-ci.yml | 22 +--------------------- 2 files changed, 3 insertions(+), 43 deletions(-) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index f159a7f8..8e841303 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -83,28 +83,8 @@ jobs: exit 1 fi && - celery_status="" && - celery_ping="" && - for attempt in $(seq 1 24); do - celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" && - if [ "$celery_status" = "running" ]; then - celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping -d \"celery@$(hostname)\"' 2>&1 || true)" && - printf '%s\n' "$celery_ping" && - if printf '%s\n' "$celery_ping" | grep -q 'pong'; then - echo "Celery check passed on attempt ${attempt}" && - break - fi - fi && - - sleep 5 - done && - + celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" && if [ "$celery_status" != "running" ]; then echo "Celery container is not running: ${celery_status}" >&2 && exit 1 - fi && - - printf '%s\n' "$celery_ping" | grep -q 'pong' || { - echo "Celery ping failed" >&2 - exit 1 - } + fi diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index 5389e3d4..706e1769 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -210,28 +210,8 @@ jobs: exit 1 fi - celery_status="" - celery_ping="" - for attempt in $(seq 1 24); do - celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" - if [ "$celery_status" = "running" ]; then - celery_ping="$(docker compose -f docker-compose.prod-ci.yml -p prod exec -T celerys sh -lc 'celery -A procollab inspect ping -d \"celery@$(hostname)\"' 2>&1 || true)" - printf '%s\n' "$celery_ping" - if printf '%s\n' "$celery_ping" | grep -q 'pong'; then - echo "Celery check passed on attempt ${attempt}" - break - fi - fi - - sleep 5 - done - + celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" if [ "$celery_status" != "running" ]; then echo "Celery container is not running: ${celery_status}" >&2 exit 1 fi - - printf '%s\n' "$celery_ping" | grep -q 'pong' || { - echo "Celery ping failed" >&2 - exit 1 - } From ddfac1f715bbdb108411382abd105d1c149a4613 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 22 Apr 2026 12:52:14 +0500 Subject: [PATCH 16/18] =?UTF-8?q?=D0=92=D0=BE=D0=B7=D0=B2=D1=80=D0=B0?= =?UTF-8?q?=D1=89=D1=91=D0=BD=20=D0=BF=D1=80=D0=B5=D0=B6=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20Redis=20image=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?volume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.dev-ci.yml | 2 +- docker-compose.prod-ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.dev-ci.yml b/docker-compose.dev-ci.yml index 782e64ac..0271744a 100644 --- a/docker-compose.dev-ci.yml +++ b/docker-compose.dev-ci.yml @@ -17,7 +17,7 @@ services: - "127.0.0.1:8000:8000" redis: - image: redis:7.2.5 + image: redis:latest restart: unless-stopped expose: - 6379 diff --git a/docker-compose.prod-ci.yml b/docker-compose.prod-ci.yml index 45df8d3f..6b80ba70 100644 --- a/docker-compose.prod-ci.yml +++ b/docker-compose.prod-ci.yml @@ -13,7 +13,7 @@ services: ports: - "127.0.0.1:8000:8000" redis: - image: redis:7.2.5 + image: redis:latest restart: unless-stopped expose: - 6379 From 4046758512f30ffe91ddc2e2affe65c1c12acbb8 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 22 Apr 2026 12:54:03 +0500 Subject: [PATCH 17/18] =?UTF-8?q?=D0=92=D0=BE=D0=B7=D0=B2=D1=80=D0=B0?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B0=20blocking-=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20Celery=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 24 ++++++++++++++++++++++-- .github/workflows/release-ci.yml | 22 +++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 8e841303..f159a7f8 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -83,8 +83,28 @@ jobs: exit 1 fi && - celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" && + celery_status="" && + celery_ping="" && + for attempt in $(seq 1 24); do + celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" && + if [ "$celery_status" = "running" ]; then + celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping -d \"celery@$(hostname)\"' 2>&1 || true)" && + printf '%s\n' "$celery_ping" && + if printf '%s\n' "$celery_ping" | grep -q 'pong'; then + echo "Celery check passed on attempt ${attempt}" && + break + fi + fi && + + sleep 5 + done && + if [ "$celery_status" != "running" ]; then echo "Celery container is not running: ${celery_status}" >&2 && exit 1 - fi + fi && + + printf '%s\n' "$celery_ping" | grep -q 'pong' || { + echo "Celery ping failed" >&2 + exit 1 + } diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index 706e1769..5389e3d4 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -210,8 +210,28 @@ jobs: exit 1 fi - celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" + celery_status="" + celery_ping="" + for attempt in $(seq 1 24); do + celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" + if [ "$celery_status" = "running" ]; then + celery_ping="$(docker compose -f docker-compose.prod-ci.yml -p prod exec -T celerys sh -lc 'celery -A procollab inspect ping -d \"celery@$(hostname)\"' 2>&1 || true)" + printf '%s\n' "$celery_ping" + if printf '%s\n' "$celery_ping" | grep -q 'pong'; then + echo "Celery check passed on attempt ${attempt}" + break + fi + fi + + sleep 5 + done + if [ "$celery_status" != "running" ]; then echo "Celery container is not running: ${celery_status}" >&2 exit 1 fi + + printf '%s\n' "$celery_ping" | grep -q 'pong' || { + echo "Celery ping failed" >&2 + exit 1 + } From 9885657d550880baa365da1e0d494387ef1e28dd Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 22 Apr 2026 13:02:44 +0500 Subject: [PATCH 18/18] =?UTF-8?q?=D0=A1=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20blocki?= =?UTF-8?q?ng-=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20Celery=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 2 +- .github/workflows/release-ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index f159a7f8..af3738a1 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -88,7 +88,7 @@ jobs: for attempt in $(seq 1 24); do celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" && if [ "$celery_status" = "running" ]; then - celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping -d \"celery@$(hostname)\"' 2>&1 || true)" && + celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=10' 2>&1 || true)" && printf '%s\n' "$celery_ping" && if printf '%s\n' "$celery_ping" | grep -q 'pong'; then echo "Celery check passed on attempt ${attempt}" && diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index 5389e3d4..90de6eb5 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -215,7 +215,7 @@ jobs: for attempt in $(seq 1 24); do celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" if [ "$celery_status" = "running" ]; then - celery_ping="$(docker compose -f docker-compose.prod-ci.yml -p prod exec -T celerys sh -lc 'celery -A procollab inspect ping -d \"celery@$(hostname)\"' 2>&1 || true)" + celery_ping="$(docker compose -f docker-compose.prod-ci.yml -p prod exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=10' 2>&1 || true)" printf '%s\n' "$celery_ping" if printf '%s\n' "$celery_ping" | grep -q 'pong'; then echo "Celery check passed on attempt ${attempt}"