feat(monitoring): add Redis exporter + Sentry docs to deployment guide
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 47m30s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add redis-exporter container to docker-compose (oliver006/redis_exporter, 32MB)
- Add Redis scrape target to Prometheus config
- Add 4 Redis alert rules: RedisDown, HighMemory, HighConnections, RejectedConnections
- Document Step 19b (Sentry Error Tracking) in Hetzner deployment guide
- Document Step 19c (Redis Monitoring) in Hetzner deployment guide
- Update resource budget and port reference tables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 23:30:18 +01:00
parent ce822af883
commit 35d1559162
54 changed files with 664 additions and 343 deletions

View File

@@ -100,28 +100,28 @@ def apply_theme_preset(
db: Session = Depends(get_db),
):
"""Apply a theme preset to store."""
# Validate preset name
if preset_name not in get_available_presets():
raise HTTPException(
status_code=400,
detail=f"Invalid preset. Available: {get_available_presets()}"
)
# Get or create store theme
theme = db.query(StoreTheme).filter(
StoreTheme.store_id == store.id
).first()
if not theme:
theme = StoreTheme(store_id=store.id)
db.add(theme)
# Apply preset
apply_preset(theme, preset_name)
db.commit()
db.refresh(theme)
return {
"message": f"Theme preset '{preset_name}' applied successfully",
"theme": theme.to_dict()
@@ -135,12 +135,12 @@ def apply_theme_preset(
def get_theme_presets():
"""Get all available theme presets with previews."""
from app.core.theme_presets import get_available_presets, get_preset_preview
presets = []
for preset_name in get_available_presets():
preview = get_preset_preview(preset_name)
presets.append(preview)
return {"presets": presets}
# Returns:
@@ -302,16 +302,16 @@ from models.database.store_theme import StoreTheme
def test_all_presets():
"""Test applying all presets"""
presets = get_available_presets()
for preset_name in presets:
theme = StoreTheme(store_id=999) # Test store
apply_preset(theme, preset_name)
assert theme.theme_name == preset_name
assert theme.colors is not None
assert theme.font_family_heading is not None
assert theme.is_active == True
print(f"✅ {preset_name} preset OK")
test_all_presets()

View File

@@ -430,4 +430,3 @@ Which modules should be mandatory (cannot be disabled)?
| `app/services/menu_service.py` | MODIFY | Filter by enabled modules |
| `app/platforms/oms/config.py` | MODIFY | Add enabled_modules |
| `app/platforms/loyalty/config.py` | MODIFY | Add enabled_modules |

View File

@@ -82,16 +82,16 @@ Update your `admin_service.py` to log actions:
from app.services.admin_audit_service import admin_audit_service
class AdminService:
def create_store_with_owner(
self, db: Session, store_data: StoreCreate
) -> Tuple[Store, User, str]:
"""Create store with owner user account."""
# ... existing code ...
store, owner_user, temp_password = # ... your creation logic
# LOG THE ACTION
admin_audit_service.log_action(
db=db,
@@ -105,19 +105,19 @@ class AdminService:
"owner_email": owner_user.email
}
)
return store, owner_user, temp_password
def toggle_store_status(
self, db: Session, store_id: int, admin_user_id: int
) -> Tuple[Store, str]:
"""Toggle store status with audit logging."""
store = self._get_store_by_id_or_raise(db, store_id)
old_status = store.is_active
# ... toggle logic ...
# LOG THE ACTION
admin_audit_service.log_action(
db=db,
@@ -130,7 +130,7 @@ class AdminService:
"new_status": "active" if store.is_active else "inactive"
}
)
return store, message
```
@@ -154,9 +154,9 @@ def create_store_with_owner(
store_data=store_data,
admin_user_id=current_admin.id # Pass admin ID for audit logging
)
# Audit log is automatically created inside the service
return {
**StoreResponse.model_validate(store).model_dump(),
"owner_email": owner_user.email,
@@ -205,9 +205,9 @@ def delete_store(
# Get request metadata
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
message = admin_service.delete_store(db, store_id)
# Log with full context
admin_audit_service.log_action(
db=db,
@@ -219,7 +219,7 @@ def delete_store(
user_agent=user_agent,
details={"confirm": True}
)
return {"message": message}
```
@@ -300,15 +300,15 @@ from app.services.admin_settings_service import admin_settings_service
def can_create_store(db: Session) -> bool:
"""Check if platform allows creating more stores."""
max_stores = admin_settings_service.get_setting_value(
db=db,
key="max_stores_allowed",
default=1000
)
current_count = db.query(Store).count()
return current_count < max_stores
@@ -331,7 +331,7 @@ def is_maintenance_mode(db: Session) -> bool:
<!-- templates/admin/audit_logs.html -->
<div x-data="auditLogs()" x-init="loadLogs()">
<h1>Audit Logs</h1>
<!-- Filters -->
<div class="filters">
<select x-model="filters.action" @change="loadLogs()">
@@ -341,7 +341,7 @@ def is_maintenance_mode(db: Session) -> bool:
<option value="toggle_store_status">Toggle Status</option>
<option value="update_setting">Update Setting</option>
</select>
<select x-model="filters.target_type" @change="loadLogs()">
<option value="">All Targets</option>
<option value="store">Stores</option>
@@ -349,7 +349,7 @@ def is_maintenance_mode(db: Session) -> bool:
<option value="setting">Settings</option>
</select>
</div>
<!-- Logs Table -->
<table class="data-table">
<thead>
@@ -379,7 +379,7 @@ def is_maintenance_mode(db: Session) -> bool:
</template>
</tbody>
</table>
<!-- Pagination -->
<div class="pagination">
<button @click="previousPage()" :disabled="skip === 0">Previous</button>
@@ -400,45 +400,45 @@ function auditLogs() {
skip: 0,
limit: 50,
total: 0,
async loadLogs() {
const params = new URLSearchParams({
skip: this.skip,
limit: this.limit,
...this.filters
});
const response = await apiClient.get(`/api/v1/admin/audit/logs?${params}`);
this.logs = response.logs;
this.total = response.total;
},
showDetails(log) {
// Show modal with full details
console.log('Details:', log.details);
},
formatDate(date) {
return new Date(date).toLocaleString();
},
get currentPage() {
return Math.floor(this.skip / this.limit) + 1;
},
get totalPages() {
return Math.ceil(this.total / this.limit);
},
get hasMore() {
return this.skip + this.limit < this.total;
},
nextPage() {
this.skip += this.limit;
this.loadLogs();
},
previousPage() {
this.skip = Math.max(0, this.skip - this.limit);
this.loadLogs();
@@ -454,23 +454,23 @@ function auditLogs() {
<!-- templates/admin/settings.html -->
<div x-data="platformSettings()" x-init="loadSettings()">
<h1>Platform Settings</h1>
<!-- Category Tabs -->
<div class="tabs">
<button
<button
@click="selectedCategory = 'system'"
:class="{'active': selectedCategory === 'system'}"
>System</button>
<button
<button
@click="selectedCategory = 'security'"
:class="{'active': selectedCategory === 'security'}"
>Security</button>
<button
<button
@click="selectedCategory = 'payments'"
:class="{'active': selectedCategory === 'payments'}"
>Payments</button>
</div>
<!-- Settings List -->
<div class="settings-list">
<template x-for="setting in filteredSettings" :key="setting.id">
@@ -480,10 +480,10 @@ function auditLogs() {
<span class="badge" x-text="setting.value_type"></span>
</div>
<p class="setting-description" x-text="setting.description"></p>
<div class="setting-value">
<input
type="text"
<input
type="text"
:value="setting.value"
@change="updateSetting(setting.key, $event.target.value)"
>
@@ -492,7 +492,7 @@ function auditLogs() {
</div>
</template>
</div>
<!-- Add New Setting -->
<button @click="showAddModal = true" class="btn-primary">
Add New Setting
@@ -505,17 +505,17 @@ function platformSettings() {
settings: [],
selectedCategory: 'system',
showAddModal: false,
async loadSettings() {
const response = await apiClient.get('/api/v1/admin/settings');
this.settings = response.settings;
},
get filteredSettings() {
if (!this.selectedCategory) return this.settings;
return this.settings.filter(s => s.category === this.selectedCategory);
},
async updateSetting(key, newValue) {
try {
await apiClient.put(`/api/v1/admin/settings/${key}`, {
@@ -527,7 +527,7 @@ function platformSettings() {
showNotification('Failed to update setting', 'error');
}
},
formatDate(date) {
return new Date(date).toLocaleString();
}
@@ -557,7 +557,7 @@ def test_log_admin_action(db_session, test_admin_user):
target_id="123",
details={"store_code": "TEST"}
)
assert log is not None
assert log.action == "create_store"
assert log.target_type == "store"
@@ -574,12 +574,12 @@ def test_query_audit_logs(db_session, test_admin_user):
target_type="test",
target_id=str(i)
)
# Query logs
from models.schemas.admin import AdminAuditLogFilters
filters = AdminAuditLogFilters(limit=10)
logs = admin_audit_service.get_audit_logs(db_session, filters)
assert len(logs) == 5
```
@@ -590,20 +590,20 @@ def test_query_audit_logs(db_session, test_admin_user):
def test_create_setting(db_session, test_admin_user):
"""Test creating platform setting."""
from models.schemas.admin import AdminSettingCreate
setting_data = AdminSettingCreate(
key="test_setting",
value="test_value",
value_type="string",
category="test"
)
result = admin_settings_service.create_setting(
db=db_session,
setting_data=setting_data,
admin_user_id=test_admin_user.id
)
assert result.key == "test_setting"
assert result.value == "test_value"
@@ -617,7 +617,7 @@ def test_get_setting_value_with_type_conversion(db_session):
category="system"
)
admin_settings_service.create_setting(db_session, setting_data, 1)
# Get value (should be converted to int)
value = admin_settings_service.get_setting_value(db_session, "max_stores")
assert isinstance(value, int)
@@ -630,10 +630,10 @@ def test_get_setting_value_with_type_conversion(db_session):
You now have a complete admin infrastructure with:
**Audit Logging**: Track all admin actions for compliance
**Platform Settings**: Manage global configuration
**Notifications**: System alerts for admins (structure ready)
**Platform Alerts**: Health monitoring (structure ready)
**Audit Logging**: Track all admin actions for compliance
**Platform Settings**: Manage global configuration
**Notifications**: System alerts for admins (structure ready)
**Platform Alerts**: Health monitoring (structure ready)
**Session Tracking**: Monitor admin logins (structure ready)
### Next Steps
@@ -646,4 +646,4 @@ You now have a complete admin infrastructure with:
6. **Implement notification service** (notifications.py stubs)
7. **Add monitoring** for platform alerts
These additions make your platform production-ready with full compliance and monitoring capabilities!
These additions make your platform production-ready with full compliance and monitoring capabilities!

View File

@@ -1101,9 +1101,10 @@ Prometheus + Grafana monitoring stack with host and container metrics.
| grafana | 192 MB | Dashboards (SQLite backend) |
| node-exporter | 64 MB | Host CPU/RAM/disk metrics |
| cadvisor | 128 MB | Per-container resource metrics |
| **Total new** | **640 MB** | |
| redis-exporter | 32 MB | Redis memory, connections, command stats |
| **Total new** | **672 MB** | |
Existing stack ~1.8 GB + 640 MB new = ~2.4 GB. Leaves ~1.6 GB for OS. If too tight, live-upgrade to CAX21 (8 GB/80 GB, ~7.50 EUR/mo) via **Cloud Console > Server > Rescale** (~2 min restart).
Existing stack ~1.8 GB + 672 MB new = ~2.5 GB. Leaves ~1.6 GB for OS. If too tight, live-upgrade to CAX21 (8 GB/80 GB, ~7.50 EUR/mo) via **Cloud Console > Server > Rescale** (~2 min restart).
### 18.1 DNS Record
@@ -1372,6 +1373,264 @@ This is the professional approach — emails come from the client's domain with
---
## Step 19b: Sentry Error Tracking
Application-level error tracking with [Sentry](https://sentry.io). While Prometheus monitors infrastructure metrics (CPU, memory, HTTP error rates), Sentry captures **individual exceptions** with full stack traces, request context, and breadcrumbs — making it possible to debug production errors without SSH access.
!!! info "How Sentry fits into the monitoring stack"
```
┌──────────────────────────────────────────────────────────────┐
│ Observability Stack │
├──────────────────┬──────────────────┬────────────────────────┤
│ Prometheus │ Grafana │ Sentry │
│ Infrastructure │ Dashboards │ Application errors │
│ metrics & alerts │ & visualization │ & performance traces │
├──────────────────┴──────────────────┴────────────────────────┤
│ Prometheus: "API 5xx rate is 3%" │
│ Sentry: "TypeError in /api/v1/orders/checkout line 42 │
│ request_id=abc123, user_id=7, store=acme" │
└──────────────────────────────────────────────────────────────┘
```
### What's Already Wired
The codebase already initializes Sentry in two places — you just need to provide the DSN:
| Component | File | Integrations |
|---|---|---|
| FastAPI (API server) | `main.py:42-58` | `FastApiIntegration`, `SqlalchemyIntegration` |
| Celery (background workers) | `app/core/celery_config.py:31-39` | `CeleryIntegration` |
Both read from the same `SENTRY_DSN` environment variable. When unset, Sentry is silently skipped.
### 19b.1 Create Sentry Project
1. Sign up at [sentry.io](https://sentry.io) (free Developer plan: **5K errors/month**, 1 user)
2. Create a new project:
- **Platform**: Python → FastAPI
- **Project name**: `orion` (or `rewardflow`)
- **Team**: default
3. Copy the **DSN** from the project settings — it looks like:
```
https://abc123def456@o123456.ingest.de.sentry.io/7891011
```
!!! tip "Sentry pricing"
| Plan | Errors/month | Cost | Notes |
|---|---|---|---|
| **Developer** (free) | 5,000 | $0 | 1 user, 30-day retention |
| **Team** | 50,000 | $26/mo | Unlimited users, 90-day retention |
| **Business** | 50,000 | $80/mo | SSO, audit logs, 90-day retention |
The free plan is sufficient for launch. Upgrade to Team if you exceed 5K errors/month or need multiple team members.
### 19b.2 Configure Environment
Add to `~/apps/orion/.env` on the server:
```bash
# Sentry Error Tracking
SENTRY_DSN=https://your-key@o123456.ingest.de.sentry.io/your-project-id
SENTRY_ENVIRONMENT=production
SENTRY_TRACES_SAMPLE_RATE=0.1
```
| Variable | Default | Description |
|---|---|---|
| `SENTRY_DSN` | `None` (disabled) | Project DSN from Sentry dashboard |
| `SENTRY_ENVIRONMENT` | `development` | Tags errors by environment (`production`, `staging`) |
| `SENTRY_TRACES_SAMPLE_RATE` | `0.1` | Fraction of requests traced for performance (0.1 = 10%) |
!!! warning "Traces sample rate"
`0.1` (10%) is a good starting point. At high traffic, lower to `0.01` (1%) to stay within the free plan's span limits. For initial launch with low traffic, you can temporarily set `1.0` (100%) for full visibility.
### 19b.3 Deploy
Restart the API and Celery containers to pick up the new env vars:
```bash
cd ~/apps/orion
docker compose --profile full restart api celery-worker celery-beat
```
Check the API logs to confirm Sentry initialized:
```bash
docker compose --profile full logs api --tail 20 | grep -i sentry
```
You should see:
```
Sentry initialized for environment: production
```
### 19b.4 Verify
**1. Trigger a test error** by hitting the API with a request that will fail:
```bash
curl -s https://api.wizard.lu/api/v1/nonexistent-endpoint-sentry-test
```
**2. Check Sentry dashboard:**
- Go to [sentry.io](https://sentry.io) → your project → **Issues**
- You should see a `404 Not Found` or similar error appear within seconds
- Click into it to see the full stack trace, request headers, and breadcrumbs
**3. Verify Celery integration** — check that the Celery worker also reports to Sentry:
```bash
docker compose --profile full logs celery-worker --tail 10 | grep -i sentry
```
### 19b.5 Sentry Features to Configure
After verifying the basic setup, configure these in the Sentry web UI:
**Alerts (Sentry → Alerts → Create Alert):**
| Alert | Condition | Action |
|---|---|---|
| New issue spike | >10 events in 1 hour | Email notification |
| First seen error | Any new issue | Email notification |
| Unresolved high-volume | >50 events in 24h | Email notification |
**Release tracking** — Sentry automatically tags errors with the release version via `release=f"orion@{settings.version}"` in `main.py`. This lets you see which deploy introduced a bug.
**Source maps** (optional, post-launch) — if you want JS errors from the admin frontend, add the Sentry browser SDK to your base template. Not needed for launch since most errors will be server-side.
### 19b.6 What Sentry Captures
With the current integration, Sentry automatically captures:
| Data | Source | Example |
|---|---|---|
| Python exceptions | FastAPI + Celery | `TypeError`, `ValidationError`, unhandled 500s |
| Request context | `FastApiIntegration` | URL, method, headers, query params, user IP |
| DB query breadcrumbs | `SqlalchemyIntegration` | SQL queries leading up to the error |
| Celery task failures | `CeleryIntegration` | Task name, args, retry count, worker hostname |
| User info | `send_default_pii=True` | User email and IP (if authenticated) |
| Performance traces | `traces_sample_rate` | End-to-end request timing, DB query duration |
!!! note "Privacy"
`send_default_pii=True` is set in both `main.py` and `celery_config.py`. This sends user emails and IP addresses to Sentry for debugging context. If GDPR compliance requires stricter data handling, set this to `False` and configure Sentry's [Data Scrubbing](https://docs.sentry.io/security-legal-pii/scrubbing/) rules.
---
## Step 19c: Redis Monitoring (Redis Exporter)
Add direct Redis monitoring to Prometheus. Without this, Redis can die silently — Celery tasks stop processing and emails stop sending, but no alert fires.
### Why Not Just cAdvisor?
cAdvisor tells you "the Redis container is running." The Redis exporter tells you "Redis is running, responding to commands, using 45MB memory, has 3 clients connected, and command latency is 0.2ms." It also catches scenarios where the container is running but Redis itself is unhealthy (maxmemory reached, connection limit hit).
### Resource Impact
| Container | RAM | CPU | Image Size |
|---|---|---|---|
| redis-exporter | ~5 MB | negligible | ~10 MB |
### 19c.1 Docker Compose
The `redis-exporter` service has been added to `docker-compose.yml`:
```yaml
redis-exporter:
image: oliver006/redis_exporter:latest
restart: always
profiles:
- full
ports:
- "127.0.0.1:9121:9121"
environment:
REDIS_ADDR: redis://redis:6379
depends_on:
redis:
condition: service_healthy
mem_limit: 32m
networks:
- backend
- monitoring
```
It joins both `backend` (to reach Redis) and `monitoring` (so Prometheus can scrape it).
### 19c.2 Prometheus Scrape Target
Added to `monitoring/prometheus.yml`:
```yaml
- job_name: "redis"
static_configs:
- targets: ["redis-exporter:9121"]
labels:
service: "redis"
```
### 19c.3 Alert Rules
Four Redis-specific alerts added to `monitoring/prometheus/alert.rules.yml`:
| Alert | Condition | Severity | What It Means |
|---|---|---|---|
| `RedisDown` | `redis_up == 0` for 1m | critical | Redis is unreachable — all background tasks stalled |
| `RedisHighMemoryUsage` | >80% of maxmemory for 5m | warning | Queue backlog or memory leak |
| `RedisHighConnectionCount` | >50 clients for 5m | warning | Possible connection leak |
| `RedisRejectedConnections` | Any rejected in 5m | critical | Redis is refusing new connections |
### 19c.4 Deploy
```bash
cd ~/apps/orion
git pull
docker compose --profile full up -d
```
Verify the exporter is running and Prometheus can scrape it:
```bash
# Exporter health
curl -s http://localhost:9121/health
# Redis metrics flowing
curl -s http://localhost:9121/metrics | grep redis_up
# Prometheus target status (should show "redis" as UP)
curl -s http://localhost:9090/api/v1/targets | python3 -m json.tool | grep -A2 '"redis"'
```
### 19c.5 Grafana Dashboard
Import the community Redis dashboard:
1. Open `https://grafana.wizard.lu`
2. **Dashboards** → **Import** → ID `763` → Select Prometheus datasource
3. You'll see: memory usage, connected clients, commands/sec, hit rate, key count
### 19c.6 Verification
```bash
# Redis is being monitored
curl -s http://localhost:9121/metrics | grep redis_up
# redis_up 1
# Memory usage
curl -s http://localhost:9121/metrics | grep redis_memory_used_bytes
# redis_memory_used_bytes 1.234e+07 (≈12 MB)
# Connected clients
curl -s http://localhost:9121/metrics | grep redis_connected_clients
# redis_connected_clients 4 (API + celery-worker + celery-beat + flower)
# Alert rules loaded
curl -s http://localhost:9090/api/v1/rules | python3 -m json.tool | grep -i redis
```
---
## Step 20: Security Hardening
Docker network segmentation, fail2ban configuration, and automatic security updates.
@@ -2010,6 +2269,7 @@ After Google Wallet is verified working:
| Grafana | 3000 | 3001 (localhost) | `grafana.wizard.lu` |
| Node Exporter | 9100 | 9100 (localhost) | (internal only) |
| cAdvisor | 8080 | 8080 (localhost) | (internal only) |
| Redis Exporter | 9121 | 9121 (localhost) | (internal only) |
| Alertmanager | 9093 | 9093 (localhost) | (internal only) |
| Caddy | — | 80, 443 | (reverse proxy) |

View File

@@ -49,7 +49,7 @@ apiClient.interceptors.response.use(
};
throw apiError;
}
// Handle network errors
if (error.code === 'ECONNABORTED') {
throw {
@@ -58,7 +58,7 @@ apiClient.interceptors.response.use(
statusCode: 408
};
}
throw {
errorCode: 'NETWORK_ERROR',
message: 'Network error. Please check your connection.',
@@ -105,7 +105,7 @@ class ApiClient {
if (error.errorCode) {
throw error; // Re-throw API errors
}
// Handle network errors
throw {
errorCode: 'NETWORK_ERROR',
@@ -130,28 +130,28 @@ export const ERROR_MESSAGES = {
INVALID_CREDENTIALS: 'Invalid username or password. Please try again.',
TOKEN_EXPIRED: 'Your session has expired. Please log in again.',
USER_NOT_ACTIVE: 'Your account has been deactivated. Contact support.',
// MarketplaceProduct errors
PRODUCT_NOT_FOUND: 'MarketplaceProduct not found. It may have been removed.',
PRODUCT_ALREADY_EXISTS: 'A product with this ID already exists.',
INVALID_PRODUCT_DATA: 'Please check the product information and try again.',
// Inventory errors
INSUFFICIENT_INVENTORY: 'Not enough inventory available for this operation.',
INVENTORY_NOT_FOUND: 'No inventory information found for this product.',
NEGATIVE_INVENTORY_NOT_ALLOWED: 'Inventory quantity cannot be negative.',
// Shop errors
SHOP_NOT_FOUND: 'Shop not found or no longer available.',
UNAUTHORIZED_SHOP_ACCESS: 'You do not have permission to access this shop.',
SHOP_ALREADY_EXISTS: 'A shop with this code already exists.',
MAX_SHOPS_REACHED: 'You have reached the maximum number of shops allowed.',
// Import errors
IMPORT_JOB_NOT_FOUND: 'Import job not found.',
IMPORT_JOB_CANNOT_BE_CANCELLED: 'This import job cannot be cancelled at this time.',
MARKETPLACE_CONNECTION_FAILED: 'Failed to connect to marketplace. Please try again.',
// Generic fallbacks
VALIDATION_ERROR: 'Please check your input and try again.',
NETWORK_ERROR: 'Connection error. Please check your internet connection.',
@@ -179,7 +179,7 @@ export const useApiError = () => {
const handleApiCall = async (apiCall) => {
setIsLoading(true);
setError(null);
try {
const result = await apiCall();
setIsLoading(false);
@@ -233,7 +233,7 @@ const ProductForm = () => {
if (apiError.field) {
setFieldErrors({ [apiError.field]: apiError.message });
}
// Handle specific error codes
switch (apiError.errorCode) {
case 'PRODUCT_ALREADY_EXISTS':
@@ -312,7 +312,7 @@ class ErrorBoundary extends React.Component {
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
// Log to error reporting service
if (window.errorReporting) {
window.errorReporting.captureException(error, {
@@ -442,7 +442,7 @@ const handleInventoryUpdate = async (gtin, location, quantity) => {
switch (error.errorCode) {
case 'INSUFFICIENT_INVENTORY':
const { available_quantity, requested_quantity } = error.details;
notificationManager.notify('error',
notificationManager.notify('error',
`Cannot remove ${requested_quantity} items. Only ${available_quantity} available.`
);
break;
@@ -493,7 +493,7 @@ const ImportJobStatus = ({ jobId }) => {
switch (error.errorCode) {
case 'IMPORT_JOB_CANNOT_BE_CANCELLED':
const { current_status } = error.details;
notificationManager.notify('error',
notificationManager.notify('error',
`Cannot cancel job in ${current_status} status`
);
break;
@@ -549,12 +549,12 @@ export const logError = (error, context = {}) => {
try {
const existingErrors = JSON.parse(localStorage.getItem('apiErrors') || '[]');
existingErrors.push(errorData);
// Keep only last 50 errors
if (existingErrors.length > 50) {
existingErrors.splice(0, existingErrors.length - 50);
}
localStorage.setItem('apiErrors', JSON.stringify(existingErrors));
} catch (e) {
console.warn('Failed to store error locally:', e);

View File

@@ -292,7 +292,7 @@ Add this script **before** Alpine.js in your HTML pages:
<span>Options</span>
<span x-html="$icon('chevron-down', 'w-4 h-4 ml-2')"></span>
</button>
<div x-show="open" class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg">
<a href="#" class="flex items-center px-4 py-2 hover:bg-gray-100">
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
@@ -425,4 +425,4 @@ The icon system works in all modern browsers:
- [Heroicons Official Site](https://heroicons.com/)
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
- [Alpine.js Magic Properties](https://alpinejs.dev/magics/el)
- [Alpine.js Magic Properties](https://alpinejs.dev/magics/el)

View File

@@ -213,13 +213,13 @@ For complex changes that require data transformation:
def upgrade() -> None:
# Create new column
op.add_column('products', sa.Column('normalized_price', sa.Numeric(10, 2)))
# Migrate data
connection = op.get_bind()
connection.execute(
text("UPDATE products SET normalized_price = CAST(price AS NUMERIC) WHERE price ~ '^[0-9.]+$'")
)
# Make column non-nullable after data migration
op.alter_column('products', 'normalized_price', nullable=False)
@@ -329,7 +329,7 @@ def upgrade() -> None:
if context.get_x_argument(as_dictionary=True).get('dev_data', False):
# Add development sample data
pass
# Always apply schema changes
op.create_table(...)
```
@@ -345,20 +345,20 @@ For large data transformations, use batch processing:
```python
def upgrade() -> None:
connection = op.get_bind()
# Process in batches to avoid memory issues
batch_size = 1000
offset = 0
while True:
result = connection.execute(
text(f"SELECT id, old_field FROM products LIMIT {batch_size} OFFSET {offset}")
)
rows = result.fetchall()
if not rows:
break
for row in rows:
# Transform data
new_value = transform_function(row.old_field)
@@ -366,7 +366,7 @@ def upgrade() -> None:
text("UPDATE products SET new_field = :new_val WHERE id = :id"),
{"new_val": new_value, "id": row.id}
)
offset += batch_size
```

View File

@@ -45,7 +45,7 @@ C:\ProgramData\chocolatey\bin\make.exe %*
1. Open PyCharm Settings: **File → Settings** (Ctrl+Alt+S)
2. Navigate to: **Build, Execution, Deployment → Build Tools → Make**
3. Set the **Path to make executable** to: `[YOUR_PROJECT_PATH]\make-venv.bat`
For example: `E:\Letzshop-Import-v1\make-venv.bat`
4. Click **Apply** and **OK**
@@ -112,4 +112,4 @@ your-project/
├── Makefile
├── make-venv.bat
└── requirements.txt
```
```

View File

@@ -11,11 +11,11 @@ Git is an open-source, distributed version control system that helps you manage
## 🛠️ Create a Git Repository
1. **Sign in to DSM** using an account with administrative privileges.
2. Go to:
**Control Panel > Terminal & SNMP > Terminal**
2. Go to:
**Control Panel > Terminal & SNMP > Terminal**
→ Enable **SSH service**.
3. Go to:
**Control Panel > Shared Folder**
3. Go to:
**Control Panel > Shared Folder**
→ Create a shared folder for Git repositories.
4. On your computer, access Synology NAS via SSH:
@@ -63,7 +63,7 @@ Adding new repository on NAS DS223J
> chown -R git-boulaht1:users my-repo.git
> cd my-repo.git/
```
---
### ✅ **Steps to Push Local Git Repo to Synology NAS**
@@ -110,9 +110,9 @@ and then Permission and then tick boxes apply to sub folder and files
(git-boulaht1 should be read write on the folder)
> ⚠️ **Note:**
> Do **not** perform the above commands with root permissions.
> Git Server no longer supports `git-shell` commands due to security concerns.
> ⚠️ **Note:**
> Do **not** perform the above commands with root permissions.
> Git Server no longer supports `git-shell` commands due to security concerns.
> For `git-shell` access, consider using container-based Git services.
---
@@ -149,4 +149,4 @@ and then Permission and then tick boxes apply to sub folder and files
cd /volume1/mysharefolder/myrepo1
```
---
---

View File

@@ -191,15 +191,15 @@ Component Structure:
return {
// ✅ CRITICAL: Inherit base layout state
...data(),
// ✅ CRITICAL: Set page identifier
currentPage: 'dashboard',
// Page-specific state
loading: false,
error: null,
stats: [],
// Initialization
async init() {
// Guard against multiple initialization
@@ -208,10 +208,10 @@ Component Structure:
return;
}
window._dashboardInitialized = true;
await this.loadStats();
},
// Data loading
async loadStats() {
this.loading = true;
@@ -294,22 +294,22 @@ init-alpine.js provides:
// Theme state
dark: localStorage.getItem('theme') === 'dark',
toggleTheme() { /* ... */ },
// Side menu state
isSideMenuOpen: false,
toggleSideMenu() { /* ... */ },
closeSideMenu() { /* ... */ },
// Profile menu state
isProfileMenuOpen: false,
toggleProfileMenu() { /* ... */ },
closeProfileMenu() { /* ... */ },
// Notifications menu state
isNotificationsMenuOpen: false,
toggleNotificationsMenu() { /* ... */ },
closeNotificationsMenu() { /* ... */ },
// Page identifier (override in each page)
currentPage: ''
};
@@ -320,7 +320,7 @@ Your page inherits ALL of this:
return {
...data(), // ← Spreads all base functionality
currentPage: 'stores', // ← Override page identifier
// Your page-specific state
stores: [],
loading: false
@@ -446,10 +446,10 @@ Pattern:
dashLog.warn('Already initialized, skipping...');
return; // Exit early
}
// Set flag BEFORE async operations
window._dashboardInitialized = true;
// Safe to proceed
await this.loadData();
}
@@ -485,7 +485,7 @@ Pre-configured Loggers:
Usage:
// Use pre-configured logger
const dashLog = window.LogConfig.loggers.dashboard;
dashLog.info('Dashboard loading...');
dashLog.error('Failed to load stats', error);
dashLog.debug('Stats data:', statsData);
@@ -497,13 +497,13 @@ Advanced Features:
dashLog.info('Fetching stats...');
dashLog.info('Fetching activity...');
dashLog.groupEnd();
// API call logging
window.LogConfig.logApiCall('GET', url, data, 'response');
// Performance logging
window.LogConfig.logPerformance('Load Stats', duration);
// Error logging
window.LogConfig.logError(error, 'Load Stats');
@@ -527,16 +527,16 @@ CRITICAL: Always use lowercase 'apiClient'
Usage:
const data = await apiClient.get('/api/v1/admin/stores');
await apiClient.post('/api/v1/admin/stores', {
name: 'New Store',
code: 'NEWSTORE'
});
await apiClient.put('/api/v1/admin/stores/123', {
name: 'Updated Name'
});
await apiClient.delete('/api/v1/admin/stores/123');
Features:

View File

@@ -60,7 +60,7 @@ app/
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
[Page Name]
</h2>
<!-- Action Buttons -->
<div class="flex items-center space-x-3">
<button
@@ -72,7 +72,7 @@ app/
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
<button
@click="openCreateModal()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
@@ -94,7 +94,7 @@ app/
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- ERROR STATE -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div x-show="error && !loading"
<div x-show="error && !loading"
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start dark:bg-red-900/20 dark:border-red-800 dark:text-red-200">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
@@ -120,7 +120,7 @@ app/
/>
</label>
</div>
<!-- Status Filter -->
<div>
<label class="block text-sm">
@@ -136,7 +136,7 @@ app/
</select>
</label>
</div>
<!-- Sort -->
<div>
<label class="block text-sm">
@@ -190,15 +190,15 @@ app/
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="item.name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400"
<p class="text-xs text-gray-600 dark:text-gray-400"
x-text="item.description"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="item.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
:class="item.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'"
x-text="item.is_active ? 'Active' : 'Inactive'"></span>
</td>
@@ -234,12 +234,12 @@ app/
</tbody>
</table>
</div>
<!-- Pagination -->
<div x-show="pagination.totalPages > 1"
class="px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
Showing
Showing
<span x-text="pagination.from"></span>
-
<span x-text="pagination.to"></span>
@@ -261,20 +261,20 @@ app/
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
</li>
<template x-for="page in paginationRange" :key="page">
<li>
<button
@click="goToPage(page)"
:class="page === pagination.currentPage
? 'text-white bg-purple-600'
:class="page === pagination.currentPage
? 'text-white bg-purple-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'"
class="px-3 py-1 rounded-md focus:outline-none focus:shadow-outline-purple"
x-text="page"
></button>
</li>
</template>
<li>
<button
@click="nextPage()"
@@ -299,9 +299,9 @@ app/
class="fixed inset-0 z-50 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.away="closeModal()"
@keydown.escape.window="closeModal()">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl">
<!-- Modal header -->
<div class="flex items-center justify-between pb-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
@@ -313,11 +313,11 @@ app/
<span x-html="$icon('x', 'w-6 h-6')"></span>
</button>
</div>
<!-- Modal body -->
<div class="mt-4">
<form @submit.prevent="saveItem()">
<!-- Name Field -->
<label class="block text-sm mb-4">
<span class="text-gray-700 dark:text-gray-400">Name</span>
@@ -327,11 +327,11 @@ app/
:class="errors.name ? 'border-red-600' : ''"
placeholder="Enter name"
/>
<span x-show="errors.name"
class="text-xs text-red-600 dark:text-red-400"
<span x-show="errors.name"
class="text-xs text-red-600 dark:text-red-400"
x-text="errors.name"></span>
</label>
<!-- Description Field -->
<label class="block text-sm mb-4">
<span class="text-gray-700 dark:text-gray-400">Description</span>
@@ -342,7 +342,7 @@ app/
placeholder="Enter description"
></textarea>
</label>
<!-- Status Field -->
<label class="block text-sm mb-4">
<span class="text-gray-700 dark:text-gray-400">Status</span>
@@ -354,7 +354,7 @@ app/
<option :value="false">Inactive</option>
</select>
</label>
<!-- Modal footer -->
<div class="flex flex-col items-center justify-end px-6 py-3 -mx-6 -mb-4 space-y-4 sm:space-y-0 sm:space-x-6 sm:flex-row bg-gray-50 dark:bg-gray-800">
<button
@@ -413,26 +413,26 @@ function admin[PageName]() {
// ✅ CRITICAL: INHERIT BASE LAYOUT
// ─────────────────────────────────────────────────────
...data(),
// ✅ CRITICAL: SET PAGE IDENTIFIER
currentPage: '[page-name]',
// ─────────────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────────────
// Loading states
loading: false,
saving: false,
// Error handling
error: null,
errors: {},
// Data
items: [],
currentItem: null,
// Pagination
pagination: {
currentPage: 1,
@@ -442,14 +442,14 @@ function admin[PageName]() {
from: 0,
to: 0
},
// Filters
filters: {
search: '',
status: '',
sortBy: 'created_at:desc'
},
// Modal
showModal: false,
modalMode: 'create', // 'create' or 'edit'
@@ -458,47 +458,47 @@ function admin[PageName]() {
description: '',
is_active: true
},
// ─────────────────────────────────────────────────────
// LIFECYCLE
// ─────────────────────────────────────────────────────
/**
* ✅ CRITICAL: Initialize component with guard
*/
async init() {
pageLog.info('=== [PAGE NAME] INITIALIZING ===');
// ✅ CRITICAL: Initialization guard
if (window._[pageName]Initialized) {
pageLog.warn('Already initialized, skipping...');
return;
}
window._[pageName]Initialized = true;
// Track performance
const startTime = performance.now();
try {
// Load initial data
await this.loadData();
// Log performance
const duration = performance.now() - startTime;
window.LogConfig.logPerformance('[Page Name] Init', duration);
pageLog.info('=== [PAGE NAME] INITIALIZATION COMPLETE ===');
} catch (error) {
window.LogConfig.logError(error, '[Page Name] Init');
this.error = 'Failed to initialize page';
}
},
// ─────────────────────────────────────────────────────
// DATA LOADING
// ─────────────────────────────────────────────────────
/**
* Load main data from API
*/
@@ -506,10 +506,10 @@ function admin[PageName]() {
pageLog.info('Loading data...');
this.loading = true;
this.error = null;
try {
const startTime = performance.now();
// Build query params
const params = new URLSearchParams({
page: this.pagination.currentPage,
@@ -518,18 +518,18 @@ function admin[PageName]() {
status: this.filters.status,
sort_by: this.filters.sortBy
});
const url = `/api/v1/admin/items?${params}`;
// Log API request
window.LogConfig.logApiCall('GET', url, null, 'request');
// ✅ CRITICAL: Use lowercase apiClient
const response = await apiClient.get(url);
// Log API response
window.LogConfig.logApiCall('GET', url, response, 'response');
// Update state
this.items = response.items || [];
this.pagination.total = response.total || 0;
@@ -541,17 +541,17 @@ function admin[PageName]() {
this.pagination.currentPage * this.pagination.perPage,
this.pagination.total
);
// Log performance
const duration = performance.now() - startTime;
window.LogConfig.logPerformance('Load Data', duration);
pageLog.info(`Data loaded successfully`, {
count: this.items.length,
duration: `${duration}ms`,
page: this.pagination.currentPage
});
} catch (error) {
window.LogConfig.logError(error, 'Load Data');
this.error = error.message || 'Failed to load data';
@@ -560,7 +560,7 @@ function admin[PageName]() {
this.loading = false;
}
},
/**
* Refresh data
*/
@@ -570,11 +570,11 @@ function admin[PageName]() {
await this.loadData();
Utils.showToast('Data refreshed', 'success');
},
// ─────────────────────────────────────────────────────
// FILTERS & SEARCH
// ─────────────────────────────────────────────────────
/**
* Apply filters and reload data
*/
@@ -583,7 +583,7 @@ function admin[PageName]() {
this.pagination.currentPage = 1; // Reset to first page
await this.loadData();
},
/**
* Reset filters to default
*/
@@ -595,11 +595,11 @@ function admin[PageName]() {
};
await this.applyFilters();
},
// ─────────────────────────────────────────────────────
// PAGINATION
// ─────────────────────────────────────────────────────
/**
* Navigate to specific page
*/
@@ -608,21 +608,21 @@ function admin[PageName]() {
this.pagination.currentPage = page;
await this.loadData();
},
/**
* Go to previous page
*/
async previousPage() {
await this.goToPage(this.pagination.currentPage - 1);
},
/**
* Go to next page
*/
async nextPage() {
await this.goToPage(this.pagination.currentPage + 1);
},
/**
* Get pagination range for display
*/
@@ -630,27 +630,27 @@ function admin[PageName]() {
const current = this.pagination.currentPage;
const total = this.pagination.totalPages;
const range = [];
// Show max 5 page numbers
let start = Math.max(1, current - 2);
let end = Math.min(total, start + 4);
// Adjust start if we're near the end
if (end - start < 4) {
start = Math.max(1, end - 4);
}
for (let i = start; i <= end; i++) {
range.push(i);
}
return range;
},
// ─────────────────────────────────────────────────────
// CRUD OPERATIONS
// ─────────────────────────────────────────────────────
/**
* Open create modal
*/
@@ -665,36 +665,36 @@ function admin[PageName]() {
this.errors = {};
this.showModal = true;
},
/**
* Open edit modal
*/
async editItem(itemId) {
pageLog.info('Opening edit modal for item:', itemId);
try {
this.modalMode = 'edit';
// Load item details
const url = `/api/v1/admin/items/${itemId}`;
window.LogConfig.logApiCall('GET', url, null, 'request');
const item = await apiClient.get(url);
window.LogConfig.logApiCall('GET', url, item, 'response');
// Populate form
this.formData = { ...item };
this.currentItem = item;
this.errors = {};
this.showModal = true;
} catch (error) {
window.LogConfig.logError(error, 'Edit Item');
Utils.showToast('Failed to load item', 'error');
}
},
/**
* View item details
*/
@@ -703,57 +703,57 @@ function admin[PageName]() {
// Navigate to detail page or open view modal
window.location.href = `/admin/items/${itemId}`;
},
/**
* Save item (create or update)
*/
async saveItem() {
pageLog.info('Saving item...');
// Validate form
if (!this.validateForm()) {
pageLog.warn('Form validation failed');
return;
}
this.saving = true;
this.errors = {};
try {
let response;
if (this.modalMode === 'create') {
// Create new item
const url = '/api/v1/admin/items';
window.LogConfig.logApiCall('POST', url, this.formData, 'request');
response = await apiClient.post(url, this.formData);
window.LogConfig.logApiCall('POST', url, response, 'response');
pageLog.info('Item created successfully');
Utils.showToast('Item created successfully', 'success');
} else {
// Update existing item
const url = `/api/v1/admin/items/${this.currentItem.id}`;
window.LogConfig.logApiCall('PUT', url, this.formData, 'request');
response = await apiClient.put(url, this.formData);
window.LogConfig.logApiCall('PUT', url, response, 'response');
pageLog.info('Item updated successfully');
Utils.showToast('Item updated successfully', 'success');
}
// Close modal and reload data
this.closeModal();
await this.loadData();
} catch (error) {
window.LogConfig.logError(error, 'Save Item');
// Handle validation errors
if (error.details && error.details.validation_errors) {
this.errors = error.details.validation_errors;
@@ -764,38 +764,38 @@ function admin[PageName]() {
this.saving = false;
}
},
/**
* Delete item
*/
async deleteItem(itemId) {
pageLog.info('Deleting item:', itemId);
// Confirm deletion
if (!confirm('Are you sure you want to delete this item?')) {
return;
}
try {
const url = `/api/v1/admin/items/${itemId}`;
window.LogConfig.logApiCall('DELETE', url, null, 'request');
await apiClient.delete(url);
window.LogConfig.logApiCall('DELETE', url, null, 'response');
pageLog.info('Item deleted successfully');
Utils.showToast('Item deleted successfully', 'success');
// Reload data
await this.loadData();
} catch (error) {
window.LogConfig.logError(error, 'Delete Item');
Utils.showToast('Failed to delete item', 'error');
}
},
/**
* Close modal
*/
@@ -805,30 +805,30 @@ function admin[PageName]() {
this.errors = {};
this.currentItem = null;
},
// ─────────────────────────────────────────────────────
// VALIDATION
// ─────────────────────────────────────────────────────
/**
* Validate form data
*/
validateForm() {
this.errors = {};
if (!this.formData.name || this.formData.name.trim() === '') {
this.errors.name = 'Name is required';
}
// Add more validation rules as needed
return Object.keys(this.errors).length === 0;
},
// ─────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────
/**
* Format date for display
*/
@@ -836,7 +836,7 @@ function admin[PageName]() {
if (!dateString) return '-';
return Utils.formatDate(dateString);
},
/**
* Truncate text
*/
@@ -943,7 +943,7 @@ async loadData() {
async init() {
if (window._dashboardInitialized) return;
window._dashboardInitialized = true;
await Promise.all([
this.loadStats(),
this.loadRecentActivity()
@@ -969,7 +969,7 @@ async loadRecentActivity() {
async init() {
if (window._editPageInitialized) return;
window._editPageInitialized = true;
const itemId = this.getItemIdFromUrl();
await this.loadItem(itemId);
}
@@ -982,12 +982,12 @@ async loadItem(id) {
async saveItem() {
if (!this.validateForm()) return;
await apiClient.put(
`/api/v1/admin/items/${this.item.id}`,
this.formData
);
Utils.showToast('Saved successfully', 'success');
}
```
@@ -1017,11 +1017,11 @@ async loadData() {
per_page: this.pagination.perPage,
...this.filters
});
const response = await apiClient.get(
`/api/v1/admin/items?${params}`
);
this.items = response.items;
this.pagination.total = response.total;
this.pagination.totalPages = Math.ceil(
@@ -1062,7 +1062,7 @@ async init() {
return;
}
window._myPageInitialized = true;
await this.loadData();
}
```
@@ -1345,13 +1345,13 @@ onStoreChange() {
// Quick fill from selected store's settings
quickFill(language) {
if (!this.selectedStore) return;
const urlMap = {
'fr': this.selectedStore.letzshop_csv_url_fr,
'en': this.selectedStore.letzshop_csv_url_en,
'de': this.selectedStore.letzshop_csv_url_de
};
if (urlMap[language]) {
this.importForm.csv_url = urlMap[language];
this.importForm.language = language;
@@ -1367,11 +1367,11 @@ async loadJobs() {
limit: this.limit,
created_by_me: 'true' // Only show jobs I triggered
});
const response = await apiClient.get(
`/admin/marketplace-import-jobs?${params.toString()}`
);
this.jobs = response.items || [];
}
```
@@ -1443,12 +1443,12 @@ filters: {
async applyFilters() {
this.page = 1; // Reset to first page
const params = new URLSearchParams({
page: this.page,
limit: this.limit
});
// Add filters
if (this.filters.store_id) {
params.append('store_id', this.filters.store_id);
@@ -1459,7 +1459,7 @@ async applyFilters() {
if (this.filters.created_by === 'me') {
params.append('created_by_me', 'true');
}
await this.loadJobs();
await this.loadStats(); // Update stats based on filters
}
@@ -1472,12 +1472,12 @@ async applyFilters() {
<select x-model="filters.store_id" @change="applyFilters()">
<option value="">All Stores</option>
<template x-for="store in stores" :key="store.id">
<option :value="store.id"
<option :value="store.id"
x-text="`${store.name} (${store.store_code})`">
</option>
</template>
</select>
<!-- Status Filter -->
<select x-model="filters.status" @change="applyFilters()">
<option value="">All Statuses</option>
@@ -1486,7 +1486,7 @@ async applyFilters() {
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
<!-- Creator Filter -->
<select x-model="filters.created_by" @change="applyFilters()">
<option value="">All Users</option>
@@ -1627,14 +1627,14 @@ See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
```html
<!-- Success -->
<div x-show="successMessage" x-transition
<div x-show="successMessage" x-transition
class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3')"></span>
<p class="font-semibold" x-text="successMessage"></p>
</div>
<!-- Error -->
<div x-show="error" x-transition
<div x-show="error" x-transition
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg dark:bg-red-900/20">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3')"></span>
<div>
@@ -1682,12 +1682,12 @@ See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
<span x-html="$icon('close', 'w-5 h-5')"></span>
</button>
</div>
<!-- Modal Content -->
<div x-show="selectedJob">
<!-- Job details grid -->
</div>
<!-- Modal Footer -->
<div class="flex justify-end mt-6">
<button @click="closeJobModal()" class="...">Close</button>
@@ -1704,4 +1704,3 @@ See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
- [Store Page Templates](../store/page-templates.md) - Store page patterns
- [Icons Guide](../../development/icons-guide.md) - Available icons
- [Admin Integration Guide](../../backend/admin-integration-guide.md) - Backend integration

View File

@@ -173,11 +173,11 @@ Example:
return {
loading: false,
stats: {},
async init() {
await this.loadStats();
},
async loadStats() {
this.loading = true;
try {

View File

@@ -55,7 +55,7 @@ app/
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
[Page Name]
</h2>
<!-- Action Buttons -->
<div class="flex items-center space-x-3">
<button
@@ -67,7 +67,7 @@ app/
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
<button
@click="openCreateModal()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
@@ -89,7 +89,7 @@ app/
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- ERROR STATE -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div x-show="error && !loading"
<div x-show="error && !loading"
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
@@ -115,7 +115,7 @@ app/
/>
</label>
</div>
<!-- Status Filter -->
<div>
<label class="block text-sm">
@@ -131,7 +131,7 @@ app/
</select>
</label>
</div>
<!-- Sort -->
<div>
<label class="block text-sm">
@@ -185,15 +185,15 @@ app/
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="item.name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400"
<p class="text-xs text-gray-600 dark:text-gray-400"
x-text="item.description"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="item.status === 'active'
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
:class="item.status === 'active'
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'"
x-text="item.status"></span>
</td>
@@ -229,7 +229,7 @@ app/
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
@@ -272,21 +272,21 @@ app/
<!-- MODALS (if needed) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Create/Edit Modal -->
<div x-show="showModal"
<div x-show="showModal"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="closeModal()">
<div class="relative w-full max-w-lg p-6 mx-auto bg-white rounded-lg shadow-xl dark:bg-gray-800">
<!-- Modal Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
x-text="modalTitle"></h3>
<button @click="closeModal()"
<button @click="closeModal()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-6 h-6')"></span>
</button>
</div>
<!-- Modal Body -->
<form @submit.prevent="saveItem()">
<div class="space-y-4">
@@ -303,7 +303,7 @@ app/
</label>
</div>
</div>
<!-- Modal Footer -->
<div class="flex justify-end mt-6 space-x-3">
<button
@@ -414,7 +414,7 @@ function store[PageName]() {
await this.loadData();
store[PageName]Log.info('[PageName] page initialized');
},
// ═══════════════════════════════════════════════════════════
// DATA LOADING
// ═══════════════════════════════════════════════════════════
@@ -453,11 +453,11 @@ function store[PageName]() {
this.loading = false;
}
},
async refresh() {
await this.loadData();
},
updatePagination(response) {
this.pagination = {
currentPage: response.page || 1,
@@ -470,7 +470,7 @@ function store[PageName]() {
hasNext: response.page < response.pages
};
},
// ═══════════════════════════════════════════════════════════
// FILTERING & PAGINATION
// ═══════════════════════════════════════════════════════════
@@ -478,21 +478,21 @@ function store[PageName]() {
this.pagination.currentPage = 1; // Reset to first page
await this.loadData();
},
async previousPage() {
if (this.pagination.hasPrevious) {
this.pagination.currentPage--;
await this.loadData();
}
},
async nextPage() {
if (this.pagination.hasNext) {
this.pagination.currentPage++;
await this.loadData();
}
},
// ═══════════════════════════════════════════════════════════
// CRUD OPERATIONS
// ═══════════════════════════════════════════════════════════
@@ -506,12 +506,12 @@ function store[PageName]() {
};
this.showModal = true;
},
async viewItem(id) {
// Navigate to detail page or open view modal
window.location.href = `/store/${this.storeCode}/[endpoint]/${id}`;
},
async editItem(id) {
try {
// Load item data
@@ -577,12 +577,12 @@ function store[PageName]() {
alert(error.message || 'Failed to delete item');
}
},
closeModal() {
this.showModal = false;
this.formData = {};
},
// ═══════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════
@@ -595,7 +595,7 @@ function store[PageName]() {
day: 'numeric'
});
},
formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
@@ -1024,7 +1024,7 @@ async startImport() {
this.error = 'Please enter a CSV URL';
return;
}
this.importing = true;
try {
const response = await apiClient.post('/store/marketplace/import', {
@@ -1032,7 +1032,7 @@ async startImport() {
marketplace: this.importForm.marketplace,
batch_size: this.importForm.batch_size
});
this.successMessage = `Import job #${response.job_id} started!`;
await this.loadJobs(); // Refresh list
} catch (error) {
@@ -1050,7 +1050,7 @@ startAutoRefresh() {
const hasActiveJobs = this.jobs.some(job =>
job.status === 'pending' || job.status === 'processing'
);
if (hasActiveJobs) {
await this.loadJobs();
}
@@ -1077,7 +1077,7 @@ quickFill(language) {
'en': this.storeSettings.letzshop_csv_url_en,
'de': this.storeSettings.letzshop_csv_url_de
};
if (urlMap[language]) {
this.importForm.csv_url = urlMap[language];
this.importForm.language = language;
@@ -1124,15 +1124,15 @@ formatDate(dateString) {
calculateDuration(job) {
if (!job.started_at) return 'Not started';
const start = new Date(job.started_at);
const end = job.completed_at ? new Date(job.completed_at) : new Date();
const durationMs = end - start;
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
@@ -1190,4 +1190,3 @@ calculateDuration(job) {
- [Marketplace Integration Guide](../../guides/marketplace-integration.md) - Complete marketplace system documentation
- [Admin Page Templates](../admin/page-templates.md) - Admin page patterns
- [Icons Guide](../../development/icons-guide.md) - Available icons

View File

@@ -134,8 +134,8 @@ if __name__ == "__main__":
curl -X POST "http://localhost:8000/register" \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"username": "newuser",
"email": "user@example.com",
"username": "newuser",
"password": "password123"
}'
```
@@ -158,7 +158,7 @@ curl -X POST "http://localhost:8000/register" \
curl -X POST "http://localhost:8000/login" \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"username": "admin",
"password": "admin123"
}'
```
@@ -202,8 +202,8 @@ curl -X POST "http://localhost:8000/products" \
-H "Authorization: Bearer YOUR_JWT_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"marketplace_product_id": "TEST001",
"title": "Test MarketplaceProduct",
"marketplace_product_id": "TEST001",
"title": "Test MarketplaceProduct",
"description": "A test product for demonstration",
"price": "19.99",
"brand": "Test Brand",
@@ -267,13 +267,13 @@ Authorization: Bearer <your_jwt_token>
def safe_api_call(token, endpoint, method="GET", data=None):
"""Make API call with proper error handling"""
headers = {"Authorization": f"Bearer {token}"}
try:
if method == "GET":
response = requests.get(f"{BASE_URL}{endpoint}", headers=headers)
elif method == "POST":
response = requests.post(f"{BASE_URL}{endpoint}", json=data, headers=headers)
if response.status_code == 401:
print("Authentication failed - token may be expired")
return None
@@ -285,7 +285,7 @@ def safe_api_call(token, endpoint, method="GET", data=None):
else:
print(f"API call failed: {response.status_code} - {response.text}")
return None
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None
@@ -340,7 +340,7 @@ class APIClient {
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
if (response.ok) {
const data = await response.json();
this.token = data.access_token;
@@ -361,17 +361,17 @@ class APIClient {
if (data) config.body = JSON.stringify(data);
const response = await fetch(`${this.baseURL}${endpoint}`, config);
if (response.status === 401) {
// Token expired, redirect to login
localStorage.removeItem('jwt_token');
window.location.href = '/login';
return;
}
return response.json();
}
}
```
This authentication system provides secure access control for all API endpoints while maintaining ease of use for developers integrating with the system.
This authentication system provides secure access control for all API endpoints while maintaining ease of use for developers integrating with the system.

View File

@@ -1,7 +1,7 @@
Analytics Dependency Status: ❌ NOT FIXED ─
The MetricsProvider pattern exists in contracts/metrics.py, but admin_stores.py still has hard imports:
The MetricsProvider pattern exists in contracts/metrics.py, but admin_stores.py still has hard imports:
File: app/modules/tenancy/routes/api/admin_stores.py
┌──────┬────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────┐
│ Line │ Import │ Used In │
@@ -914,4 +914,4 @@ Modular Architecture Analysis
2. Each module owns its metrics - no cross-module coupling
3. Optional modules truly optional - can be removed without breaking app
4. Easy to add new metrics - just implement protocol in your module
5. Both dashboards supported - store (per-store) and admin (platform-wide)
5. Both dashboards supported - store (per-store) and admin (platform-wide)