Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
16 changes: 8 additions & 8 deletions .amazonq/rules/memory-bank/tech.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions .cursor/rules/django-python.mdc
Original file line number Diff line number Diff line change
@@ -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.
```
21 changes: 21 additions & 0 deletions .cursor/rules/frontend-nextjs.mdc
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions .cursor/rules/the-inventory-core.mdc
Original file line number Diff line number Diff line change
@@ -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 **`<Domain>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.
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 4 additions & 0 deletions .github/instrunctions/common.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ cover/

# Translations
*.mo
!locale/**/LC_MESSAGES/*.mo
*.pot

# Django stuff:
Expand Down
105 changes: 105 additions & 0 deletions api/language.py
Original file line number Diff line number Diff line change
@@ -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 a configured Wagtail content language. 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
7 changes: 3 additions & 4 deletions api/middleware.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions api/mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from api.mixins.translatable_read import TranslatableAPIReadMixin

__all__ = ["TranslatableAPIReadMixin"]
65 changes: 65 additions & 0 deletions api/mixins/translatable_read.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions api/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions api/schema_i18n.py
Original file line number Diff line number Diff line change
@@ -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."
),
)
Loading
Loading