Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e1f1cb787 | ||
|
|
74da8ab98e | ||
|
|
19f7daff6c | ||
|
|
cf3af17243 | ||
|
|
93353db511 | ||
|
|
e7f93a5f47 | ||
|
|
c2ab881be2 | ||
|
|
4a84bba697 | ||
|
|
cda3b741a1 | ||
|
|
2e82febdcc | ||
|
|
bd7710d03c |
@@ -1,5 +1,6 @@
|
|||||||
.gitattributes text eol=lf
|
.gitattributes text eol=lf
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
.github/workflows/*.yml text eol=lf
|
||||||
deploy/docker/frontend/*.sh text eol=lf
|
deploy/docker/frontend/*.sh text eol=lf
|
||||||
frontend/src/*.js text eol=lf
|
frontend/src/*.js text eol=lf
|
||||||
frontend/src/**/*.js text eol=lf
|
frontend/src/**/*.js text eol=lf
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
name: Docker build & push (reusable)
|
||||||
|
|
||||||
|
# Reusable workflow that builds the three image targets defined in
|
||||||
|
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
|
||||||
|
# them to both ghcr.io and Docker Hub under the 3252a8/ namespace.
|
||||||
|
#
|
||||||
|
# Called by:
|
||||||
|
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
|
||||||
|
# - docker-release.yml (tag_mode: release, push: true) on pushes to main
|
||||||
|
# - ci.yml (tag_mode: dev, push: false) on pull requests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
push:
|
||||||
|
description: "Push the built images to the registries"
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
tag_mode:
|
||||||
|
description: "Tagging strategy: 'dev' or 'release'"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
|
||||||
|
# No permissions block here on purpose: a reusable workflow cannot request more
|
||||||
|
# than its caller grants, so the token scope is set by each caller
|
||||||
|
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
|
||||||
|
# only needs contents: read for a no-push build).
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: backend
|
||||||
|
image: remnawave-minishop-backend
|
||||||
|
- target: worker
|
||||||
|
image: remnawave-minishop-worker
|
||||||
|
- target: frontend
|
||||||
|
image: remnawave-minishop-frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags: the Dockerfile's version-builder stage runs
|
||||||
|
# `git describe --tags` against the copied .git tree.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve release version
|
||||||
|
id: version
|
||||||
|
if: inputs.tag_mode == 'release'
|
||||||
|
run: |
|
||||||
|
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
|
||||||
|
# manual workflow_dispatch on a branch, fall back to the latest tag.
|
||||||
|
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
|
raw="${{ github.ref_name }}"
|
||||||
|
else
|
||||||
|
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
version="${raw#v}"
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
echo "::error::No git tag found to derive the release version from"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release version: ${version}"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
if: inputs.push
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
if: inputs.push
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
3252a8/${{ matrix.image }}
|
||||||
|
ghcr.io/3252a8/${{ matrix.image }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
|
||||||
|
type=sha,prefix=dev-,format=short,enable=${{ inputs.tag_mode == 'dev' }}
|
||||||
|
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
|
||||||
|
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
|
||||||
|
|
||||||
|
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: deploy/docker/Dockerfile
|
||||||
|
target: ${{ matrix.target }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: ${{ inputs.push }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
|
||||||
|
# internal version string for non-main builds. Force "main" on release
|
||||||
|
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||||
|
build-args: |
|
||||||
|
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.target }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||||
|
provenance: false
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
name: PR checks
|
||||||
|
|
||||||
|
# Runs on pull requests into main (typically from dev) and into dev (typically
|
||||||
|
# from feature/* branches): lint + format checks and a no-push image build to
|
||||||
|
# prove the Docker images still build.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint & format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install ruff
|
||||||
|
run: pip install "ruff>=0.8.0"
|
||||||
|
|
||||||
|
- name: Ruff lint (Python)
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Ruff format check (Python)
|
||||||
|
run: ruff format --check .
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: ESLint (frontend)
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Prettier check (frontend)
|
||||||
|
run: npm run format:check
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Docker build
|
||||||
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
with:
|
||||||
|
push: false
|
||||||
|
tag_mode: dev
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Dependency review
|
||||||
|
|
||||||
|
# On PRs into main/dev, flag any newly added dependency that has a known
|
||||||
|
# vulnerability or an incompatible license before it gets merged.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-review:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Dependency review
|
||||||
|
uses: actions/dependency-review-action@v4
|
||||||
|
with:
|
||||||
|
fail-on-severity: high
|
||||||
|
comment-summary-in-pr: on-failure
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Dev images
|
||||||
|
|
||||||
|
# On every push to the dev branch, build all three images and push them to
|
||||||
|
# ghcr.io and Docker Hub tagged `dev` and `dev-<short-sha>`.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-dev-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
tag_mode: dev
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Release images
|
||||||
|
|
||||||
|
# Build all three images and push them to ghcr.io and Docker Hub tagged
|
||||||
|
# `latest` and the release version (the pushed tag with its leading `v`
|
||||||
|
# stripped, e.g. v3.4.5 -> 3.4.5). Triggered only when a new v* tag is pushed,
|
||||||
|
# so images are built once per release rather than on every commit to main.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
tag_mode: release
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
# Audits the full dependency set (pip-audit, npm audit) and runs a Trivy
|
||||||
|
# filesystem scan (dependencies + Dockerfile/IaC misconfig). Trivy results are
|
||||||
|
# uploaded to the Security -> Code scanning tab.
|
||||||
|
#
|
||||||
|
# pip-audit / npm audit are informational (continue-on-error): they surface
|
||||||
|
# upstream/transitive advisories that aren't necessarily fixable in a given PR,
|
||||||
|
# so they report in the logs without blocking merges. The PR gate for newly
|
||||||
|
# introduced vulnerable deps is dependency-review.yml.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
schedule:
|
||||||
|
- cron: "27 4 * * 1" # weekly, Monday 04:27 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-audit:
|
||||||
|
name: pip-audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install pip-audit
|
||||||
|
run: pip install pip-audit
|
||||||
|
|
||||||
|
- name: Audit Python dependencies
|
||||||
|
continue-on-error: true
|
||||||
|
run: pip-audit -r backend/requirements.txt
|
||||||
|
|
||||||
|
npm-audit:
|
||||||
|
name: npm audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Audit npm dependencies
|
||||||
|
continue-on-error: true
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
trivy:
|
||||||
|
name: Trivy filesystem scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Trivy
|
||||||
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
format: sarif
|
||||||
|
output: trivy-results.sarif
|
||||||
|
severity: CRITICAL,HIGH
|
||||||
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
- name: Upload Trivy results
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-results.sarif
|
||||||
|
category: trivy-fs
|
||||||
@@ -71,11 +71,17 @@ async def show_statistics_handler(
|
|||||||
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
||||||
)
|
)
|
||||||
# Removed: Active today moved to panel stats
|
# Removed: Active today moved to panel stats
|
||||||
|
stats_text_parts.append(
|
||||||
|
f"📡 {_('admin_user_stats_active_subscription_label')}: <b>{user_stats['active_subscriptions']}</b>" # noqa: E501
|
||||||
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
f"🧪 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||||
|
)
|
||||||
|
stats_text_parts.append(
|
||||||
|
f"🎁 {_('admin_user_stats_free_subscription_label')}: <b>{user_stats['free_subscription_users']}</b>" # noqa: E501
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
||||||
|
|||||||
@@ -166,8 +166,10 @@ async def create_user_stats_result(
|
|||||||
"inline_user_stats_message",
|
"inline_user_stats_message",
|
||||||
total=user_stats["total_users"],
|
total=user_stats["total_users"],
|
||||||
active_today=user_stats["active_today"],
|
active_today=user_stats["active_today"],
|
||||||
|
active=user_stats["active_subscriptions"],
|
||||||
paid=user_stats["paid_subscriptions"],
|
paid=user_stats["paid_subscriptions"],
|
||||||
trial=user_stats["trial_users"],
|
trial=user_stats["trial_users"],
|
||||||
|
free=user_stats["free_subscription_users"],
|
||||||
inactive=user_stats["inactive_users"],
|
inactive=user_stats["inactive_users"],
|
||||||
banned=user_stats["banned_users"],
|
banned=user_stats["banned_users"],
|
||||||
referral=user_stats["referral_users"],
|
referral=user_stats["referral_users"],
|
||||||
@@ -179,7 +181,7 @@ async def create_user_stats_result(
|
|||||||
description=_(
|
description=_(
|
||||||
"inline_user_stats_description",
|
"inline_user_stats_description",
|
||||||
total=user_stats["total_users"],
|
total=user_stats["total_users"],
|
||||||
active=user_stats["paid_subscriptions"],
|
active=user_stats["active_subscriptions"],
|
||||||
),
|
),
|
||||||
input_message_content=InputTextMessageContent(
|
input_message_content=InputTextMessageContent(
|
||||||
message_text=stats_text, parse_mode="HTML"
|
message_text=stats_text, parse_mode="HTML"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
@@ -128,37 +127,6 @@ async def mark_telegram_notifications_enabled_for_telegram_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _translate(
|
|
||||||
i18n: Optional[JsonI18n],
|
|
||||||
language: str,
|
|
||||||
key: str,
|
|
||||||
fallback: str,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
if not i18n:
|
|
||||||
return fallback.format(**kwargs) if kwargs else fallback
|
|
||||||
return i18n.gettext(language, key, **kwargs) or fallback
|
|
||||||
|
|
||||||
|
|
||||||
def _probe_keyboard(
|
|
||||||
settings: Settings,
|
|
||||||
i18n: Optional[JsonI18n],
|
|
||||||
language: str,
|
|
||||||
) -> Optional[InlineKeyboardMarkup]:
|
|
||||||
app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
|
||||||
if not app_url:
|
|
||||||
return None
|
|
||||||
text = _translate(
|
|
||||||
i18n,
|
|
||||||
language,
|
|
||||||
"telegram_notifications_open_app_button",
|
|
||||||
"Open app",
|
|
||||||
)
|
|
||||||
return InlineKeyboardMarkup(
|
|
||||||
inline_keyboard=[[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=app_url))]]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def probe_telegram_notifications(
|
async def probe_telegram_notifications(
|
||||||
*,
|
*,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -187,20 +155,8 @@ async def probe_telegram_notifications(
|
|||||||
"start_link": telegram_notifications_start_link(bot_username),
|
"start_link": telegram_notifications_start_link(bot_username),
|
||||||
}
|
}
|
||||||
|
|
||||||
language = str(getattr(user, "language_code", "") or settings.DEFAULT_LANGUAGE)
|
|
||||||
text = _translate(
|
|
||||||
i18n,
|
|
||||||
language,
|
|
||||||
"telegram_notifications_enabled_message",
|
|
||||||
"Telegram notifications are enabled.",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.get_chat(int(telegram_id))
|
||||||
int(telegram_id),
|
|
||||||
text,
|
|
||||||
reply_markup=_probe_keyboard(settings, i18n, language),
|
|
||||||
disable_web_page_preview=True,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status = telegram_notification_status_from_error(exc)
|
status = telegram_notification_status_from_error(exc)
|
||||||
if status:
|
if status:
|
||||||
@@ -211,7 +167,7 @@ async def probe_telegram_notifications(
|
|||||||
"start_link": telegram_notifications_start_link(bot_username),
|
"start_link": telegram_notifications_start_link(bot_username),
|
||||||
}
|
}
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Telegram notification probe failed for user %s / telegram %s: %s",
|
"Telegram notification chat probe failed for user %s / telegram %s: %s",
|
||||||
user.user_id,
|
user.user_id,
|
||||||
telegram_id,
|
telegram_id,
|
||||||
exc,
|
exc,
|
||||||
|
|||||||
+67
-14
@@ -666,17 +666,33 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
|||||||
active_today = int(user_counts[2] or 0)
|
active_today = int(user_counts[2] or 0)
|
||||||
referral_users = int(user_counts[3] or 0)
|
referral_users = int(user_counts[3] or 0)
|
||||||
|
|
||||||
subscription_counts_stmt = (
|
provider_value = func.lower(func.coalesce(Subscription.provider, ""))
|
||||||
|
panel_status_value = func.upper(func.coalesce(Subscription.status_from_panel, ""))
|
||||||
|
trial_subscription_condition = or_(
|
||||||
|
provider_value == "trial",
|
||||||
|
panel_status_value == "TRIAL",
|
||||||
|
)
|
||||||
|
paid_subscription_condition = and_(
|
||||||
|
provider_value != "",
|
||||||
|
provider_value != "trial",
|
||||||
|
panel_status_value != "TRIAL",
|
||||||
|
)
|
||||||
|
free_subscription_condition = and_(
|
||||||
|
provider_value == "",
|
||||||
|
panel_status_value != "TRIAL",
|
||||||
|
)
|
||||||
|
|
||||||
|
active_subscription_flags_sq = (
|
||||||
select(
|
select(
|
||||||
func.count(
|
Subscription.user_id.label("user_id"),
|
||||||
func.distinct(
|
func.max(case((paid_subscription_condition, 1), else_=0)).label(
|
||||||
case((Subscription.provider.is_not(None), Subscription.user_id), else_=None)
|
"has_paid_subscription"
|
||||||
)
|
|
||||||
),
|
),
|
||||||
func.count(
|
func.max(case((trial_subscription_condition, 1), else_=0)).label(
|
||||||
func.distinct(
|
"has_trial_subscription"
|
||||||
case((Subscription.provider.is_(None), Subscription.user_id), else_=None)
|
),
|
||||||
)
|
func.max(case((free_subscription_condition, 1), else_=0)).label(
|
||||||
|
"has_free_subscription"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.join(User, Subscription.user_id == User.user_id)
|
.join(User, Subscription.user_id == User.user_id)
|
||||||
@@ -686,27 +702,64 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
|||||||
Subscription.end_date > now,
|
Subscription.end_date > now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.group_by(Subscription.user_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription_counts_stmt = select(
|
||||||
|
func.count(active_subscription_flags_sq.c.user_id),
|
||||||
|
func.coalesce(func.sum(active_subscription_flags_sq.c.has_paid_subscription), 0),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
active_subscription_flags_sq.c.has_paid_subscription == 0,
|
||||||
|
active_subscription_flags_sq.c.has_trial_subscription,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
and_(
|
||||||
|
active_subscription_flags_sq.c.has_paid_subscription == 0,
|
||||||
|
active_subscription_flags_sq.c.has_trial_subscription == 0,
|
||||||
|
),
|
||||||
|
active_subscription_flags_sq.c.has_free_subscription,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
subscription_counts = (await session.execute(subscription_counts_stmt)).one()
|
subscription_counts = (await session.execute(subscription_counts_stmt)).one()
|
||||||
paid_subs_users = int(subscription_counts[0] or 0)
|
active_subscription_users = int(subscription_counts[0] or 0)
|
||||||
trial_users = int(subscription_counts[1] or 0)
|
paid_subs_users = int(subscription_counts[1] or 0)
|
||||||
|
trial_users = int(subscription_counts[2] or 0)
|
||||||
|
free_subscription_users = int(subscription_counts[3] or 0)
|
||||||
|
|
||||||
# Inactive users (no active subscription)
|
inactive_users = total_users - active_subscription_users
|
||||||
inactive_users = total_users - paid_subs_users - trial_users - banned_users
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_users": total_users,
|
"total_users": total_users,
|
||||||
"banned_users": banned_users,
|
"banned_users": banned_users,
|
||||||
"active_today": active_today,
|
"active_today": active_today,
|
||||||
|
"active_subscriptions": active_subscription_users,
|
||||||
"paid_subscriptions": paid_subs_users,
|
"paid_subscriptions": paid_subs_users,
|
||||||
"trial_users": trial_users,
|
"trial_users": trial_users,
|
||||||
|
"free_subscription_users": free_subscription_users,
|
||||||
"inactive_users": max(0, inactive_users),
|
"inactive_users": max(0, inactive_users),
|
||||||
"referral_users": referral_users,
|
"referral_users": referral_users,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
|
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
|
||||||
"""Return non-banned user IDs who have an active subscription (paid or trial)."""
|
"""Return non-banned user IDs who have any active subscription."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|||||||
+11
-10
@@ -1,11 +1,12 @@
|
|||||||
# Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
|
# Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
|
||||||
# Use the directory entrypoint so Pages clean URLs do not loop between app.html and app.
|
# Use the generated directory index instead of app.html so Pages clean URLs do not
|
||||||
/demo/runtime/home/* /demo/runtime/app/ 200
|
# loop between extensionless and .html variants.
|
||||||
/demo/runtime/install/* /demo/runtime/app/ 200
|
/demo/runtime/home/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/trial/* /demo/runtime/app/ 200
|
/demo/runtime/install/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/invite/* /demo/runtime/app/ 200
|
/demo/runtime/trial/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/devices/* /demo/runtime/app/ 200
|
/demo/runtime/invite/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/support/* /demo/runtime/app/ 200
|
/demo/runtime/devices/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/settings/* /demo/runtime/app/ 200
|
/demo/runtime/support/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/login/* /demo/runtime/app/ 200
|
/demo/runtime/settings/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/admin/* /demo/runtime/app/ 200
|
/demo/runtime/login/* /demo/runtime/app/index.html 200
|
||||||
|
/demo/runtime/admin/* /demo/runtime/app/index.html 200
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ await runNpm(["--prefix", frontendRoot, "run", "build:docs-demo"]);
|
|||||||
|
|
||||||
await rm(runtimeDir, { recursive: true, force: true });
|
await rm(runtimeDir, { recursive: true, force: true });
|
||||||
await mkdir(runtimeDir, { recursive: true });
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
await mkdir(path.join(runtimeDir, "app"), { recursive: true });
|
||||||
|
|
||||||
const html = await appHtml();
|
const html = await appHtml();
|
||||||
|
|
||||||
@@ -204,7 +205,7 @@ await Promise.all([
|
|||||||
path.join(runtimeDir, "default-brand"),
|
path.join(runtimeDir, "default-brand"),
|
||||||
),
|
),
|
||||||
copyDirectory(themesDir, path.join(runtimeDir, "themes"), copyThemeFile),
|
copyDirectory(themesDir, path.join(runtimeDir, "themes"), copyThemeFile),
|
||||||
writeFile(path.join(runtimeDir, "app.html"), html, "utf8"),
|
writeFile(path.join(runtimeDir, "app", "index.html"), html, "utf8"),
|
||||||
installGuidesConfigPayload().then((payload) =>
|
installGuidesConfigPayload().then((payload) =>
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(runtimeDir, "subscription-guides-config.json"),
|
path.join(runtimeDir, "subscription-guides-config.json"),
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ async function copyHtml(source, route) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const demoShell = path.join(distRoot, "demo", "index.html");
|
const demoShell = path.join(distRoot, "demo", "index.html");
|
||||||
const runtimeApp = path.join(distRoot, "demo", "runtime", "app.html");
|
const runtimeApp = path.join(
|
||||||
|
distRoot,
|
||||||
|
"demo",
|
||||||
|
"runtime",
|
||||||
|
"app",
|
||||||
|
"index.html",
|
||||||
|
);
|
||||||
|
|
||||||
for (const route of demoRoutes) {
|
for (const route of demoRoutes) {
|
||||||
await copyHtml(demoShell, route);
|
await copyHtml(demoShell, route);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const runtimeAppHtml = path.join(process.cwd(), "public", "demo", "runtime", "app.html");
|
|
||||||
|
|
||||||
export const prerender = true;
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
return new Response(await readFile(runtimeAppHtml, "utf8"), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/html; charset=utf-8",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
- использует entrypoint `frontend/src/docsDemoEntry.js`, где подключены моковые данные и mock API;
|
- использует entrypoint `frontend/src/docsDemoEntry.js`, где подключены моковые данные и mock API;
|
||||||
- дополнительно собирает обычный admin-бандл, чтобы админка работала внутри демо;
|
- дополнительно собирает обычный admin-бандл, чтобы админка работала внутри демо;
|
||||||
- копирует JS/CSS, темы, default-brand ассеты, локали и конфиг гайдов подключения в `docs-site/public/demo/runtime/`;
|
- копирует JS/CSS, темы, default-brand ассеты, локали и конфиг гайдов подключения в `docs-site/public/demo/runtime/`;
|
||||||
- генерирует `app.html`, который грузит demo runtime и встроенные переводы;
|
- генерирует `app/index.html`, который грузит demo runtime и встроенные переводы;
|
||||||
- после Astro build материализует публичные страницы `/demo/home`, `/demo/install`, `/demo/admin/stats` и другие основные demo routes как статические `index.html`;
|
- после Astro build материализует публичные страницы `/demo/home`, `/demo/install`, `/demo/admin/stats` и другие основные demo routes как статические `index.html`;
|
||||||
- Cloudflare Pages rewrite-правила остаются только для внутреннего `/demo/runtime/*`, чтобы iframe мог использовать обычный History API без влияния на остальные страницы документации;
|
- Cloudflare Pages rewrite-правила остаются только для внутреннего `/demo/runtime/*`, чтобы iframe мог использовать обычный History API без влияния на остальные страницы документации;
|
||||||
- страницы `/demo/home`, `/demo/install`, `/demo/admin/*` и другие demo routes служат полноэкранной обвязкой с верхней панелью возврата в документацию, а внешняя страница синхронизирует читаемый адрес демо.
|
- страницы `/demo/home`, `/demo/install`, `/demo/admin/*` и другие demo routes служат полноэкранной обвязкой с верхней панелью возврата в документацию, а внешняя страница синхронизирует читаемый адрес демо.
|
||||||
|
|||||||
Generated
+16
-76
@@ -24,7 +24,7 @@
|
|||||||
"lucide-svelte": "^1.0.1",
|
"lucide-svelte": "^1.0.1",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.56.0",
|
||||||
"svelte-eslint-parser": "^1.4.1",
|
"svelte-eslint-parser": "^1.4.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "4.3.0",
|
"tailwindcss": "4.3.0",
|
||||||
@@ -947,9 +947,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -971,9 +968,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -995,9 +989,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1019,9 +1010,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1043,9 +1031,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1067,9 +1052,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1239,9 +1221,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1259,9 +1238,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1279,9 +1255,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1299,9 +1272,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1319,9 +1289,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1339,9 +1306,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1429,9 +1393,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/acorn-typescript": {
|
"node_modules/@sveltejs/acorn-typescript": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
|
||||||
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
|
"integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -1620,9 +1584,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1640,9 +1601,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1660,9 +1618,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1680,9 +1635,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2096,9 +2048,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.7.1",
|
"version": "5.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||||
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
|
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2421,9 +2373,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esrap": {
|
"node_modules/esrap": {
|
||||||
"version": "2.2.5",
|
"version": "2.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz",
|
||||||
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
|
"integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2899,9 +2851,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2923,9 +2872,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2947,9 +2893,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2971,9 +2914,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3640,24 +3580,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.55.5",
|
"version": "5.56.0",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.0.tgz",
|
||||||
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
|
"integrity": "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.10",
|
||||||
"@types/estree": "^1.0.5",
|
"@types/estree": "^1.0.5",
|
||||||
"@types/trusted-types": "^2.0.7",
|
"@types/trusted-types": "^2.0.7",
|
||||||
"acorn": "^8.12.1",
|
"acorn": "^8.12.1",
|
||||||
"aria-query": "5.3.1",
|
"aria-query": "5.3.1",
|
||||||
"axobject-query": "^4.1.0",
|
"axobject-query": "^4.1.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devalue": "^5.6.4",
|
"devalue": "^5.8.1",
|
||||||
"esm-env": "^1.2.1",
|
"esm-env": "^1.2.1",
|
||||||
"esrap": "^2.2.4",
|
"esrap": "^2.2.9",
|
||||||
"is-reference": "^3.0.3",
|
"is-reference": "^3.0.3",
|
||||||
"locate-character": "^3.0.0",
|
"locate-character": "^3.0.0",
|
||||||
"magic-string": "^0.30.11",
|
"magic-string": "^0.30.11",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"lucide-svelte": "^1.0.1",
|
"lucide-svelte": "^1.0.1",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.56.0",
|
||||||
"svelte-eslint-parser": "^1.4.1",
|
"svelte-eslint-parser": "^1.4.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "4.3.0",
|
"tailwindcss": "4.3.0",
|
||||||
|
|||||||
@@ -692,17 +692,25 @@
|
|||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Description>{at("stats_label_paid_subs", {}, "")}</Card.Description>
|
<Card.Description>{at("stats_label_active_subs", {}, "")}</Card.Description>
|
||||||
<Card.Title>{users.paid_subscriptions ?? 0}</Card.Title>
|
<Card.Title>{users.active_subscriptions ?? 0}</Card.Title>
|
||||||
<Card.Action>
|
<Card.Action>
|
||||||
<Badge variant="outline">{users.trial_users ?? 0}</Badge>
|
<Badge variant="outline"
|
||||||
|
>{users.total_users
|
||||||
|
? Math.round(((users.active_subscriptions ?? 0) / (users.total_users || 1)) * 100)
|
||||||
|
: 0}%</Badge
|
||||||
|
>
|
||||||
</Card.Action>
|
</Card.Action>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Footer class="admin-cn-card-footer--stack">
|
<Card.Footer class="admin-cn-card-footer--stack">
|
||||||
<div class="admin-cn-card-footer-primary">
|
<div class="admin-cn-card-footer-primary">
|
||||||
{at("stats_trend_trials", { count: users.trial_users ?? 0 }, "")}
|
{at("stats_trend_paid", { count: users.paid_subscriptions ?? 0 }, "")}
|
||||||
|
· {at("stats_trend_free", { count: users.free_subscription_users ?? 0 }, "")}
|
||||||
|
· {at("stats_trend_trials", { count: users.trial_users ?? 0 }, "")}
|
||||||
|
</div>
|
||||||
|
<div class="admin-cn-card-footer-muted">
|
||||||
|
{at("stats_card_active_subs_caption", {}, "")}
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-cn-card-footer-muted">{at("stats_card_paid_caption", {}, "")}</div>
|
|
||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|||||||
@@ -1231,7 +1231,17 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
currency_symbol: "RUB",
|
currency_symbol: "RUB",
|
||||||
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
|
users: {
|
||||||
|
total_users: 248,
|
||||||
|
active_today: 9,
|
||||||
|
active_subscriptions: 172,
|
||||||
|
paid_subscriptions: 141,
|
||||||
|
trial_users: 8,
|
||||||
|
free_subscription_users: 23,
|
||||||
|
inactive_users: 76,
|
||||||
|
banned_users: 3,
|
||||||
|
referral_users: 34,
|
||||||
|
},
|
||||||
financial: {
|
financial: {
|
||||||
today_revenue: 1240,
|
today_revenue: 1240,
|
||||||
week_revenue: 15800,
|
week_revenue: 15800,
|
||||||
|
|||||||
+16
-6
@@ -347,8 +347,8 @@
|
|||||||
"admin_user_no_logs": "📜 User has no actions",
|
"admin_user_no_logs": "📜 User has no actions",
|
||||||
"admin_user_logs_error": "❌ Error loading user actions",
|
"admin_user_logs_error": "❌ Error loading user actions",
|
||||||
"admin_direct_message_signature": "\n\n---\n💬 Message from administrator",
|
"admin_direct_message_signature": "\n\n---\n💬 Message from administrator",
|
||||||
"inline_user_stats_message": "👥 <b>User Statistics</b>\n\n📊 Total: <b>{total}</b>\n📈 Active today: <b>{active_today}</b>\n💳 With paid subscription: <b>{paid}</b>\n🆓 On trial period: <b>{trial}</b>\n😴 Inactive: <b>{inactive}</b>\n🚫 Banned: <b>{banned}</b>\n🎁 Via referral program: <b>{referral}</b>",
|
"inline_user_stats_message": "👥 <b>User Statistics</b>\n\n📊 Total: <b>{total}</b>\n📈 Active today: <b>{active_today}</b>\n📡 With active subscription: <b>{active}</b>\n💳 With paid subscription: <b>{paid}</b>\n🧪 With trial subscription: <b>{trial}</b>\n🎁 With free subscription: <b>{free}</b>\n😴 Without active subscription: <b>{inactive}</b>\n🚫 Banned: <b>{banned}</b>\n🎁 Via referral program: <b>{referral}</b>",
|
||||||
"inline_user_stats_description": "Total: {total}, Paid: {active}",
|
"inline_user_stats_description": "Total: {total}, subscribed: {active}",
|
||||||
"inline_admin_user_stats_title": "👥 User Statistics",
|
"inline_admin_user_stats_title": "👥 User Statistics",
|
||||||
"inline_financial_stats_message": "💰 <b>Financial Statistics</b>\n\n📅 Today: <b>{today:.2f} RUB</b>\n ({today_count} payments)\n📅 Week: <b>{week:.2f} RUB</b>\n📅 Month: <b>{month:.2f} RUB</b>\n🏆 All time: <b>{all_time:.2f} RUB</b>",
|
"inline_financial_stats_message": "💰 <b>Financial Statistics</b>\n\n📅 Today: <b>{today:.2f} RUB</b>\n ({today_count} payments)\n📅 Week: <b>{week:.2f} RUB</b>\n📅 Month: <b>{month:.2f} RUB</b>\n🏆 All time: <b>{all_time:.2f} RUB</b>",
|
||||||
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
||||||
@@ -488,9 +488,11 @@
|
|||||||
"inline_referral_description": "Share referral link to get bonuses",
|
"inline_referral_description": "Share referral link to get bonuses",
|
||||||
"inline_financial_description": "Today: {today} RUB",
|
"inline_financial_description": "Today: {today} RUB",
|
||||||
"inline_system_description": "🟢 Online: {online}, 📊 Active: {active}",
|
"inline_system_description": "🟢 Online: {online}, 📊 Active: {active}",
|
||||||
|
"admin_user_stats_active_subscription_label": "With active subscription",
|
||||||
"admin_user_stats_paid_subs_label": "With paid subscription",
|
"admin_user_stats_paid_subs_label": "With paid subscription",
|
||||||
"admin_user_stats_trial_label": "On trial period",
|
"admin_user_stats_trial_label": "With trial subscription",
|
||||||
"admin_user_stats_inactive_label": "Inactive",
|
"admin_user_stats_free_subscription_label": "With free subscription",
|
||||||
|
"admin_user_stats_inactive_label": "Without active subscription",
|
||||||
"admin_user_stats_banned_label": "Banned",
|
"admin_user_stats_banned_label": "Banned",
|
||||||
"admin_user_stats_referral_label": "Attracted via referral program",
|
"admin_user_stats_referral_label": "Attracted via referral program",
|
||||||
"admin_financial_today_label": "Today",
|
"admin_financial_today_label": "Today",
|
||||||
@@ -1098,8 +1100,13 @@
|
|||||||
"admin_stats_error": "Failed to load statistics: {error}",
|
"admin_stats_error": "Failed to load statistics: {error}",
|
||||||
"admin_stats_label_users": "Users",
|
"admin_stats_label_users": "Users",
|
||||||
"admin_stats_trend_banned": "Banned: {count}",
|
"admin_stats_trend_banned": "Banned: {count}",
|
||||||
"admin_stats_label_paid_subs": "Paid Subscriptions",
|
"admin_stats_label_active_subs": "Active users",
|
||||||
|
"admin_stats_label_paid_subs": "Paid users",
|
||||||
|
"admin_stats_label_trial_users": "With trial subscription",
|
||||||
|
"admin_stats_label_free_users": "With free subscription",
|
||||||
|
"admin_stats_trend_paid": "Paid: {count}",
|
||||||
"admin_stats_trend_trials": "Trials: {count}",
|
"admin_stats_trend_trials": "Trials: {count}",
|
||||||
|
"admin_stats_trend_free": "Free: {count}",
|
||||||
"admin_stats_label_today_rev": "Today's Revenue",
|
"admin_stats_label_today_rev": "Today's Revenue",
|
||||||
"admin_stats_trend_payments": "{count} payments",
|
"admin_stats_trend_payments": "{count} payments",
|
||||||
"admin_stats_label_week": "This Week",
|
"admin_stats_label_week": "This Week",
|
||||||
@@ -1145,7 +1152,10 @@
|
|||||||
"admin_stats_revenue_avg_check": "Average ticket today: {value}",
|
"admin_stats_revenue_avg_check": "Average ticket today: {value}",
|
||||||
"admin_stats_revenue_avg_none": "No successful payments today",
|
"admin_stats_revenue_avg_none": "No successful payments today",
|
||||||
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
|
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
|
||||||
"admin_stats_card_paid_caption": "Trials shown in the badge",
|
"admin_stats_card_active_subs_caption": "Paid, trial, and free access combined",
|
||||||
|
"admin_stats_card_paid_caption": "Paid users separated from trial and free access",
|
||||||
|
"admin_stats_card_trial_caption": "Only real trial subscriptions",
|
||||||
|
"admin_stats_card_free_caption": "Manual extensions and bonus access",
|
||||||
"admin_stats_card_inactive_caption": "Share of all users is in the badge",
|
"admin_stats_card_inactive_caption": "Share of all users is in the badge",
|
||||||
"admin_stats_revenue_last_7_calendar": "Last 7 calendar days total",
|
"admin_stats_revenue_last_7_calendar": "Last 7 calendar days total",
|
||||||
"admin_stats_revenue_prev_7_calendar": "Previous 7 days",
|
"admin_stats_revenue_prev_7_calendar": "Previous 7 days",
|
||||||
|
|||||||
+17
-7
@@ -347,8 +347,8 @@
|
|||||||
"admin_user_no_logs": "📜 У пользователя нет действий",
|
"admin_user_no_logs": "📜 У пользователя нет действий",
|
||||||
"admin_user_logs_error": "❌ Ошибка загрузки действий пользователя",
|
"admin_user_logs_error": "❌ Ошибка загрузки действий пользователя",
|
||||||
"admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора",
|
"admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора",
|
||||||
"inline_user_stats_message": "📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n📊 Всего: <b>{total}</b>\n📈 Активных сегодня: <b>{active_today}</b>\n💳 С платной подпиской: <b>{paid}</b>\n🆓 На пробном периоде: <b>{trial}</b>\n😴 Неактивных: <b>{inactive}</b>\n🚫 Заблокированных: <b>{banned}</b>\n🎁 Привлечено по реферальной программе: <b>{referral}</b>",
|
"inline_user_stats_message": "📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n📊 Всего: <b>{total}</b>\n📈 Активных сегодня: <b>{active_today}</b>\n📡 С активной подпиской: <b>{active}</b>\n💳 С платной подпиской: <b>{paid}</b>\n🧪 С пробной подпиской: <b>{trial}</b>\n🎁 С бесплатной подпиской: <b>{free}</b>\n😴 Без активной подписки: <b>{inactive}</b>\n🚫 Заблокированных: <b>{banned}</b>\n🎁 Привлечено по реферальной программе: <b>{referral}</b>",
|
||||||
"inline_user_stats_description": "Всего: {total}, Платных: {active}",
|
"inline_user_stats_description": "Всего: {total}, с подпиской: {active}",
|
||||||
"inline_admin_user_stats_title": "👥 Статистика пользователей",
|
"inline_admin_user_stats_title": "👥 Статистика пользователей",
|
||||||
"inline_financial_stats_message": "💰 <b>Финансовая статистика</b>\n\n📅 За сегодня: <b>{today:.2f} RUB</b>\n ({today_count} платежей)\n📅 За неделю: <b>{week:.2f} RUB</b>\n📅 За месяц: <b>{month:.2f} RUB</b>\n🏆 За все время: <b>{all_time:.2f} RUB</b>",
|
"inline_financial_stats_message": "💰 <b>Финансовая статистика</b>\n\n📅 За сегодня: <b>{today:.2f} RUB</b>\n ({today_count} платежей)\n📅 За неделю: <b>{week:.2f} RUB</b>\n📅 За месяц: <b>{month:.2f} RUB</b>\n🏆 За все время: <b>{all_time:.2f} RUB</b>",
|
||||||
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
||||||
@@ -488,9 +488,11 @@
|
|||||||
"inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
|
"inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
|
||||||
"inline_financial_description": "Сегодня: {today} RUB",
|
"inline_financial_description": "Сегодня: {today} RUB",
|
||||||
"inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}",
|
"inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}",
|
||||||
|
"admin_user_stats_active_subscription_label": "С активной подпиской",
|
||||||
"admin_user_stats_paid_subs_label": "С платной подпиской",
|
"admin_user_stats_paid_subs_label": "С платной подпиской",
|
||||||
"admin_user_stats_trial_label": "На пробном периоде",
|
"admin_user_stats_trial_label": "С пробной подпиской",
|
||||||
"admin_user_stats_inactive_label": "Неактивных",
|
"admin_user_stats_free_subscription_label": "С бесплатной подпиской",
|
||||||
|
"admin_user_stats_inactive_label": "Без активной подписки",
|
||||||
"admin_user_stats_banned_label": "Заблокированных",
|
"admin_user_stats_banned_label": "Заблокированных",
|
||||||
"admin_user_stats_referral_label": "Привлечено по реферальной программе",
|
"admin_user_stats_referral_label": "Привлечено по реферальной программе",
|
||||||
"admin_financial_today_label": "За сегодня",
|
"admin_financial_today_label": "За сегодня",
|
||||||
@@ -1098,8 +1100,13 @@
|
|||||||
"admin_stats_error": "Не удалось загрузить статистику: {error}",
|
"admin_stats_error": "Не удалось загрузить статистику: {error}",
|
||||||
"admin_stats_label_users": "Пользователи",
|
"admin_stats_label_users": "Пользователи",
|
||||||
"admin_stats_trend_banned": "В бане: {count}",
|
"admin_stats_trend_banned": "В бане: {count}",
|
||||||
"admin_stats_label_paid_subs": "Платные подписки",
|
"admin_stats_label_active_subs": "Активные пользователи",
|
||||||
"admin_stats_trend_trials": "Триалы: {count}",
|
"admin_stats_label_paid_subs": "Платные пользователи",
|
||||||
|
"admin_stats_label_trial_users": "С пробной подпиской",
|
||||||
|
"admin_stats_label_free_users": "С бесплатной подпиской",
|
||||||
|
"admin_stats_trend_paid": "Платные: {count}",
|
||||||
|
"admin_stats_trend_trials": "Пробные: {count}",
|
||||||
|
"admin_stats_trend_free": "Бесплатные: {count}",
|
||||||
"admin_stats_label_today_rev": "Доход за день",
|
"admin_stats_label_today_rev": "Доход за день",
|
||||||
"admin_stats_trend_payments": "{count} платежей",
|
"admin_stats_trend_payments": "{count} платежей",
|
||||||
"admin_stats_label_week": "За неделю",
|
"admin_stats_label_week": "За неделю",
|
||||||
@@ -1145,7 +1152,10 @@
|
|||||||
"admin_stats_revenue_avg_check": "Средний чек сегодня: {value}",
|
"admin_stats_revenue_avg_check": "Средний чек сегодня: {value}",
|
||||||
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
|
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
|
||||||
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
|
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
|
||||||
"admin_stats_card_paid_caption": "Триалы — отдельно в бейдже",
|
"admin_stats_card_active_subs_caption": "Платные, пробные и бесплатные вместе",
|
||||||
|
"admin_stats_card_paid_caption": "Платные отдельно от пробных и бесплатных",
|
||||||
|
"admin_stats_card_trial_caption": "Только реальные пробные подписки",
|
||||||
|
"admin_stats_card_free_caption": "Ручные начисления и бонусный доступ",
|
||||||
"admin_stats_card_inactive_caption": "Доля от всех пользователей — в бейдже",
|
"admin_stats_card_inactive_caption": "Доля от всех пользователей — в бейдже",
|
||||||
"admin_stats_revenue_last_7_calendar": "Сумма за последние 7 дн. (календарь)",
|
"admin_stats_revenue_last_7_calendar": "Сумма за последние 7 дн. (календарь)",
|
||||||
"admin_stats_revenue_prev_7_calendar": "Предыдущие 7 дн.",
|
"admin_stats_revenue_prev_7_calendar": "Предыдущие 7 дн.",
|
||||||
|
|||||||
@@ -96,9 +96,11 @@ class AdminDbStatsCacheTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"total_users": 10,
|
"total_users": 10,
|
||||||
"banned_users": 1,
|
"banned_users": 1,
|
||||||
"active_today": 2,
|
"active_today": 2,
|
||||||
|
"active_subscriptions": 8,
|
||||||
"paid_subscriptions": 7,
|
"paid_subscriptions": 7,
|
||||||
"trial_users": 1,
|
"trial_users": 1,
|
||||||
"inactive_users": 1,
|
"free_subscription_users": 0,
|
||||||
|
"inactive_users": 2,
|
||||||
"referral_users": 3,
|
"referral_users": 3,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import asyncio
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from bot.services.telegram_notifications import telegram_notifications_need_prompt
|
from bot.services import telegram_notifications as module
|
||||||
|
from bot.services.telegram_notifications import (
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
telegram_notifications_need_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _user(status: str):
|
def _user(status: str):
|
||||||
@@ -22,3 +28,39 @@ def test_telegram_notifications_prompt_requires_linked_telegram():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert telegram_notifications_need_prompt(user) is False
|
assert telegram_notifications_need_prompt(user) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_telegram_notifications_uses_silent_chat_check(monkeypatch):
|
||||||
|
user = SimpleNamespace(
|
||||||
|
user_id=42,
|
||||||
|
telegram_id=123,
|
||||||
|
telegram_notifications_status="unknown",
|
||||||
|
)
|
||||||
|
bot = SimpleNamespace(
|
||||||
|
get_chat=AsyncMock(return_value=SimpleNamespace(id=123)),
|
||||||
|
send_message=AsyncMock(),
|
||||||
|
)
|
||||||
|
recorded = []
|
||||||
|
|
||||||
|
async def fake_mark_status(session, user_id, status, *, telegram_id=None, checked_at=None):
|
||||||
|
recorded.append((session, user_id, status, telegram_id, checked_at))
|
||||||
|
return user
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "mark_telegram_notifications_status", fake_mark_status)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
module.probe_telegram_notifications(
|
||||||
|
session="session",
|
||||||
|
bot=bot,
|
||||||
|
settings=SimpleNamespace(DEFAULT_LANGUAGE="ru"),
|
||||||
|
i18n=None,
|
||||||
|
user=user,
|
||||||
|
bot_username="preview_bot",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
bot.get_chat.assert_awaited_once_with(123)
|
||||||
|
bot.send_message.assert_not_called()
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["status"] == TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
|
assert recorded == [("session", 42, TELEGRAM_NOTIFICATIONS_ENABLED, 123, None)]
|
||||||
|
|||||||
@@ -27,6 +27,49 @@ class FakeResult:
|
|||||||
return self._scalar_value
|
return self._scalar_value
|
||||||
return [self._scalar_value]
|
return [self._scalar_value]
|
||||||
|
|
||||||
|
def one(self):
|
||||||
|
return self._scalar_value
|
||||||
|
|
||||||
|
|
||||||
|
class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_get_enhanced_user_statistics_splits_paid_trial_and_free_users(self):
|
||||||
|
session = SimpleNamespace(
|
||||||
|
execute=AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
FakeResult((10, 1, 2, 3)),
|
||||||
|
FakeResult((8, 4, 2, 2)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = await user_dal.get_enhanced_user_statistics(session)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
stats,
|
||||||
|
{
|
||||||
|
"total_users": 10,
|
||||||
|
"banned_users": 1,
|
||||||
|
"active_today": 2,
|
||||||
|
"active_subscriptions": 8,
|
||||||
|
"paid_subscriptions": 4,
|
||||||
|
"trial_users": 2,
|
||||||
|
"free_subscription_users": 2,
|
||||||
|
"inactive_users": 2,
|
||||||
|
"referral_users": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = session.execute.await_args_list[1].args[0]
|
||||||
|
sql = str(
|
||||||
|
stmt.compile(
|
||||||
|
dialect=postgresql.dialect(),
|
||||||
|
compile_kwargs={"literal_binds": True},
|
||||||
|
)
|
||||||
|
).upper()
|
||||||
|
self.assertIn("GROUP BY SUBSCRIPTIONS.USER_ID", sql)
|
||||||
|
self.assertIn("SUBSCRIPTIONS.PROVIDER", sql)
|
||||||
|
self.assertIn("TRIAL", sql)
|
||||||
|
|
||||||
|
|
||||||
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
|
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user