From 383b9867aceb2be29ea71d6fbe8f587f0b3596ce Mon Sep 17 00:00:00 2001 From: Ndevu12 Date: Tue, 24 Mar 2026 10:39:26 +0000 Subject: [PATCH 1/7] feat(i18n): add internationalization and localization Implement end-to-end i18n/l10n for the tenant Next.js app and Django REST API so users can work in their preferred language and receive translated API payloads where supported. Frontend: - Restructure routes under app/[locale] for auth and dashboard segments - Add locale JSON catalogs (ar, en, es, fr, rw, sw) and LanguageSwitcher - Wire Next.js middleware and config for locale detection and redirects - Sync tenant default language from the API (tenant-locale-sync) Backend: - Resolve request language (Accept-Language) and apply translatable representation for serializers/views across inventory, sales, procurement, tenants, invitations, reservation, and auth - Add language helpers, localized string serializers, schema/i18n support, translatable read/write mixins, and API middleware Tooling and docs: - Update .env.example, ARCHITECTURE, ENVIRONMENT, TASKS, and frontend README - Add Cursor rules; refresh agent/memory-bank notes where relevant - Add LanguageSwitcher component tests Closes #81 Made-with: Cursor --- .amazonq/rules/memory-bank/tech.md | 16 +- .cursor/rules/django-python.mdc | 28 + .cursor/rules/frontend-nextjs.mdc | 21 + .cursor/rules/the-inventory-core.mdc | 24 + .env.example | 2 +- .github/instrunctions/common.instructions.md | 4 + .gitignore | 1 + api/language.py | 105 + api/middleware.py | 7 +- api/mixins/__init__.py | 3 + api/mixins/translatable_read.py | 65 + api/pagination.py | 3 + api/schema_i18n.py | 19 + api/serializers/auth.py | 29 +- api/serializers/inventory.py | 80 +- api/serializers/localized_strings.py | 44 + api/serializers/procurement.py | 56 +- api/serializers/sales.py | 57 +- api/serializers/tenants.py | 15 +- .../translatable_representation.py | 27 + api/serializers/translatable_writable.py | 191 + api/views/auth.py | 15 +- api/views/inventory.py | 83 +- api/views/invitations.py | 10 +- api/views/procurement.py | 13 +- api/views/reservation.py | 37 +- api/views/sales.py | 13 +- api/views/tenants.py | 34 +- docs/ARCHITECTURE.md | 93 +- docs/ENVIRONMENT.md | 38 +- docs/TASKS.MD | 948 ++- frontend/README.md | 9 +- .../components/LanguageSwitcher.test.tsx | 75 + frontend/middleware.ts | 21 +- frontend/next.config.ts | 5 +- frontend/package.json | 19 +- frontend/public/locales/ar.json | 20 + frontend/public/locales/en.json | 20 + frontend/public/locales/es.json | 20 + frontend/public/locales/fr.json | 20 + frontend/public/locales/rw.json | 20 + frontend/public/locales/sw.json | 20 + frontend/src/app/(auth)/layout.tsx | 11 - .../(auth)/accept-invitation/[token]/page.tsx | 0 frontend/src/app/[locale]/(auth)/layout.tsx | 16 + .../src/app/{ => [locale]}/(auth)/loading.tsx | 0 .../app/{ => [locale]}/(auth)/login/page.tsx | 0 .../{ => [locale]}/(auth)/register/page.tsx | 0 .../(dashboard)/audit-log/page.tsx | 0 .../(dashboard)/bulk-operations/page.tsx | 0 .../(dashboard)/categories/page.tsx | 0 .../(dashboard)/cycle-counts/[id]/page.tsx | 0 .../(dashboard)/cycle-counts/new/page.tsx | 0 .../(dashboard)/cycle-counts/page.tsx | 0 .../app/{ => [locale]}/(dashboard)/error.tsx | 0 .../app/{ => [locale]}/(dashboard)/layout.tsx | 0 .../{ => [locale]}/(dashboard)/loading.tsx | 0 .../{ => [locale]}/(dashboard)/not-found.tsx | 2 +- .../app/{ => [locale]}/(dashboard)/page.tsx | 0 .../procurement/goods-received/new/page.tsx | 0 .../procurement/goods-received/page.tsx | 0 .../procurement/purchase-orders/[id]/page.tsx | 0 .../procurement/purchase-orders/new/page.tsx | 0 .../procurement/purchase-orders/page.tsx | 0 .../procurement/suppliers/[id]/edit/page.tsx | 0 .../procurement/suppliers/new/page.tsx | 0 .../procurement/suppliers/page.tsx | 0 .../(dashboard)/products/[id]/edit/page.tsx | 0 .../(dashboard)/products/[id]/page.tsx | 0 .../(dashboard)/products/new/page.tsx | 0 .../(dashboard)/products/page.tsx | 0 .../(dashboard)/reports/availability/page.tsx | 0 .../(dashboard)/reports/low-stock/page.tsx | 0 .../reports/movement-history/page.tsx | 0 .../(dashboard)/reports/overstock/page.tsx | 0 .../(dashboard)/reports/page.tsx | 0 .../reports/product-expiry/page.tsx | 0 .../reports/purchase-summary/page.tsx | 0 .../reports/sales-summary/page.tsx | 0 .../reports/stock-valuation/page.tsx | 0 .../(dashboard)/reports/traceability/page.tsx | 0 .../(dashboard)/reports/variances/page.tsx | 0 .../(dashboard)/reservations/new/page.tsx | 0 .../(dashboard)/reservations/page.tsx | 0 .../sales/customers/[id]/edit/page.tsx | 0 .../(dashboard)/sales/customers/new/page.tsx | 0 .../(dashboard)/sales/customers/page.tsx | 0 .../(dashboard)/sales/dispatches/new/page.tsx | 0 .../(dashboard)/sales/dispatches/page.tsx | 0 .../sales/sales-orders/[id]/page.tsx | 0 .../sales/sales-orders/new/page.tsx | 0 .../(dashboard)/sales/sales-orders/page.tsx | 0 .../(dashboard)/settings/billing/page.tsx | 0 .../(dashboard)/settings/invitations/page.tsx | 0 .../(dashboard)/settings/members/page.tsx | 0 .../(dashboard)/settings/page.tsx | 0 .../settings/platform-audit-log/page.tsx | 0 .../(dashboard)/settings/users/page.tsx | 0 .../(dashboard)/stock/locations/page.tsx | 0 .../(dashboard)/stock/lots/page.tsx | 0 .../(dashboard)/stock/movements/[id]/page.tsx | 0 .../(dashboard)/stock/movements/new/page.tsx | 0 .../(dashboard)/stock/movements/page.tsx | 0 .../(dashboard)/stock/records/page.tsx | 0 frontend/src/app/{ => [locale]}/error.tsx | 0 frontend/src/app/[locale]/layout.tsx | 57 + frontend/src/app/{ => [locale]}/loading.tsx | 0 .../src/app/[locale]/locale-layout-shell.tsx | 31 + frontend/src/app/{ => [locale]}/not-found.tsx | 2 +- frontend/src/app/layout.tsx | 16 +- frontend/src/app/page.tsx | 8 + frontend/src/components/LanguageSwitcher.tsx | 58 + .../src/components/layout/app-sidebar.tsx | 3 +- .../src/components/layout/breadcrumbs.tsx | 3 +- frontend/src/components/layout/header.tsx | 8 +- .../src/components/layout/search-command.tsx | 2 +- .../src/components/layout/tenant-switcher.tsx | 72 +- frontend/src/components/layout/user-menu.tsx | 2 +- .../src/components/tenant-locale-sync.tsx | 38 + .../audit/pages/platform-audit-log-page.tsx | 2 +- .../features/auth/components/auth-guard.tsx | 2 +- frontend/src/features/auth/hooks/use-auth.ts | 40 +- .../src/features/auth/pages/login-page.tsx | 3 +- .../auth/pages/register-company-page.tsx | 6 +- .../cycle-counts/components/cycle-columns.tsx | 2 +- .../cycle-counts/pages/cycle-create-page.tsx | 2 +- .../cycle-counts/pages/cycle-detail-page.tsx | 2 +- .../cycle-counts/pages/cycle-list-page.tsx | 2 +- .../components/movements/movement-form.tsx | 2 +- .../components/movements/movement-table.tsx | 2 +- .../components/products/product-table.tsx | 2 +- .../inventory/pages/movement-detail-page.tsx | 2 +- .../inventory/pages/movement-list-page.tsx | 2 +- .../inventory/pages/product-create-page.tsx | 2 +- .../inventory/pages/product-detail-page.tsx | 2 +- .../inventory/pages/product-edit-page.tsx | 2 +- .../inventory/pages/product-list-page.tsx | 2 +- .../procurement/pages/grn-create-page.tsx | 2 +- .../procurement/pages/grn-list-page.tsx | 2 +- .../procurement/pages/po-create-page.tsx | 2 +- .../procurement/pages/po-detail-page.tsx | 2 +- .../procurement/pages/po-list-page.tsx | 2 +- .../pages/supplier-create-page.tsx | 4 +- .../procurement/pages/supplier-edit-page.tsx | 4 +- .../procurement/pages/supplier-list-page.tsx | 4 +- .../reports/components/report-card.tsx | 2 +- .../pages/reservation-create-page.tsx | 2 +- .../pages/reservation-list-page.tsx | 2 +- .../sales/pages/customer-create-page.tsx | 4 +- .../sales/pages/customer-edit-page.tsx | 4 +- .../sales/pages/customer-list-page.tsx | 4 +- .../sales/pages/dispatch-create-page.tsx | 2 +- .../sales/pages/dispatch-list-page.tsx | 2 +- .../features/sales/pages/so-create-page.tsx | 2 +- .../features/sales/pages/so-detail-page.tsx | 2 +- .../src/features/sales/pages/so-list-page.tsx | 4 +- .../settings/pages/accept-invitation-page.tsx | 2 +- .../settings/pages/platform-users-page.tsx | 1 - frontend/src/i18n.ts | 25 + frontend/src/i18n/load-messages.ts | 24 + frontend/src/i18n/navigation.ts | 6 + frontend/src/i18n/routing.ts | 14 + frontend/src/lib/auth-store.ts | 2 + frontend/src/lib/locale-preference.ts | 29 + frontend/vitest.config.ts | 18 + frontend/vitest.setup.ts | 1 + frontend/yarn.lock | 6716 +++++++++++++++++ home/migrations/0003_seed_wagtail_locales.py | 35 + home/migrations/0004_seed_locale_ar.py | 23 + ...add_translatable_mixin_product_category.py | 162 + ...0024_align_translation_key_with_wagtail.py | 24 + inventory/models/category.py | 12 +- inventory/models/product.py | 37 +- inventory/models/reservation.py | 15 +- inventory/models/stock.py | 9 +- inventory/services/cache.py | 18 +- inventory/services/localization.py | 56 + locale/.gitkeep | 1 + locale/ar/LC_MESSAGES/django.mo | Bin 0 -> 5879 bytes locale/ar/LC_MESSAGES/django.po | 238 + locale/es/LC_MESSAGES/django.mo | Bin 0 -> 5133 bytes locale/es/LC_MESSAGES/django.po | 238 + locale/fr/LC_MESSAGES/django.mo | Bin 0 -> 5729 bytes locale/fr/LC_MESSAGES/django.po | 303 + locale/rw/LC_MESSAGES/django.mo | Bin 0 -> 4904 bytes locale/rw/LC_MESSAGES/django.po | 238 + locale/sw/LC_MESSAGES/django.mo | Bin 0 -> 4852 bytes locale/sw/LC_MESSAGES/django.po | 237 + ...ocale_supplier_translation_key_and_more.py | 85 + procurement/models/order.py | 9 +- procurement/models/supplier.py | 28 +- requirements.txt | 1 + ...ocale_customer_translation_key_and_more.py | 85 + sales/models/customer.py | 17 +- sales/models/order.py | 9 +- seeders/base.py | 10 + seeders/category_seeder.py | 68 +- seeders/locale_support.py | 46 + seeders/low_stock_seeder.py | 18 +- seeders/management/commands/seed_database.py | 2 +- seeders/product_seeder.py | 30 +- seeders/seeder_manager.py | 40 +- seeders/stock_movement_seeder.py | 20 +- seeders/stock_record_seeder.py | 6 +- tenants/middleware.py | 33 + .../0007_add_tenant_preferred_language.py | 46 + ...nant_preferred_language_wagtail_locales.py | 19 + tenants/models.py | 47 +- tenants/permissions.py | 14 +- tenants/wagtail_locales.py | 20 + tests/api/test_api_i18n_authoring.py | 217 + tests/api/test_api_i18n_read.py | 325 + tests/api/test_auth_api.py | 13 + tests/api/test_language_parameter.py | 380 + tests/api/test_reservation_api.py | 11 +- tests/api/test_tenant_api.py | 89 +- tests/fixtures/factories.py | 10 +- tests/home/test_wagtail_locales_seed.py | 33 + tests/integration/test_i18n_models.py | 70 + .../test_panels/test_translatable_panels.py | 54 + tests/seeders/test_seeder_manager.py | 3 + tests/tenants/test_middleware.py | 102 +- tests/tenants/test_models.py | 8 + tests/test_locale_catalogs.py | 61 + tests/test_locale_middleware.py | 54 + the_inventory/settings/base.py | 35 +- 226 files changed, 12757 insertions(+), 635 deletions(-) create mode 100644 .cursor/rules/django-python.mdc create mode 100644 .cursor/rules/frontend-nextjs.mdc create mode 100644 .cursor/rules/the-inventory-core.mdc create mode 100644 api/language.py create mode 100644 api/mixins/__init__.py create mode 100644 api/mixins/translatable_read.py create mode 100644 api/schema_i18n.py create mode 100644 api/serializers/localized_strings.py create mode 100644 api/serializers/translatable_representation.py create mode 100644 api/serializers/translatable_writable.py create mode 100644 frontend/__tests__/components/LanguageSwitcher.test.tsx create mode 100644 frontend/public/locales/ar.json create mode 100644 frontend/public/locales/en.json create mode 100644 frontend/public/locales/es.json create mode 100644 frontend/public/locales/fr.json create mode 100644 frontend/public/locales/rw.json create mode 100644 frontend/public/locales/sw.json delete mode 100644 frontend/src/app/(auth)/layout.tsx rename frontend/src/app/{ => [locale]}/(auth)/accept-invitation/[token]/page.tsx (100%) create mode 100644 frontend/src/app/[locale]/(auth)/layout.tsx rename frontend/src/app/{ => [locale]}/(auth)/loading.tsx (100%) rename frontend/src/app/{ => [locale]}/(auth)/login/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(auth)/register/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/audit-log/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/bulk-operations/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/categories/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/cycle-counts/[id]/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/cycle-counts/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/cycle-counts/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/error.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/layout.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/loading.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/not-found.tsx (95%) rename frontend/src/app/{ => [locale]}/(dashboard)/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/procurement/goods-received/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/procurement/goods-received/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/procurement/purchase-orders/[id]/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/procurement/purchase-orders/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/procurement/purchase-orders/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/procurement/suppliers/[id]/edit/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/procurement/suppliers/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/procurement/suppliers/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/products/[id]/edit/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/products/[id]/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/products/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/products/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/availability/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/low-stock/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/movement-history/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/overstock/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/product-expiry/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/purchase-summary/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/sales-summary/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/stock-valuation/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/traceability/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reports/variances/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reservations/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/reservations/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/sales/customers/[id]/edit/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/sales/customers/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/sales/customers/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/sales/dispatches/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/sales/dispatches/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/sales/sales-orders/[id]/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/sales/sales-orders/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/sales/sales-orders/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/settings/billing/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/settings/invitations/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/settings/members/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/settings/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/settings/platform-audit-log/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/settings/users/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/stock/locations/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/stock/lots/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/stock/movements/[id]/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/stock/movements/new/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/stock/movements/page.tsx (100%) rename frontend/src/app/{ => [locale]}/(dashboard)/stock/records/page.tsx (100%) rename frontend/src/app/{ => [locale]}/error.tsx (100%) create mode 100644 frontend/src/app/[locale]/layout.tsx rename frontend/src/app/{ => [locale]}/loading.tsx (100%) create mode 100644 frontend/src/app/[locale]/locale-layout-shell.tsx rename frontend/src/app/{ => [locale]}/not-found.tsx (95%) create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/components/LanguageSwitcher.tsx create mode 100644 frontend/src/components/tenant-locale-sync.tsx create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/i18n/load-messages.ts create mode 100644 frontend/src/i18n/navigation.ts create mode 100644 frontend/src/i18n/routing.ts create mode 100644 frontend/src/lib/locale-preference.ts create mode 100644 frontend/vitest.config.ts create mode 100644 frontend/vitest.setup.ts create mode 100644 frontend/yarn.lock create mode 100644 home/migrations/0003_seed_wagtail_locales.py create mode 100644 home/migrations/0004_seed_locale_ar.py create mode 100644 inventory/migrations/0023_add_translatable_mixin_product_category.py create mode 100644 inventory/migrations/0024_align_translation_key_with_wagtail.py create mode 100644 inventory/services/localization.py create mode 100644 locale/.gitkeep create mode 100644 locale/ar/LC_MESSAGES/django.mo create mode 100644 locale/ar/LC_MESSAGES/django.po create mode 100644 locale/es/LC_MESSAGES/django.mo create mode 100644 locale/es/LC_MESSAGES/django.po create mode 100644 locale/fr/LC_MESSAGES/django.mo create mode 100644 locale/fr/LC_MESSAGES/django.po create mode 100644 locale/rw/LC_MESSAGES/django.mo create mode 100644 locale/rw/LC_MESSAGES/django.po create mode 100644 locale/sw/LC_MESSAGES/django.mo create mode 100644 locale/sw/LC_MESSAGES/django.po create mode 100644 procurement/migrations/0006_supplier_locale_supplier_translation_key_and_more.py create mode 100644 sales/migrations/0006_customer_locale_customer_translation_key_and_more.py create mode 100644 seeders/locale_support.py create mode 100644 tenants/migrations/0007_add_tenant_preferred_language.py create mode 100644 tenants/migrations/0008_alter_tenant_preferred_language_wagtail_locales.py create mode 100644 tenants/wagtail_locales.py create mode 100644 tests/api/test_api_i18n_authoring.py create mode 100644 tests/api/test_api_i18n_read.py create mode 100644 tests/api/test_language_parameter.py create mode 100644 tests/home/test_wagtail_locales_seed.py create mode 100644 tests/integration/test_i18n_models.py create mode 100644 tests/inventory/test_panels/test_translatable_panels.py create mode 100644 tests/test_locale_catalogs.py create mode 100644 tests/test_locale_middleware.py diff --git a/.amazonq/rules/memory-bank/tech.md b/.amazonq/rules/memory-bank/tech.md index 602d99d..bfd06ae 100644 --- a/.amazonq/rules/memory-bank/tech.md +++ b/.amazonq/rules/memory-bank/tech.md @@ -53,7 +53,7 @@ - **Gunicorn**: WSGI application server (production) ### Frontend -- **npm/yarn**: Node package manager +- **Yarn**: Node package manager (use Yarn for the frontend; lockfile: `frontend/yarn.lock`) - **Next.js**: Build and dev server - **TypeScript**: Type checking - **ESLint**: Code linting @@ -120,7 +120,7 @@ celery -A the_inventory beat -l info cd frontend # Install dependencies -npm install +yarn install # Create environment file cp .env.local.example .env.local @@ -129,19 +129,19 @@ cp .env.local.example .env.local ### Frontend Development ```bash # Run development server -npm run dev +yarn dev # Build for production -npm run build +yarn build # Run production build -npm start +yarn start # Run linter -npm run lint +yarn lint -# Type checking -npm run type-check +# Type checking (no script; invoke TypeScript directly) +yarn exec tsc --noEmit ``` ### Docker Development diff --git a/.cursor/rules/django-python.mdc b/.cursor/rules/django-python.mdc new file mode 100644 index 0000000..ac2da25 --- /dev/null +++ b/.cursor/rules/django-python.mdc @@ -0,0 +1,28 @@ +--- +description: Django, Wagtail, DRF, and Python conventions for this repo +globs: "**/*.py" +alwaysApply: false +--- + +# Python / Django / Wagtail + +## Layout + +- Apps: `inventory/`, `procurement/`, `sales/`, `tenants/`, `api/`, etc. Settings: `the_inventory/settings/`. +- **Migrations:** add via `makemigrations`; keep operations reversible when practical. + +## Models and API + +- Follow existing patterns: `panels`, `ClusterableModel`, Wagtail images/search fields where the codebase already does. +- **Translatable content:** align with `TranslatableMixin`, `locale`, and `translation_key` patterns already on catalog models; check `tenants/wagtail_locales.py` and Wagtail locale setup before inventing new i18n shapes. +- **DRF:** serializers in `api/serializers/`; keep OpenAPI (`drf-spectacular`) compatibility in mind for schema consumers. + +## Tests + +- Place tests under `tests/` mirroring domain (`tests/api/`, `tests/tenants/`, …). +- Use Django `TestCase` / APIClient patterns consistent with neighboring files. + +```python +# Prefer extending existing test modules and factories/fixtures +# rather than new one-off scripts in the repo root. +``` diff --git a/.cursor/rules/frontend-nextjs.mdc b/.cursor/rules/frontend-nextjs.mdc new file mode 100644 index 0000000..7425a7e --- /dev/null +++ b/.cursor/rules/frontend-nextjs.mdc @@ -0,0 +1,21 @@ +--- +description: Next.js frontend in frontend/ — package manager and API config +globs: "frontend/**/*" +alwaysApply: false +--- + +# Frontend (`frontend/`) + +## Package manager + +- Use **yarn** (not npm) for install, scripts, and lockfile workflows. + +## Configuration + +- **Do not hardcode** backend base URLs. Use **environment variables** (and existing project patterns) for dev vs production API URLs. +- Keep **imports at the top** of files; avoid dynamic imports inside functions unless there is a strong, documented reason (e.g. code splitting entry points). +- If the file you are working with has the pre-existing import in the codeblock or file or anywhere in the file than at the top of the file, move them at the top of the file and avoid multiple imports + +## API usage + +- Tenant app talks to **DRF** at `/api/v1/`; reuse shared API client/helpers if present rather than duplicating fetch logic. diff --git a/.cursor/rules/the-inventory-core.mdc b/.cursor/rules/the-inventory-core.mdc new file mode 100644 index 0000000..c7ae91c --- /dev/null +++ b/.cursor/rules/the-inventory-core.mdc @@ -0,0 +1,24 @@ +--- +description: Core stack, architecture boundaries, and collaboration norms for The Inventory +alwaysApply: true +--- + +# The Inventory — core context + +## Stack + +- **Backend:** Django 6, Wagtail 7, DRF at `/api/v1/` (JWT via `djangorestframework-simplejwt`), SQLite dev / PostgreSQL prod. +- **Tenant UX:** Next.js in `frontend/` — primary inventory UI; consumes the REST API, not Wagtail admin for day-to-day ops. +- **Platform:** Wagtail admin at `/admin/` for staff; optional snippet registration for monitoring/support only. + +## Design conventions + +- Prefer **OOP**: domain logic in **`Service`** classes; CBVs / Wagtail view classes where applicable. +- **Multi-tenancy:** respect existing tenant scoping on models and API; do not leak data across tenants. +- **Headless rule:** new tenant-facing behavior should go through **API + Next.js**, not only Wagtail pages, unless the task is explicitly platform-admin. + +## Collaboration + +- Keep changes **minimal and scoped** to the request; match surrounding style, imports, and test patterns. +- Do **not** add unsolicited markdown docs (e.g. per-task writeups) unless the user asks. +- Run or add **tests** under `tests/` when changing behavior; follow existing `TestCase` layout by domain. diff --git a/.env.example b/.env.example index c334784..473a42a 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,7 @@ ALLOWED_HOSTS=localhost,127.0.0.1 # ----------------------------------------------------------------------------- # i18n / paths (optional) # ----------------------------------------------------------------------------- -# LANGUAGE_CODE=en-us +# LANGUAGE_CODE=en # TIME_ZONE=UTC # STATIC_URL=/static/ # MEDIA_URL=/media/ diff --git a/.github/instrunctions/common.instructions.md b/.github/instrunctions/common.instructions.md index e849d4f..ad5bbe8 100644 --- a/.github/instrunctions/common.instructions.md +++ b/.github/instrunctions/common.instructions.md @@ -6,6 +6,10 @@ Description: This file describes the environment variable configuration for the ``` +## Standards + +- On frontend(Next.js app) use yarn as the core packagemanager over the npm + ## What to avoid - Never write document per each task asked to work on unless explicitly asked. diff --git a/.gitignore b/.gitignore index 62c9cae..360fc23 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ cover/ # Translations *.mo +!locale/**/LC_MESSAGES/*.mo *.pot # Django stuff: diff --git a/api/language.py b/api/language.py new file mode 100644 index 0000000..5f9768d --- /dev/null +++ b/api/language.py @@ -0,0 +1,105 @@ +"""Resolve language for tenant API reads (query param, tenant default, Accept-Language).""" + +from django.conf import settings +from django.utils import translation +from rest_framework.exceptions import ValidationError +from wagtail.models import Locale + + +def _language_query_value(request, key: str = "language"): + """DRF ``Request.query_params`` or Django ``GET``.""" + params = getattr(request, "query_params", None) + if params is not None: + return params.get(key) + return request.GET.get(key) + + +def resolve_display_language_code(request, tenant) -> str: + """Return Django language code for this request (gettext + Wagtail lookups). + + Order: ``?language=`` → ``tenant.preferred_language`` → ``Accept-Language`` → + ``settings.LANGUAGE_CODE``. + + Raises ``ValidationError`` when ``language`` is present but not supported by Django. + """ + raw = _language_query_value(request, "language") + if raw is not None and str(raw).strip() == "": + raw = None + if raw is not None: + try: + return translation.get_supported_language_variant(raw) + except LookupError as exc: + raise ValidationError( + {"language": ["Unsupported or invalid language code."]} + ) from exc + + pref = getattr(tenant, "preferred_language", None) or "" + pref = str(pref).strip() + if pref: + try: + return translation.get_supported_language_variant(pref) + except LookupError: + pass + + from_request = translation.get_language_from_request(request, check_path=False) + if from_request: + try: + return translation.get_supported_language_variant(from_request) + except LookupError: + pass + + return translation.get_supported_language_variant(settings.LANGUAGE_CODE) + + +def resolve_canonical_language_code(tenant) -> str: + """Language code for the tenant's primary locale rows (stable ids, FKs, stock).""" + pref = getattr(tenant, "preferred_language", None) or "" + pref = str(pref).strip() or settings.LANGUAGE_CODE + try: + return translation.get_supported_language_variant(pref) + except LookupError: + return translation.get_supported_language_variant(settings.LANGUAGE_CODE) + + +def resolve_write_language_code(request, tenant) -> str: + """Language code for **writes** to translatable catalog rows (I18N-16). + + Unlike :func:`resolve_display_language_code`, this does **not** fall back to + ``Accept-Language``: absent or blank ``language`` means the tenant **canonical** + locale only. When ``language`` is present, it must be a supported Django code. + """ + raw = _language_query_value(request, "language") + if raw is not None and str(raw).strip() == "": + raw = None + if raw is not None: + try: + return translation.get_supported_language_variant(raw) + except LookupError as exc: + raise ValidationError( + {"language": ["Unsupported or invalid language code."]} + ) from exc + return resolve_canonical_language_code(tenant) + + +def wagtail_locale_for_language(code: str) -> Locale | None: + """Return Wagtail ``Locale`` for *code*, or ``None`` if not configured. + + Prefer :meth:`~wagtail.models.Locale.objects.get_for_language` when the code + is in ``WAGTAIL_CONTENT_LANGUAGES``. Fall back to an exact ``language_code`` + row so API ``?language=`` works for locales created in Wagtail admin even if + settings lag behind. + """ + if not code: + return None + try: + return Locale.objects.get_for_language(code) + except (Locale.DoesNotExist, LookupError): + pass + normalized = code.replace("_", "-").strip().lower() + loc = Locale.objects.filter(language_code__iexact=normalized).first() + if loc is not None: + return loc + short = normalized.split("-")[0] + if short != normalized: + loc = Locale.objects.filter(language_code__iexact=short).first() + return loc diff --git a/api/middleware.py b/api/middleware.py index afe750d..692547e 100644 --- a/api/middleware.py +++ b/api/middleware.py @@ -1,9 +1,8 @@ """JWT authentication middleware. -DRF's authentication runs at the view layer, after all middleware. -This middleware pre-authenticates JWT requests so that ``request.user`` -is available to ``TenantMiddleware`` for automatic tenant resolution -from user memberships. +DRF also authenticates at the view layer; this runs earlier so +``request.user`` is set before :class:`tenants.middleware.TenantMiddleware` +resolves ``request.tenant`` from memberships (Bearer token flows). """ from rest_framework_simplejwt.authentication import JWTAuthentication diff --git a/api/mixins/__init__.py b/api/mixins/__init__.py new file mode 100644 index 0000000..65e6ee7 --- /dev/null +++ b/api/mixins/__init__.py @@ -0,0 +1,3 @@ +from api.mixins.translatable_read import TranslatableAPIReadMixin + +__all__ = ["TranslatableAPIReadMixin"] diff --git a/api/mixins/translatable_read.py b/api/mixins/translatable_read.py new file mode 100644 index 0000000..c05f29d --- /dev/null +++ b/api/mixins/translatable_read.py @@ -0,0 +1,65 @@ +"""Activate resolved language and scope read querysets to the tenant canonical locale.""" + +from django.utils import translation +from wagtail.models import TranslatableMixin + +from api.language import ( + resolve_canonical_language_code, + resolve_display_language_code, + resolve_write_language_code, + wagtail_locale_for_language, +) + + +class TranslatableAPIReadMixin: + """For ``TranslatableMixin`` models: canonical rows on reads + display locale in context. + + - Read list/retrieve querysets are filtered to the tenant's **canonical** Wagtail locale + (from ``tenant.preferred_language``) so product ids and stock FKs stay consistent. + - ``?language=`` (then tenant default, then ``Accept-Language``) controls **display** + strings via serializer context ``language`` (Django code), ``wagtail_display_locale`` + (Wagtail :class:`~wagtail.models.Locale`), and ``translation.activate``. + """ + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + tenant = self._get_current_tenant() + display_code = resolve_display_language_code(request, tenant) + canonical_code = resolve_canonical_language_code(tenant) + write_code = resolve_write_language_code(request, tenant) + self._api_display_language_code = display_code + self._api_display_locale = wagtail_locale_for_language(display_code) + self._api_canonical_locale = wagtail_locale_for_language(canonical_code) + self._api_write_locale = wagtail_locale_for_language(write_code) + translation.activate(display_code) + + def finalize_response(self, request, response, *args, **kwargs): + translation.deactivate() + return super().finalize_response(request, response, *args, **kwargs) + + def get_serializer_context(self): + parent = super() + get_ctx = getattr(parent, "get_serializer_context", None) + ctx = get_ctx() if callable(get_ctx) else {} + ctx["wagtail_display_locale"] = getattr(self, "_api_display_locale", None) + ctx["language"] = getattr(self, "_api_display_language_code", None) + ctx["wagtail_canonical_locale"] = getattr(self, "_api_canonical_locale", None) + ctx["wagtail_write_locale"] = getattr(self, "_api_write_locale", None) + return ctx + + def _read_list_uses_canonical_locale_only(self) -> bool: + """One row per translation group on **list**; detail/actions keep full tenant scope.""" + if self.request.method not in ("GET", "HEAD"): + return False + return getattr(self, "action", None) == "list" + + def get_queryset(self): + qs = super().get_queryset() + if not self._read_list_uses_canonical_locale_only(): + return qs + if not issubclass(qs.model, TranslatableMixin): + return qs + loc = getattr(self, "_api_canonical_locale", None) + if loc is not None: + qs = qs.filter(locale_id=loc.id) + return qs diff --git a/api/pagination.py b/api/pagination.py index 85dba05..26df3bf 100644 --- a/api/pagination.py +++ b/api/pagination.py @@ -5,6 +5,9 @@ class StandardPagination(PageNumberPagination): """Default pagination for all API endpoints. Supports ``?page=N`` and ``?page_size=N`` (max 100). + + Translatable list endpoints (products, categories, suppliers, customers) also accept + optional ``?language=CODE`` (e.g. ``fr``). See :mod:`api.language`. """ page_size = 25 diff --git a/api/schema_i18n.py b/api/schema_i18n.py new file mode 100644 index 0000000..f582cc1 --- /dev/null +++ b/api/schema_i18n.py @@ -0,0 +1,19 @@ +"""OpenAPI fragments for catalog i18n (I18N-10).""" + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter + +OPENAPI_LANGUAGE_QUERY_PARAMETER = OpenApiParameter( + name="language", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + description=( + "Translatable catalog locale. **GET:** overlay strings; resolution order is " + "this parameter, tenant preferred_language, then Accept-Language. " + "**POST/PATCH/PUT:** target locale for the persisted row; when omitted, the " + "tenant canonical locale is used (Accept-Language is ignored). " + "Invalid codes return 400. For POST in a non-canonical locale, send " + "`translation_of` (canonical row id) in the body—see serializer schema." + ), +) diff --git a/api/serializers/auth.py b/api/serializers/auth.py index a255e57..bc3b599 100644 --- a/api/serializers/auth.py +++ b/api/serializers/auth.py @@ -10,8 +10,25 @@ User = get_user_model() +def memberships_payload_for_user(user): + """Membership rows for API responses (login, /me/, register, impersonate).""" + return list( + TenantMembership.objects.filter(user=user, is_active=True) + .select_related("tenant") + .order_by("-is_default", "pk") + .values( + "tenant__id", + "tenant__name", + "tenant__slug", + "tenant__preferred_language", + "role", + "is_default", + ) + ) + + class InventoryTokenObtainPairSerializer(TokenObtainPairSerializer): - """Extends the default JWT login response with user and tenant info.""" + """Extends the default JWT login response with user, tenant, and all memberships.""" def validate(self, attrs): data = super().validate(attrs) @@ -29,10 +46,13 @@ def validate(self, attrs): "name": membership.tenant.name, "slug": membership.tenant.slug, "role": membership.role, + "preferred_language": membership.tenant.preferred_language, } else: data["tenant"] = None + data["memberships"] = memberships_payload_for_user(self.user) + return data @@ -82,14 +102,11 @@ def get_tenant(self, obj): "name": tenant.name, "slug": tenant.slug, "role": membership.role if membership else None, + "preferred_language": tenant.preferred_language, } def get_memberships(self, obj): - return list( - TenantMembership.objects.filter(user=obj["user"], is_active=True) - .select_related("tenant") - .values("tenant__id", "tenant__name", "tenant__slug", "role", "is_default") - ) + return memberships_payload_for_user(obj["user"]) class ChangePasswordSerializer(serializers.Serializer): diff --git a/api/serializers/inventory.py b/api/serializers/inventory.py index ad10aef..d560092 100644 --- a/api/serializers/inventory.py +++ b/api/serializers/inventory.py @@ -12,10 +12,26 @@ StockMovementLot, StockRecord, ) +from inventory.models.reservation import AllocationStrategy from tenants.middleware import get_effective_tenant +from api.serializers.localized_strings import ( + attribute_in_display_locale, + display_locale_from_context, +) +from api.serializers.translatable_representation import TranslatableRepresentationMixin +from api.serializers.translatable_writable import TranslatableWritableMixin + + +class CategorySerializer( + TranslatableWritableMixin, + TranslatableRepresentationMixin, + serializers.ModelSerializer, +): + """Translatable (per Wagtail locale): ``name``, ``slug``, ``description``.""" + + translatable_overlay_fields = ("name", "slug", "description") -class CategorySerializer(serializers.ModelSerializer): class Meta: model = Category fields = [ @@ -24,16 +40,29 @@ class Meta: ] read_only_fields = ["created_at", "updated_at"] + def _create_canonical_row(self, validated_data): + tenant = validated_data.pop("tenant") + created_by = validated_data.pop("created_by", None) + loc = validated_data.pop("locale") + return Category.add_root( + tenant=tenant, + created_by=created_by, + locale=loc, + **validated_data, + ) + def validate_slug(self, value): request = self.context.get("request") tenant = self.context.get("tenant") or ( get_effective_tenant(request) if request else None ) + loc = self.context.get("wagtail_write_locale") qs = Category.objects.filter(slug=value) if tenant: qs = qs.filter(tenant=tenant) - if self.instance: - qs = qs.exclude(pk=self.instance.pk) + if loc: + qs = qs.filter(locale_id=loc.id) + qs = self._same_translation_group_qs(qs, self.instance) if qs.exists(): raise serializers.ValidationError( "A category with this slug already exists for this tenant." @@ -41,34 +70,54 @@ def validate_slug(self, value): return value -class ProductSerializer(serializers.ModelSerializer): - category_name = serializers.CharField( - source="category.name", read_only=True, default=None, - ) +class ProductSerializer( + TranslatableWritableMixin, + TranslatableRepresentationMixin, + serializers.ModelSerializer, +): + """Translatable: ``name``, ``description``. ``category_name`` follows the same display locale.""" + + translatable_overlay_fields = ("name", "description") + + category_name = serializers.SerializerMethodField() unit_of_measure_display = serializers.CharField( source="get_unit_of_measure_display", read_only=True, ) + tracking_mode_display = serializers.CharField( + source="get_tracking_mode_display", read_only=True, + ) class Meta: model = Product fields = [ "id", "sku", "name", "description", "category", "category_name", "unit_of_measure", "unit_of_measure_display", + "tracking_mode", "tracking_mode_display", "unit_cost", "reorder_point", "is_active", "created_at", "updated_at", ] read_only_fields = ["created_at", "updated_at"] + def get_category_name(self, obj): + category = obj.category + if category is None: + return None + return attribute_in_display_locale( + category, "name", display_locale_from_context(self.context), + ) + def validate_sku(self, value): request = self.context.get("request") tenant = self.context.get("tenant") or ( get_effective_tenant(request) if request else None ) + loc = self.context.get("wagtail_write_locale") qs = Product.objects.filter(sku=value) if tenant: qs = qs.filter(tenant=tenant) - if self.instance: - qs = qs.exclude(pk=self.instance.pk) + if loc: + qs = qs.filter(locale_id=loc.id) + qs = self._same_translation_group_qs(qs, self.instance) if qs.exists(): raise serializers.ValidationError( "A product with this SKU already exists for this tenant." @@ -91,8 +140,10 @@ class Meta: class StockRecordSerializer(serializers.ModelSerializer): + """``product_name`` uses the request display locale when ``product`` is translatable.""" + product_sku = serializers.CharField(source="product.sku", read_only=True) - product_name = serializers.CharField(source="product.name", read_only=True) + product_name = serializers.SerializerMethodField() location_name = serializers.CharField(source="location.name", read_only=True) reserved_quantity = serializers.IntegerField(read_only=True) available_quantity = serializers.IntegerField(read_only=True) @@ -111,6 +162,11 @@ class Meta: "created_at", "updated_at", ] + def get_product_name(self, obj): + return attribute_in_display_locale( + obj.product, "name", display_locale_from_context(self.context), + ) + class StockLotSerializer(serializers.ModelSerializer): """Read-only serializer for lot/batch records.""" @@ -227,9 +283,9 @@ class StockMovementCreateSerializer(serializers.Serializer): required=False, allow_null=True, default=None, ) allocation_strategy = serializers.ChoiceField( - choices=[("FIFO", "FIFO"), ("LIFO", "LIFO")], + choices=AllocationStrategy.choices, required=False, - default="FIFO", + default=AllocationStrategy.FIFO, ) @property diff --git a/api/serializers/localized_strings.py b/api/serializers/localized_strings.py new file mode 100644 index 0000000..25c1059 --- /dev/null +++ b/api/serializers/localized_strings.py @@ -0,0 +1,44 @@ +"""Helpers for overlaying Wagtail translation fields in API serializers.""" + +from __future__ import annotations + +from typing import Any + +from api.language import wagtail_locale_for_language + + +def display_locale_from_context(context: dict | None) -> Any: + """Resolve Wagtail ``Locale`` for serializer output. + + Prefers ``wagtail_display_locale``; falls back to ``language`` (Django code) + via :func:`~api.language.wagtail_locale_for_language` so tests and custom + call sites can pass either. + """ + if not context: + return None + loc = context.get("wagtail_display_locale") + if loc is not None: + return loc + code = context.get("language") + if code: + return wagtail_locale_for_language(code) + return None + + +def attribute_in_display_locale( + source: Any, + attr: str, + display_locale, +) -> Any: + """Return ``attr`` from the translation in *display_locale*, else from *source*.""" + if source is None: + return None + if display_locale is None: + return getattr(source, attr, None) + getter = getattr(source, "get_translation_or_none", None) + if not callable(getter): + return getattr(source, attr) + trans = getter(display_locale) + if trans is not None: + return getattr(trans, attr) + return getattr(source, attr) diff --git a/api/serializers/procurement.py b/api/serializers/procurement.py index d17c2d1..5a1703e 100644 --- a/api/serializers/procurement.py +++ b/api/serializers/procurement.py @@ -9,8 +9,24 @@ Supplier, ) +from api.serializers.localized_strings import ( + attribute_in_display_locale, + display_locale_from_context, +) +from tenants.middleware import get_effective_tenant + +from api.serializers.translatable_representation import TranslatableRepresentationMixin +from api.serializers.translatable_writable import TranslatableWritableMixin + -class SupplierSerializer(serializers.ModelSerializer): +class SupplierSerializer( + TranslatableWritableMixin, + TranslatableRepresentationMixin, + serializers.ModelSerializer, +): + """Translatable: ``name``, ``contact_name``, ``address``, ``notes``.""" + + translatable_overlay_fields = ("name", "contact_name", "address", "notes") payment_terms_display = serializers.CharField( source="get_payment_terms_display", read_only=True, ) @@ -25,10 +41,30 @@ class Meta: ] read_only_fields = ["created_at", "updated_at"] + def validate_code(self, value): + request = self.context.get("request") + tenant = self.context.get("tenant") or ( + get_effective_tenant(request) if request else None + ) + loc = self.context.get("wagtail_write_locale") + qs = Supplier.objects.filter(code=value) + if tenant: + qs = qs.filter(tenant=tenant) + if loc: + qs = qs.filter(locale_id=loc.id) + qs = self._same_translation_group_qs(qs, self.instance) + if qs.exists(): + raise serializers.ValidationError( + "A supplier with this code already exists for this tenant." + ) + return value + class PurchaseOrderLineSerializer(serializers.ModelSerializer): + """``product_name`` uses the request display locale for translated catalog products.""" + product_sku = serializers.CharField(source="product.sku", read_only=True) - product_name = serializers.CharField(source="product.name", read_only=True) + product_name = serializers.SerializerMethodField() line_total = serializers.DecimalField( max_digits=12, decimal_places=2, read_only=True, ) @@ -41,11 +77,16 @@ class Meta: ] read_only_fields = ["id"] + def get_product_name(self, obj): + return attribute_in_display_locale( + obj.product, "name", display_locale_from_context(self.context), + ) + class PurchaseOrderSerializer(serializers.ModelSerializer): - supplier_name = serializers.CharField( - source="supplier.name", read_only=True, - ) + """``supplier_name`` uses the request display locale for translated suppliers.""" + + supplier_name = serializers.SerializerMethodField() status_display = serializers.CharField( source="get_status_display", read_only=True, ) @@ -64,6 +105,11 @@ class Meta: ] read_only_fields = ["status", "created_at", "updated_at"] + def get_supplier_name(self, obj): + return attribute_in_display_locale( + obj.supplier, "name", display_locale_from_context(self.context), + ) + class GoodsReceivedNoteSerializer(serializers.ModelSerializer): purchase_order_number = serializers.CharField( diff --git a/api/serializers/sales.py b/api/serializers/sales.py index 457fda4..c62f66f 100644 --- a/api/serializers/sales.py +++ b/api/serializers/sales.py @@ -10,8 +10,25 @@ SalesOrderLine, ) +from api.serializers.localized_strings import ( + attribute_in_display_locale, + display_locale_from_context, +) +from tenants.middleware import get_effective_tenant + +from api.serializers.translatable_representation import TranslatableRepresentationMixin +from api.serializers.translatable_writable import TranslatableWritableMixin + + +class CustomerSerializer( + TranslatableWritableMixin, + TranslatableRepresentationMixin, + serializers.ModelSerializer, +): + """Translatable: ``name``, ``contact_name``, ``address``, ``notes``.""" + + translatable_overlay_fields = ("name", "contact_name", "address", "notes") -class CustomerSerializer(serializers.ModelSerializer): class Meta: model = Customer fields = [ @@ -21,10 +38,30 @@ class Meta: ] read_only_fields = ["created_at", "updated_at"] + def validate_code(self, value): + request = self.context.get("request") + tenant = self.context.get("tenant") or ( + get_effective_tenant(request) if request else None + ) + loc = self.context.get("wagtail_write_locale") + qs = Customer.objects.filter(code=value) + if tenant: + qs = qs.filter(tenant=tenant) + if loc: + qs = qs.filter(locale_id=loc.id) + qs = self._same_translation_group_qs(qs, self.instance) + if qs.exists(): + raise serializers.ValidationError( + "A customer with this code already exists for this tenant." + ) + return value + class SalesOrderLineSerializer(serializers.ModelSerializer): + """``product_name`` uses the request display locale for translated catalog products.""" + product_sku = serializers.CharField(source="product.sku", read_only=True) - product_name = serializers.CharField(source="product.name", read_only=True) + product_name = serializers.SerializerMethodField() line_total = serializers.DecimalField( max_digits=12, decimal_places=2, read_only=True, ) @@ -37,6 +74,11 @@ class Meta: ] read_only_fields = ["id"] + def get_product_name(self, obj): + return attribute_in_display_locale( + obj.product, "name", display_locale_from_context(self.context), + ) + class SalesOrderLineWriteSerializer(serializers.Serializer): product = serializers.PrimaryKeyRelatedField( @@ -47,9 +89,9 @@ class SalesOrderLineWriteSerializer(serializers.Serializer): class SalesOrderSerializer(serializers.ModelSerializer): - customer_name = serializers.CharField( - source="customer.name", read_only=True, - ) + """``customer_name`` uses the request display locale for translated customers.""" + + customer_name = serializers.SerializerMethodField() status_display = serializers.CharField( source="get_status_display", read_only=True, ) @@ -69,6 +111,11 @@ class Meta: ] read_only_fields = ["status", "created_at", "updated_at"] + def get_customer_name(self, obj): + return attribute_in_display_locale( + obj.customer, "name", display_locale_from_context(self.context), + ) + def get_can_fulfill(self, obj): """True when every order line can be met by available stock across all locations.""" for line in obj.lines.select_related("product").all(): diff --git a/api/serializers/tenants.py b/api/serializers/tenants.py index 2a9fd7a..1470977 100644 --- a/api/serializers/tenants.py +++ b/api/serializers/tenants.py @@ -11,13 +11,21 @@ class TenantSerializer(serializers.ModelSerializer): user_count = serializers.SerializerMethodField() product_count = serializers.SerializerMethodField() + subscription_plan_display = serializers.CharField( + source="get_subscription_plan_display", read_only=True, + ) + subscription_status_display = serializers.CharField( + source="get_subscription_status_display", read_only=True, + ) class Meta: model = Tenant fields = ( "id", "name", "slug", "is_active", + "preferred_language", "branding_site_name", "branding_primary_color", - "subscription_plan", "subscription_status", + "subscription_plan", "subscription_plan_display", + "subscription_status", "subscription_status_display", "max_users", "max_products", "user_count", "product_count", "created_at", "updated_at", @@ -100,12 +108,15 @@ class TenantMemberSerializer(serializers.ModelSerializer): email = serializers.CharField(source="user.email", read_only=True) first_name = serializers.CharField(source="user.first_name", read_only=True) last_name = serializers.CharField(source="user.last_name", read_only=True) + role_display = serializers.CharField( + source="get_role_display", read_only=True, + ) class Meta: model = TenantMembership fields = ( "id", "username", "email", "first_name", "last_name", - "role", "is_active", "is_default", "created_at", + "role", "role_display", "is_active", "is_default", "created_at", ) read_only_fields = ("id", "username", "email", "first_name", "last_name", "created_at") diff --git a/api/serializers/translatable_representation.py b/api/serializers/translatable_representation.py new file mode 100644 index 0000000..d66ba47 --- /dev/null +++ b/api/serializers/translatable_representation.py @@ -0,0 +1,27 @@ +"""Merge Wagtail translation field values into serializer output for ``?language=``.""" + +from api.serializers.localized_strings import display_locale_from_context + + +class TranslatableRepresentationMixin: + """Overlay string fields from ``get_translation_or_none(display_locale)``. + + Reads display locale from serializer context: ``wagtail_display_locale`` or + ``language`` (see :func:`~api.serializers.localized_strings.display_locale_from_context`), + typically set by :class:`~api.mixins.translatable_read.TranslatableAPIReadMixin`. + """ + + translatable_overlay_fields: tuple[str, ...] = () + + def to_representation(self, instance): + data = super().to_representation(instance) + loc = display_locale_from_context(self.context) + if not loc or not hasattr(instance, "get_translation_or_none"): + return data + trans = instance.get_translation_or_none(loc) + if trans is None or trans.pk == instance.pk: + return data + for field in self.translatable_overlay_fields: + if field in data: + data[field] = getattr(trans, field) + return data diff --git a/api/serializers/translatable_writable.py b/api/serializers/translatable_writable.py new file mode 100644 index 0000000..879057c --- /dev/null +++ b/api/serializers/translatable_writable.py @@ -0,0 +1,191 @@ +"""DRF create/update for Wagtail ``TranslatableMixin`` catalog rows (I18N-16). + +**Write locale** uses :func:`api.language.resolve_write_language_code`: ``?language=`` +when present, otherwise the tenant **canonical** locale. ``Accept-Language`` is not +used for writes. + +- **POST** in the canonical locale: start a new translation group (omit ``translation_of``). +- **POST** in another locale: set ``translation_of`` to the PK of the **canonical** + row (the id returned by default list/detail). +- **PATCH** with ``?language=`` updates that locale’s row (creating the linked + translation first when needed). The URL ``id`` stays the canonical list id. +""" + +from __future__ import annotations + +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from wagtail.models import TranslatableMixin + +from inventory.services.localization import copy_catalog_row_for_locale +from tenants.middleware import get_effective_tenant + + +class TranslatableWritableMixin: + """Set ``locale`` on create, resolve / bootstrap translation rows on update.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + model = self.Meta.model + self.fields["translation_of"] = serializers.PrimaryKeyRelatedField( + queryset=model.objects.all(), + write_only=True, + required=False, + allow_null=True, + help_text=( + "Required when POSTing in a non-canonical locale: PK of the canonical " + "locale row (default list/detail id)." + ), + ) + + def _write_locale(self): + loc = self.context.get("wagtail_write_locale") + if loc is None: + raise ValidationError( + { + "detail": "No Wagtail Locale matches the resolved language; " + "configure Locales in Wagtail admin." + } + ) + return loc + + def _canonical_locale(self): + loc = self.context.get("wagtail_canonical_locale") + if loc is None: + raise ValidationError({"detail": "Could not resolve the tenant canonical locale."}) + return loc + + def _same_translation_group_qs(self, qs, instance): + if instance is not None and getattr(instance, "translation_key", None): + return qs.exclude(translation_key=instance.translation_key) + if instance is not None: + return qs.exclude(pk=instance.pk) + return qs + + def validate(self, attrs): + attrs = super().validate(attrs) + translation_of = attrs.get("translation_of") + write_loc = self.context.get("wagtail_write_locale") + canon_loc = self.context.get("wagtail_canonical_locale") + if self.instance is None and translation_of is not None and write_loc and canon_loc: + if write_loc.id == canon_loc.id: + raise ValidationError( + {"translation_of": ["Omit this field when creating in the canonical locale."]} + ) + return attrs + + def _localize_relation(self, related, target_locale): + if related is None or target_locale is None: + return related + if not isinstance(related, TranslatableMixin): + return related + if related.locale_id == target_locale.id: + return related + translated = related.get_translation_or_none(target_locale) + return translated if translated is not None else related + + def _apply_localized_fks(self, validated_data, loc): + if "category" in validated_data: + validated_data["category"] = self._localize_relation( + validated_data.get("category"), loc, + ) + return validated_data + + def _assign_translated_fields(self, translated, validated_data, write_loc): + for field, value in validated_data.items(): + if field == "category": + setattr( + translated, + "category", + self._localize_relation(value, write_loc), + ) + else: + setattr(translated, field, value) + + def create(self, validated_data): + translation_of = validated_data.pop("translation_of", None) + write_loc = self._write_locale() + canon_loc = self._canonical_locale() + model = self.Meta.model + request = self.context.get("request") + tenant = validated_data.get("tenant") or ( + get_effective_tenant(request) if request else None + ) + + if not issubclass(model, TranslatableMixin): + return super().create(validated_data) + + if write_loc.id == canon_loc.id: + if translation_of is not None: + raise ValidationError( + {"translation_of": ["Omit this field when creating in the canonical locale."]} + ) + validated_data["locale"] = write_loc + validated_data = self._apply_localized_fks(validated_data, write_loc) + return self._create_canonical_row(validated_data) + + if translation_of is None: + raise ValidationError( + { + "translation_of": [ + "This field is required when creating in a non-canonical locale." + ], + } + ) + + source = translation_of + if tenant and source.tenant_id != tenant.pk: + raise ValidationError({"translation_of": ["Object does not belong to this tenant."]}) + if source.locale_id != canon_loc.id: + raise ValidationError( + { + "translation_of": [ + "Must reference the row in the tenant's canonical locale " + "(default list/detail id)." + ], + } + ) + if source.get_translation_or_none(write_loc) is not None: + raise ValidationError( + { + "translation_of": [ + "A translation for this locale already exists; use PATCH with ?language=." + ], + } + ) + + translated = copy_catalog_row_for_locale(source, write_loc) + self._assign_translated_fields(translated, validated_data, write_loc) + translated.save() + return translated + + def _create_canonical_row(self, validated_data): + """Create a new row in the canonical locale (override for MP_Tree ``Category``).""" + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data.pop("translation_of", None) + loc = self._write_locale() + validated_data.pop("locale", None) + validated_data = self._apply_localized_fks(validated_data, loc) + + model = self.Meta.model + if not issubclass(model, TranslatableMixin) or not isinstance(instance, TranslatableMixin): + return super().update(instance, validated_data) + + if instance.locale_id == loc.id: + return super().update(instance, validated_data) + + target = instance.get_translation_or_none(loc) + if target is None: + canon_loc = self._canonical_locale() + source = ( + instance + if instance.locale_id == canon_loc.id + else instance.get_translation_or_none(canon_loc) + ) + if source is None: + source = instance + target = copy_catalog_row_for_locale(source, loc) + + return super().update(target, validated_data) diff --git a/api/views/auth.py b/api/views/auth.py index cd841ac..9a61da7 100644 --- a/api/views/auth.py +++ b/api/views/auth.py @@ -16,6 +16,7 @@ RegisterTenantSerializer, UserProfileSerializer, UserSerializer, + memberships_payload_for_user, ) from tenants.models import Tenant, TenantMembership, TenantRole @@ -137,11 +138,7 @@ def post(self, request): ) refresh = RefreshToken.for_user(user) - memberships = list( - TenantMembership.objects.filter(user=user, is_active=True) - .select_related("tenant") - .values("tenant__id", "tenant__name", "tenant__slug", "role", "is_default") - ) + memberships = memberships_payload_for_user(user) return Response( { @@ -153,6 +150,7 @@ def post(self, request): "name": tenant.name, "slug": tenant.slug, "role": TenantRole.OWNER, + "preferred_language": tenant.preferred_language, }, "memberships": memberships, }, @@ -225,11 +223,7 @@ def post(self, request): refresh = RefreshToken.for_user(target_user) refresh["impersonated_by"] = real_user.pk - memberships = list( - TenantMembership.objects.filter(user=target_user, is_active=True) - .select_related("tenant") - .values("tenant__id", "tenant__name", "tenant__slug", "role", "is_default") - ) + memberships = memberships_payload_for_user(target_user) AuditService().log( tenant=audit_tenant, @@ -253,6 +247,7 @@ def post(self, request): "name": tenant.name, "slug": tenant.slug, "role": membership.role, + "preferred_language": tenant.preferred_language, } if tenant else None diff --git a/api/views/inventory.py b/api/views/inventory.py index 25392ec..c2baaf9 100644 --- a/api/views/inventory.py +++ b/api/views/inventory.py @@ -10,19 +10,22 @@ from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from inventory.exceptions import InventoryError, InsufficientStockError, MovementImmutableError from inventory.models import Category, Product, StockLocation, StockLot, StockMovement, StockRecord from inventory.services.cache import ( cache_get, cache_set, - stock_record_key, + stock_record_serialized_cache_key, ) from inventory.services.stock import StockService from tenants.context import get_current_tenant from tenants.middleware import resolve_tenant_for_request from tenants.permissions import TenantReadOnlyOrManager, get_membership +from api.mixins import TranslatableAPIReadMixin +from api.schema_i18n import OPENAPI_LANGUAGE_QUERY_PARAMETER from api.serializers.inventory import ( CategorySerializer, ProductSerializer, @@ -90,7 +93,8 @@ def perform_destroy(self, instance): instance.delete() -class CategoryViewSet(TenantScopedInventoryMixin, viewsets.ModelViewSet): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class CategoryViewSet(TranslatableAPIReadMixin, TenantScopedInventoryMixin, viewsets.ModelViewSet): queryset = Category.objects.all() serializer_class = CategorySerializer filter_backends = [SearchFilter, OrderingFilter] @@ -103,7 +107,8 @@ def get_queryset(self): return super().get_queryset().filter(tenant=tenant) -class ProductViewSet(TenantScopedInventoryMixin, viewsets.ModelViewSet): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class ProductViewSet(TranslatableAPIReadMixin, TenantScopedInventoryMixin, viewsets.ModelViewSet): queryset = Product.objects.select_related("category").all() serializer_class = ProductSerializer filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] @@ -116,24 +121,55 @@ def get_queryset(self): tenant = self._get_current_tenant() return super().get_queryset().filter(tenant=tenant) + def get_object(self): + """Detail writes/retrieve use tenant-scoped queryset (404 if wrong tenant). + + Custom stock-related actions resolve PK globally then enforce membership so + cross-tenant PKs return 403, matching :mod:`tests.api.test_inventory_tenant_security`. + """ + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + if getattr(self, "action", None) in ( + "stock", "movements", "availability", "lots", + ): + obj = get_object_or_404(Product.objects.all(), **filter_kwargs) + self.check_object_permissions(self.request, obj) + self._verify_tenant_ownership(obj) + return obj + obj = get_object_or_404(self.get_queryset(), **filter_kwargs) + self.check_object_permissions(self.request, obj) + return obj + + def _product_for_stock_operations(self, product: Product) -> Product: + """Stock/movements are keyed to the tenant canonical locale row.""" + loc = getattr(self, "_api_canonical_locale", None) + if loc is None or product.locale_id == loc.id: + return product + canonical = product.get_translation_or_none(loc) + return canonical if canonical is not None else product + @action(detail=True, methods=["get"]) def stock(self, request, pk=None): """Return stock records for this product across all locations.""" tenant = self._get_current_tenant() - product = get_object_or_404(Product.objects.all(), pk=pk) + product = self._product_for_stock_operations(self.get_object()) self._verify_tenant_ownership(product) records = StockRecord.objects.filter( product=product, tenant=tenant, ).select_related("location") + ctx = self.get_serializer_context() + lang = ctx.get("language") data = [] for record in records: - key = stock_record_key(product.pk, record.location_id) + key = stock_record_serialized_cache_key( + product.pk, record.location_id, lang, + ) cached = cache_get(key) if cached is not None: data.append(cached) else: - serialized = StockRecordSerializer(record).data + serialized = StockRecordSerializer(record, context=ctx).data cache_set(key, serialized) data.append(serialized) return Response(data) @@ -142,19 +178,21 @@ def stock(self, request, pk=None): def movements(self, request, pk=None): """Return recent stock movements for this product.""" tenant = self._get_current_tenant() - product = get_object_or_404(Product.objects.all(), pk=pk) + product = self._product_for_stock_operations(self.get_object()) self._verify_tenant_ownership(product) movements = StockMovement.objects.filter( product=product, tenant=tenant, ).select_related("from_location", "to_location")[:50] - serializer = StockMovementSerializer(movements, many=True) + serializer = StockMovementSerializer( + movements, many=True, context=self.get_serializer_context(), + ) return Response(serializer.data) @action(detail=True, methods=["get"]) def availability(self, request, pk=None): """Return per-location availability: quantity, reserved, available.""" - product = get_object_or_404(Product.objects.all(), pk=pk) + product = self._product_for_stock_operations(self.get_object()) self._verify_tenant_ownership(product) return product_availability_action(self, request, product=product) @@ -162,7 +200,7 @@ def availability(self, request, pk=None): def lots(self, request, pk=None): """Return lots for this product, with optional filtering.""" tenant = self._get_current_tenant() - product = get_object_or_404(Product.objects.all(), pk=pk) + product = self._product_for_stock_operations(self.get_object()) self._verify_tenant_ownership(product) qs = StockLot.objects.filter(product=product, tenant=tenant).select_related("product") @@ -184,7 +222,12 @@ def lots(self, request, pk=None): return Response(serializer.data) -class StockLocationViewSet(TenantScopedInventoryMixin, viewsets.ModelViewSet): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class StockLocationViewSet( + TranslatableAPIReadMixin, + TenantScopedInventoryMixin, + viewsets.ModelViewSet, +): queryset = StockLocation.objects.all() serializer_class = StockLocationSerializer filter_backends = [SearchFilter, OrderingFilter] @@ -206,20 +249,24 @@ def stock(self, request, pk=None): location=location, tenant=tenant, ).select_related("product") + ctx = self.get_serializer_context() + lang = ctx.get("language") data = [] for record in records: - key = stock_record_key(record.product_id, location.pk) + key = stock_record_serialized_cache_key( + record.product_id, location.pk, lang, + ) cached = cache_get(key) if cached is not None: data.append(cached) else: - serialized = StockRecordSerializer(record).data + serialized = StockRecordSerializer(record, context=ctx).data cache_set(key, serialized) data.append(serialized) return Response(data) -class StockRecordViewSet(TenantScopedInventoryMixin, viewsets.ReadOnlyModelViewSet): +class StockRecordViewSet(TranslatableAPIReadMixin, TenantScopedInventoryMixin, viewsets.ReadOnlyModelViewSet): """Read-only — stock is managed through movements, not direct edits.""" queryset = StockRecord.objects.select_related("product", "location").all() @@ -242,7 +289,9 @@ def low_stock(self, request): return Response(serializer.data) -class StockMovementViewSet(TenantScopedInventoryMixin, +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class StockMovementViewSet(TranslatableAPIReadMixin, + TenantScopedInventoryMixin, viewsets.GenericViewSet, viewsets.mixins.ListModelMixin, viewsets.mixins.RetrieveModelMixin, @@ -332,7 +381,9 @@ def create(self, request, *args, **kwargs): status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) - output = StockMovementSerializer(movement) + output = StockMovementSerializer( + movement, context=self.get_serializer_context(), + ) return Response(output.data, status=status.HTTP_201_CREATED) diff --git a/api/views/invitations.py b/api/views/invitations.py index c92bf91..aa1f716 100644 --- a/api/views/invitations.py +++ b/api/views/invitations.py @@ -47,7 +47,7 @@ TenantInvitation, TenantMembership, ) -from tenants.permissions import IsTenantAdmin +from tenants.permissions import IsTenantAdmin, IsTenantMember User = get_user_model() @@ -55,14 +55,18 @@ class InvitationListCreateView(ListAPIView): """List or create invitations for the current tenant. - GET — list all invitations (any status). + GET — list all invitations (any member). POST — create a new invitation (admin/owner only). """ serializer_class = InvitationSerializer - permission_classes = (IsAuthenticated, IsTenantAdmin) pagination_class = None + def get_permissions(self): + if self.request.method == "POST": + return [IsAuthenticated(), IsTenantAdmin()] + return [IsAuthenticated(), IsTenantMember()] + def get_queryset(self): tenant = get_effective_tenant(self.request) if not tenant: diff --git a/api/views/procurement.py b/api/views/procurement.py index d3f109f..2b05846 100644 --- a/api/views/procurement.py +++ b/api/views/procurement.py @@ -6,10 +6,13 @@ from rest_framework.decorators import action from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from procurement.models import GoodsReceivedNote, PurchaseOrder, Supplier from procurement.services.procurement import ProcurementService +from api.mixins import TranslatableAPIReadMixin +from api.schema_i18n import OPENAPI_LANGUAGE_QUERY_PARAMETER from api.serializers.procurement import ( GoodsReceivedNoteSerializer, PurchaseOrderSerializer, @@ -18,7 +21,8 @@ from api.views.inventory import TenantScopedInventoryMixin -class SupplierViewSet(TenantScopedInventoryMixin, viewsets.ModelViewSet): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class SupplierViewSet(TranslatableAPIReadMixin, TenantScopedInventoryMixin, viewsets.ModelViewSet): queryset = Supplier.objects.all() serializer_class = SupplierSerializer filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] @@ -32,7 +36,12 @@ def get_queryset(self): return super().get_queryset().filter(tenant=tenant) -class PurchaseOrderViewSet(TenantScopedInventoryMixin, viewsets.ModelViewSet): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class PurchaseOrderViewSet( + TranslatableAPIReadMixin, + TenantScopedInventoryMixin, + viewsets.ModelViewSet, +): queryset = PurchaseOrder.objects.select_related("supplier").prefetch_related( "lines", "lines__product", ).all() diff --git a/api/views/reservation.py b/api/views/reservation.py index b226e62..bdadcea 100644 --- a/api/views/reservation.py +++ b/api/views/reservation.py @@ -2,12 +2,16 @@ from django.db.models import Sum from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.filters import OrderingFilter from rest_framework.response import Response +from api.mixins import TranslatableAPIReadMixin from api.permissions import ReadOnlyOrStaff +from api.schema_i18n import OPENAPI_LANGUAGE_QUERY_PARAMETER from api.serializers.reservation import ( StockReservationCreateSerializer, StockReservationSerializer, @@ -20,11 +24,16 @@ from inventory.models import StockRecord, StockReservation from inventory.models.reservation import ReservationStatus from inventory.services.reservation import ReservationService +from tenants.context import get_current_tenant +from tenants.middleware import resolve_tenant_for_request +from tenants.permissions import get_membership _ACTIVE_STATUSES = [ReservationStatus.PENDING, ReservationStatus.CONFIRMED] -class StockReservationViewSet(viewsets.GenericViewSet, +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class StockReservationViewSet(TranslatableAPIReadMixin, + viewsets.GenericViewSet, viewsets.mixins.ListModelMixin, viewsets.mixins.RetrieveModelMixin, viewsets.mixins.CreateModelMixin): @@ -50,6 +59,20 @@ def get_serializer_class(self): return StockReservationCreateSerializer return StockReservationSerializer + def _get_current_tenant(self): + tenant = get_current_tenant() + if tenant is None: + tenant = resolve_tenant_for_request(self.request) + if tenant is None: + raise PermissionDenied("No tenant context available.") + if not get_membership(self.request.user, tenant): + raise PermissionDenied("User does not belong to this tenant.") + return tenant + + def get_queryset(self): + tenant = self._get_current_tenant() + return super().get_queryset().filter(tenant=tenant) + def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -78,7 +101,9 @@ def create(self, request, *args, **kwargs): status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) - output = StockReservationSerializer(reservation) + output = StockReservationSerializer( + reservation, context=self.get_serializer_context(), + ) return Response(output.data, status=status.HTTP_201_CREATED) @action(detail=True, methods=["post"]) @@ -102,7 +127,9 @@ def fulfill(self, request, pk=None): ) reservation.refresh_from_db() - output = StockReservationSerializer(reservation) + output = StockReservationSerializer( + reservation, context=self.get_serializer_context(), + ) return Response(output.data) @action(detail=True, methods=["post"]) @@ -120,7 +147,9 @@ def cancel(self, request, pk=None): ) reservation.refresh_from_db() - output = StockReservationSerializer(reservation) + output = StockReservationSerializer( + reservation, context=self.get_serializer_context(), + ) return Response(output.data) diff --git a/api/views/sales.py b/api/views/sales.py index 057018b..15ccceb 100644 --- a/api/views/sales.py +++ b/api/views/sales.py @@ -6,10 +6,13 @@ from rest_framework.decorators import action from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from sales.models import Customer, Dispatch, SalesOrder from sales.services.sales import SalesService +from api.mixins import TranslatableAPIReadMixin +from api.schema_i18n import OPENAPI_LANGUAGE_QUERY_PARAMETER from api.serializers.sales import ( CustomerSerializer, DispatchSerializer, @@ -18,7 +21,8 @@ from api.views.inventory import TenantScopedInventoryMixin -class CustomerViewSet(TenantScopedInventoryMixin, viewsets.ModelViewSet): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class CustomerViewSet(TranslatableAPIReadMixin, TenantScopedInventoryMixin, viewsets.ModelViewSet): queryset = Customer.objects.all() serializer_class = CustomerSerializer filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] @@ -32,7 +36,12 @@ def get_queryset(self): return super().get_queryset().filter(tenant=tenant) -class SalesOrderViewSet(TenantScopedInventoryMixin, viewsets.ModelViewSet): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class SalesOrderViewSet( + TranslatableAPIReadMixin, + TenantScopedInventoryMixin, + viewsets.ModelViewSet, +): queryset = SalesOrder.objects.select_related("customer").prefetch_related( "lines", "lines__product", ).all() diff --git a/api/views/tenants.py b/api/views/tenants.py index e87e130..ea7cbd3 100644 --- a/api/views/tenants.py +++ b/api/views/tenants.py @@ -3,32 +3,40 @@ from datetime import date from django.http import HttpResponse +from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.generics import ListAPIView, RetrieveUpdateDestroyAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from api.mixins import TranslatableAPIReadMixin from api.permissions import IsPlatformSuperuser +from api.schema_i18n import OPENAPI_LANGUAGE_QUERY_PARAMETER from api.serializers.tenants import TenantMemberSerializer, TenantSerializer from inventory.models import AuditAction from inventory.services.audit import AuditService from tenants.middleware import get_effective_tenant from tenants.models import Tenant, TenantMembership -from tenants.permissions import IsTenantAdmin, IsTenantMember +from tenants.permissions import IsTenantAdmin, IsTenantManager, IsTenantMember from tenants.services import TenantExportService -class CurrentTenantView(APIView): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class CurrentTenantView(TranslatableAPIReadMixin, APIView): """GET / PATCH the current tenant. - All authenticated members can read; only admins/owners can update. + All authenticated members can read. PATCH allows owner, admin, or manager + (writable fields are name, locale, branding — not billing/subscription). """ + def _get_current_tenant(self): + return get_effective_tenant(self.request) + def get_permissions(self): if self.request.method in ("GET", "HEAD", "OPTIONS"): return [IsAuthenticated(), IsTenantMember()] - return [IsAuthenticated(), IsTenantAdmin()] + return [IsAuthenticated(), IsTenantManager()] def get(self, request): tenant = get_effective_tenant(request) @@ -36,7 +44,7 @@ def get(self, request): return Response( {"detail": "No active tenant."}, status=status.HTTP_404_NOT_FOUND, ) - serializer = TenantSerializer(tenant) + serializer = TenantSerializer(tenant, context=self.get_serializer_context()) return Response(serializer.data) def patch(self, request): @@ -45,18 +53,24 @@ def patch(self, request): return Response( {"detail": "No active tenant."}, status=status.HTTP_404_NOT_FOUND, ) - serializer = TenantSerializer(tenant, data=request.data, partial=True) + serializer = TenantSerializer( + tenant, data=request.data, partial=True, context=self.get_serializer_context(), + ) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data) -class TenantMemberListView(ListAPIView): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class TenantMemberListView(TranslatableAPIReadMixin, ListAPIView): """List all members of the current tenant.""" serializer_class = TenantMemberSerializer permission_classes = (IsAuthenticated, IsTenantMember) + def _get_current_tenant(self): + return get_effective_tenant(self.request) + def get_queryset(self): tenant = get_effective_tenant(self.request) if not tenant: @@ -68,7 +82,8 @@ def get_queryset(self): ) -class TenantMemberDetailView(RetrieveUpdateDestroyAPIView): +@extend_schema(parameters=[OPENAPI_LANGUAGE_QUERY_PARAMETER]) +class TenantMemberDetailView(TranslatableAPIReadMixin, RetrieveUpdateDestroyAPIView): """View / update role / remove a tenant member. Only admins and owners can modify or remove members. @@ -77,6 +92,9 @@ class TenantMemberDetailView(RetrieveUpdateDestroyAPIView): serializer_class = TenantMemberSerializer permission_classes = (IsAuthenticated, IsTenantAdmin) + def _get_current_tenant(self): + return get_effective_tenant(self.request) + def get_queryset(self): tenant = get_effective_tenant(self.request) if not tenant: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 71a82d1..2a782ef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,14 +7,16 @@ This document describes the technical design of **The Inventory** — an open-so ## High-Level Overview ``` -┌─────────────────────────────────────────────────┐ -│ Browser / Client │ -└────────────────────────┬────────────────────────┘ - │ - ┌──────────────┴──────────────┐ - │ Django / Wagtail │ - │ (WSGI server) │ - └──────────────┬──────────────┘ +┌──────────────────────────────────────────────────────────────┐ +│ Browser / clients │ +└─────────────┬───────────────────────────────┬────────────────┘ + │ │ + ┌──────────▼──────────┐ ┌──────────▼──────────┐ + │ Next.js frontend │ │ Wagtail / Django │ + │ (tenant inventory) │ JSON │ admin (platform) │ + └──────────┬──────────┘ REST └──────────┬──────────┘ + │ ┌─────────────────────┘ + └──────────┤ Django / Wagtail (WSGI) │ ┌───────────┬───────┴───────┬───────────┐ │ │ │ │ @@ -30,7 +32,16 @@ This document describes the technical design of **The Inventory** — an open-so └─────────┘ ``` -Users interact with The Inventory primarily through the **Wagtail admin** interface. The standard Django admin is available at `/django-admin/` for low-level access when needed. +**Who uses which surface** + +| Audience | Primary UI | Protocol | +| -------- | ---------- | -------- | +| **Tenant companies** (inventory operators) | [frontend/](../frontend/) (Next.js) | **DRF** at `/api/v1/` (JWT and related auth) | +| **Platform / internal staff** | **Wagtail admin** at `/admin/` | Session, reports, tenant management, imports; **optional** registered snippets for inventory entities when you want monitoring or support tooling in admin | + +**Current default:** inventory listing menus are not in Wagtail ([inventory/wagtail_hooks.py](../inventory/wagtail_hooks.py)). **Evolving the architecture** to register Product, Category, or related models as **tenant-scoped** snippets (see [tenants/snippets.py](../tenants/snippets.py)) is fine for **staff monitoring, audits, or translation workflows**—as long as **tenant companies** still treat the **Next.js app + `/api/v1/`** as their **primary** place for day-to-day inventory operations. Domain models use Wagtail-friendly patterns (`panels`, `ClusterableModel`, images) because the stack is Wagtail-based; that does not imply admin is the main operator UI. + +The standard Django admin remains at `/django-admin/` for low-level access when needed. --- @@ -40,7 +51,7 @@ Users interact with The Inventory primarily through the **Wagtail admin** interf |---|---|---| | Language | Python 3.12+ | | | Web framework | Django 6.0 | | -| CMS / Admin UI | Wagtail 7.3 | Primary interface for all CRUD | +| CMS / Admin UI | Wagtail 7.3 | Platform/staff UI; tenant inventory CRUD via API + Next.js | | Database | SQLite (dev) / PostgreSQL (prod) | Split settings pattern | | Search | Wagtail search (database backend) | Elasticsearch planned for Phase 4 | | Tagging | `django-taggit` | Bundled with Wagtail | @@ -56,37 +67,28 @@ Users interact with The Inventory primarily through the **Wagtail admin** interf ### Frontend / UI Stack -The backend operates as a **headless API server** serving JSON data to a separate modern frontend, while retaining the Wagtail admin UI for staff. Wagtail admin templates remain for internal CMS use. +The backend is a **headless API** for **tenant** applications: the **[frontend/](../frontend/)** Next.js app calls **DRF** at `/api/v1/`. **Wagtail admin** serves **platform** workflows (tenants, reports under `/admin/`, imports, dashboards)—not the main path for tenant inventory operators. | Layer | Technology | Role | |---|---|---| -| Interactivity | [HTMX](https://htmx.org/) | Server-driven partial page updates, live search, inline editing | -| Client-side state | [Alpine.js](https://alpinejs.dev/) | Dropdowns, modals, toggles, small reactive state | -| Styling | [Tailwind CSS](https://tailwindcss.com/) | Utility-first CSS for custom views | -| Admin CRUD | Wagtail Snippets | Built-in model editing UI with permissions | - -**Why this stack?** +| Tenant app | **Next.js** (App Router) | Primary inventory UX for companies; consumes REST API | +| API | **Django REST Framework** | CRUD, auth, OpenAPI (`drf-spectacular`) | +| Platform admin | **Wagtail 7** | Staff UI, reports; inventory snippets **if registered** (monitoring/support—not primary for tenant operators) | +| Legacy / optional templates | Django templates, **HTMX**, **Alpine.js**, **Tailwind** | Some reports, imports, or custom views under Wagtail—not the tenant Next.js app | -- Inventory management is **CRUD-heavy and staff-facing** — naturally fits Django's request-response model. -- HTMX gives SPA-like responsiveness (partial updates, debounced search, form submissions) without a JavaScript framework. -- Alpine.js handles the small bits of client-side state (expand/collapse, confirm dialogs) that HTMX doesn't cover. -- Tailwind CSS provides a modern look for custom views without fighting Django's template system. -- No npm, no webpack, no Vite — HTMX and Alpine.js are loaded via `