Compare commits
5 Commits
3015a490f9
...
edd55cd2fd
| Author | SHA1 | Date | |
|---|---|---|---|
| edd55cd2fd | |||
| f3344b2859 | |||
| 1107de989b | |||
| a423bcf03e | |||
| 661547f6cf |
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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')) }}
|
||||
|
||||
@@ -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')) }}
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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')) }}
|
||||
|
||||
@@ -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')) }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user