Compare commits

..

5 Commits

Author SHA1 Message Date
edd55cd2fd fix: context-aware back button for cross-module admin navigation
All checks were successful
CI / ruff (push) Successful in 16s
CI / pytest (push) Successful in 2h40m11s
CI / validate (push) Successful in 32s
CI / dependency-scanning (push) Successful in 37s
CI / docs (push) Successful in 49s
CI / deploy (push) Successful in 1m10s
The tenancy merchant detail page now reads an optional ?back= query
parameter to determine the back button destination. Falls back to
/admin/merchants when no param is present (default behavior preserved).

The loyalty merchant detail "View Merchant" link now passes
?back=/admin/loyalty/merchants/{id} so clicking back from the tenancy
page returns to the loyalty context instead of the merchants list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:37:28 +01:00
f3344b2859 fix(loyalty): open View Merchant link in new tab to preserve loyalty context
The "View Merchant" quick action on the loyalty merchant detail hub
links to the tenancy merchant page, which has its own back button going
to /admin/merchants. Opening in a new tab prevents losing the loyalty
context. Added external link icon as visual indicator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:21:39 +01:00
1107de989b fix(loyalty): pass merchant name server-side to admin on-behalf headers
Load merchant name in page route handlers and pass to template context.
Headers now render as "Cards: Fashion Group S.A." using server-side
Jinja2 variables instead of relying on JS program.merchant_name which
was not in the ProgramResponse schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:15:05 +01:00
a423bcf03e fix(loyalty): show merchant name in admin on-behalf page headers
Switch admin sub-pages (cards, pins, transactions) from page_header_flex
to detail_page_header with merchant name context, matching the settings
page pattern. Headers now show "MerchantName — Cards" with back button
to merchant detail hub.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:03:18 +01:00
661547f6cf docs: update deployment docs for CI timeouts, build info, and prod safety
- hetzner-server-setup: runner timeout 3h, shutdown_timeout 300s,
  deploy.sh now writes .build-info and uses explicit -f flag
- gitea: document unit-only CI tests and xdist incompatibility
- docker: add build info section, document volume mount approach

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:00:35 +01:00
10 changed files with 66 additions and 49 deletions

View File

@@ -15,9 +15,15 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.modules.tenancy.models import Merchant, User
from app.templates_config import templates
def _get_merchant_name(db: Session, merchant_id: int) -> str:
"""Look up merchant name for page header context."""
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
return merchant.name if merchant else ""
router = APIRouter()
# Route configuration for module route discovery
@@ -139,9 +145,10 @@ async def admin_loyalty_merchant_cards(
Render merchant loyalty cards list page.
Shows all loyalty cards for a specific merchant.
"""
merchant_name = _get_merchant_name(db, merchant_id)
return templates.TemplateResponse(
"loyalty/admin/merchant-cards.html",
get_admin_context(request, db, current_user, merchant_id=merchant_id),
get_admin_context(request, db, current_user, merchant_id=merchant_id, merchant_name=merchant_name),
)
@@ -161,9 +168,10 @@ async def admin_loyalty_merchant_card_detail(
Render merchant loyalty card detail page.
Shows detailed info for a specific loyalty card.
"""
merchant_name = _get_merchant_name(db, merchant_id)
return templates.TemplateResponse(
"loyalty/admin/merchant-card-detail.html",
get_admin_context(request, db, current_user, merchant_id=merchant_id, card_id=card_id),
get_admin_context(request, db, current_user, merchant_id=merchant_id, card_id=card_id, merchant_name=merchant_name),
)
@@ -182,9 +190,10 @@ async def admin_loyalty_merchant_transactions(
Render merchant loyalty transactions list page.
Shows all loyalty transactions for a specific merchant.
"""
merchant_name = _get_merchant_name(db, merchant_id)
return templates.TemplateResponse(
"loyalty/admin/merchant-transactions.html",
get_admin_context(request, db, current_user, merchant_id=merchant_id),
get_admin_context(request, db, current_user, merchant_id=merchant_id, merchant_name=merchant_name),
)
@@ -203,9 +212,10 @@ async def admin_loyalty_merchant_pins(
Render merchant staff PINs page (read-only).
Shows all staff PINs for a specific merchant.
"""
merchant_name = _get_merchant_name(db, merchant_id)
return templates.TemplateResponse(
"loyalty/admin/merchant-pins.html",
get_admin_context(request, db, current_user, merchant_id=merchant_id),
get_admin_context(request, db, current_user, merchant_id=merchant_id, merchant_name=merchant_name),
)

View File

@@ -8,8 +8,8 @@
{% block alpine_data %}adminMerchantCardDetail(){% endblock %}
{% block content %}
{% call detail_page_header("card?.card_number || 'Card Detail'", '/admin/loyalty/merchants/' + merchant_id|string + '/cards', subtitle_show='card') %}
<span x-text="card ? (card.customer_name || card.customer_email || '') : ''"></span>
{% call detail_page_header("(card?.customer_name || '" + _('loyalty.admin.merchant_card_detail.title') + "')", '/admin/loyalty/merchants/' ~ merchant_id ~ '/cards', subtitle_show='card') %}
<span x-text="card?.card_number || ''"></span>
{% endcall %}
{{ loading_state(_('loyalty.admin.merchant_card_detail.loading')) }}

View File

@@ -1,6 +1,6 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-cards.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/headers.html' import detail_page_header, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.admin.merchant_cards.title') }}{% endblock %}
@@ -8,15 +8,8 @@
{% block alpine_data %}adminMerchantCards(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.admin.merchant_cards.title'), subtitle=_('loyalty.admin.merchant_cards.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
<a href="/admin/loyalty/merchants/{{ merchant_id }}"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.common.back') }}
</a>
</div>
{% call detail_page_header("'" + _('loyalty.admin.merchant_cards.title') + ": " + (merchant_name or '') + "'", '/admin/loyalty/merchants/' ~ merchant_id, subtitle_show='program') %}
{{ _('loyalty.admin.merchant_cards.subtitle') }}
{% endcall %}
{{ loading_state(_('loyalty.admin.merchant_cards.loading')) }}

View File

@@ -41,7 +41,7 @@
{{ _('loyalty.admin.merchant_detail.admin_policy') }}
</a>
<a
:href="`/admin/merchants/${merchant?.id}`"
:href="`/admin/merchants/${merchant?.id}?back=/admin/loyalty/merchants/${merchantId}`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.admin.merchant_detail.view_merchant') }}

View File

@@ -1,6 +1,6 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-pins.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/headers.html' import detail_page_header, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.admin.merchant_pins.title') }}{% endblock %}
@@ -8,15 +8,8 @@
{% block alpine_data %}adminMerchantPins(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.admin.merchant_pins.title'), subtitle=_('loyalty.admin.merchant_pins.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadPins()', variant='secondary') }}
<a href="/admin/loyalty/merchants/{{ merchant_id }}"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.common.back') }}
</a>
</div>
{% call detail_page_header("'" + _('loyalty.admin.merchant_pins.title') + ": " + (merchant_name or '') + "'", '/admin/loyalty/merchants/' ~ merchant_id, subtitle_show='program') %}
{{ _('loyalty.admin.merchant_pins.subtitle') }}
{% endcall %}
{{ loading_state(_('loyalty.admin.merchant_pins.loading')) }}

View File

@@ -1,6 +1,6 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-transactions.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/headers.html' import detail_page_header, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.admin.merchant_transactions.title') }}{% endblock %}
@@ -8,15 +8,8 @@
{% block alpine_data %}adminMerchantTransactions(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.admin.merchant_transactions.title'), subtitle=_('loyalty.admin.merchant_transactions.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadTransactions()', variant='secondary') }}
<a href="/admin/loyalty/merchants/{{ merchant_id }}"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.common.back') }}
</a>
</div>
{% call detail_page_header("'" + _('loyalty.admin.merchant_transactions.title') + ": " + (merchant_name or '') + "'", '/admin/loyalty/merchants/' ~ merchant_id, subtitle_show='program') %}
{{ _('loyalty.admin.merchant_transactions.subtitle') }}
{% endcall %}
{{ loading_state(_('loyalty.admin.merchant_transactions.loading')) }}

View File

@@ -11,7 +11,7 @@
{% block alpine_data %}adminMerchantDetail(){% endblock %}
{% block content %}
{% call detail_page_header("merchant?.name || 'Merchant Details'", '/admin/merchants', subtitle_show='merchant') %}
{% call detail_page_header("merchant?.name || 'Merchant Details'", request.query_params.get('back', '/admin/merchants'), subtitle_show='merchant') %}
ID: <span x-text="merchantId"></span>
<span class="text-gray-400 mx-2">|</span>
<span x-text="merchant?.store_count || 0"></span> store(s)

View File

@@ -331,6 +331,25 @@ docker compose -f docker-compose.prod.yml exec api python scripts/seed/init_prod
---
## Build Info
The deploy script writes a `.build-info` JSON file (commit SHA + deploy timestamp) before rebuilding containers. This file is mounted as a read-only volume into the API container:
```yaml
# In docker-compose.yml
volumes:
- ./.build-info:/app/.build-info:ro
```
The app reads it via `app/core/build_info.py` and exposes it in:
- **`/health` endpoint** — `commit` and `deployed_at` fields
- **Admin sidebar** — version, commit, and deploy timestamp
In local development (where `.build-info` doesn't exist), the app falls back to `git rev-parse` for the commit SHA.
---
## Daily Operations
### View Logs

View File

@@ -252,9 +252,13 @@ The `scripts/deploy.sh` script handles the full deploy lifecycle:
1. Stash local changes (preserves `.env` and other server-side edits)
2. Pull latest code (`--ff-only`)
3. Pop stash to restore local changes
4. Rebuild and restart Docker containers (`docker compose --profile full up -d --build`)
5. Run database migrations (`alembic upgrade heads`)
6. Health check `http://localhost:8001/health` with retries
4. Write `.build-info` (commit SHA + deploy timestamp)
5. Rebuild and restart Docker containers (`docker compose -f docker-compose.yml --profile full up -d --build`)
6. Run database migrations (`alembic upgrade heads`)
7. Health check `http://localhost:8001/health` with retries
!!! note "CI test configuration"
Only unit tests run in CI (`-m "unit"` with `timeout-minutes: 150`). Integration tests are run locally via `make test`. The CAX11 runner (2 vCPU ARM, 4GB) takes ~2.5h for 2,484 unit tests. `pytest-xdist` parallel execution is not compatible with the shared database session test fixtures.
See [Hetzner Server Setup — Step 16](hetzner-server-setup.md#step-16-continuous-deployment) for the full setup guide including SSH key generation and Gitea secrets configuration.

View File

@@ -1081,7 +1081,8 @@ Generate a config file to override defaults (notably the 3h job timeout which ca
```bash
cd ~/gitea-runner
./act_runner generate-config > config.yaml
sed -i 's/timeout: 3h/timeout: 1h/' config.yaml
sed -i 's/timeout: 3h/timeout: 3h/' config.yaml
sed -i 's/shutdown_timeout: 0s/shutdown_timeout: 300s/' config.yaml
sudo systemctl restart gitea-runner
```
@@ -1089,12 +1090,12 @@ Key settings in `config.yaml`:
| Setting | Default | Recommended | Why |
|---|---|---|---|
| `runner.timeout` | 3h | 1h | Prevents silent failures — tests take ~25min, so 1h is generous |
| `runner.shutdown_timeout` | 0s | 0s | OK as-is |
| `runner.timeout` | 3h | 3h | 2,484 unit tests take ~2.5h on the CAX11 (2 vCPU ARM). Keep the default |
| `runner.shutdown_timeout` | 0s | 300s | Wait for running jobs to finish on restart — `0s` kills jobs immediately |
| `runner.fetch_timeout` | 5s | 5s | OK as-is |
!!! tip "CI also has per-job and per-test timeouts"
The `.gitea/workflows/ci.yml` sets `timeout-minutes: 45` on the pytest job and `--timeout=120` per individual test. These work together with the runner timeout to catch different failure modes.
The `.gitea/workflows/ci.yml` sets `timeout-minutes: 150` on the pytest job and `--timeout=120` per individual test. These work together with the runner timeout to catch different failure modes.
### 15.2 Swap for CI Stability
@@ -1160,9 +1161,13 @@ The deploy script lives at `scripts/deploy.sh` in the repository. It:
1. Stashes local changes (preserves `.env`)
2. Pulls latest code (`--ff-only`)
3. Pops stash to restore local changes
4. Rebuilds and restarts Docker containers (`docker compose --profile full up -d --build`)
5. Runs database migrations (`alembic upgrade heads`)
6. Health checks `http://localhost:8001/health` with 12 retries (60s total)
4. Writes `.build-info` (commit SHA + deploy timestamp)
5. Rebuilds and restarts Docker containers (`docker compose -f docker-compose.yml --profile full up -d --build`)
6. Runs database migrations (`alembic upgrade heads`)
7. Health checks `http://localhost:8001/health` with 12 retries (60s total)
!!! warning "Always use `-f docker-compose.yml` on the production server"
The explicit `-f` flag prevents `docker-compose.override.yml` (which exposes db/redis ports for local dev) from being loaded. This flag must never be removed from `deploy.sh`, and any manual `docker compose` commands on the server must also include it. See [Docker Deployment — Dev vs Prod](docker.md#dev-vs-prod-compose-architecture) for details.
Exit codes: `0` success, `1` git pull failed, `2` docker compose failed, `3` migration failed, `4` health check failed.