diff --git a/AGENTS.md b/AGENTS.md index f7e06d8..6ddc261 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,19 +6,24 @@ in the current conversation, the user instruction wins, but flag the conflict. ## What this project is -Formy is a form builder and submission platform: a Django REST Framework backend (`backend/`) -and a React + Vite + Tailwind frontend (`frontend/`). The backend is written as a self-contained, -reusable Django app (`backend/formy`) plus a thin deployment project (`backend/config`), so it can -be copied into another Django project. See `docs/architecture.md` for how the pieces fit together -and `docs/` in general before assuming how something works. +Formy is a form builder and submission platform: a Django REST Framework backend (`backend/`), +a React + Vite + Tailwind frontend (`frontend/`), and a top-level `label-universe/` directory that +both of them read UI copy from. The backend is written as a self-contained, reusable Django app +(`backend/formy`) plus a thin deployment project (`backend/config`), so it can be copied into +another Django project. See `docs/architecture.md` for how the pieces fit together and `docs/` in +general before assuming how something works. ## Ground rules - Do not use em dashes (`—`) or en dashes (`–`) anywhere: code, comments, docs, commit messages, PR descriptions, changelog entries. Use a period, comma, colon, parentheses, or "and"/"or" instead. This is a standing preference, not a one-off request. -- Do not write comments that explain what code does. Only write a comment when the reason is - non-obvious (a workaround, an invariant, a constraint that would surprise a reader). +- Every public function, method, and exported frontend function/component gets a docstring + (Python) or JSDoc block (JavaScript/JSX). See "Docstring convention" below for the exact shape. + A one-line file header comment describing what the file contains goes at the top of every + backend and frontend source file. Do not write comments inside a function body that just narrate + what the next line does; only comment there when the reason is non-obvious (a workaround, an + invariant, a constraint that would surprise a reader). - Do not add abstractions, config flags, or generalized helpers for a single call site. Match the existing style in the file you are editing rather than introducing a new pattern. - Do not add error handling for cases that cannot happen given Django/DRF's own guarantees. @@ -28,6 +33,24 @@ and `docs/` in general before assuming how something works. imports from `config` into `formy`, and do not hardcode anything that assumes this specific deployment (domains, ports, `config.settings` values that are not passed through `settings.py` in a generic way). +- Business logic and validation belong in `backend/formy/services.py`, not in views. A view's job + is to parse the request, call a service function, and translate whatever it raises into an HTTP + response. Domain-rule violations (as opposed to plain field validation) get their own exception + class in `backend/formy/exceptions.py`, following the existing ones there. +- Any new user-facing string (button label, heading, notice, error message) goes in + `label-universe/labels.json`, never hardcoded inline. Add the key with all three languages + (`en`, `es`, `zh`) at once, never just `en`. Frontend components read it through + `useTranslation()` (`frontend/src/lib/i18n.jsx`); backend default messages read the English + string through `LABELS` (`backend/formy/labels.py`). See `label-universe/README.md` for the key + naming convention and what counts as UI copy versus user-authored content that stays out of it. + The embeddable widget (`frontend/src/embed/main.js`) has no signed-in user, so it always reads + the English string directly rather than going through `useTranslation()`. +- Both `Dockerfile`s build with the repository root as context (see `docker-compose.yml`), not + their own directory, so they can copy in `label-universe/` alongside `backend/`/`frontend/`. If + you edit either `Dockerfile`, keep each service nested under `/app/` in the image so the + relative path from source to `label-universe/` matches on disk and in the container; do not + revert to a flat `/app` layout for one service without the other, or the label loader's relative + path breaks. ## Before committing anything @@ -89,6 +112,40 @@ which reads the top dated version section, tags it (`vX.Y.Z`), and publishes a G that section's body as the release notes automatically. There is nothing else to run by hand, and nothing to do if you are not asked to cut a release. Do not manually create tags or GitHub releases. +## Docstring convention + +Backend (Python), every public function and method: + +```python +def get_user(conn, user_id): + """ + One-line summary of what this does. + :param user_id: what it is and any constraints + :return: what is returned and its shape + :errors: ExceptionClassA, ExceptionClassB (only if it can raise something a caller should + expect and handle; omit this line entirely if it cannot) + """ +``` + +Exception classes follow the shape already in `backend/formy/exceptions.py`: a one-line class-level +purpose (when it is raised), plus a `:param message:` line on `__init__`. + +Frontend (JavaScript/JSX), every exported function and component gets a JSDoc block directly above +it (`@param`, `@returns`); component props are documented as `@param {type} props.name`. Purely +internal one-off inline handlers (an `onClick` defined inline in JSX) do not need their own JSDoc, +but a named helper function does. + +Every source file (backend and frontend) starts with: + +``` +# By: Md. Fahim Bin Amin +# +# This file contains ... (one or two lines describing what the file contains) +``` + +(`//` instead of `#` in JavaScript/JSX files.) Follow the exact pattern already used across +`backend/formy/*.py`, `backend/config/*.py`, and `frontend/src/**/*.{js,jsx}`. + ## Testing conventions - Backend tests live in `backend/formy/tests.py` using DRF's `APITestCase`. Follow the existing @@ -97,8 +154,8 @@ nothing to do if you are not asked to cut a release. Do not manually create tags `@override_settings(MEDIA_ROOT=tempfile.mkdtemp())` so it never writes into real media storage. - Anything validating file uploads should assume Django's `ImageField` only checks the file extension, not the actual content. If you add another upload field, validate the decoded content - (see `AvatarUploadView` in `backend/formy/views.py` for the pattern) rather than trusting the - extension or the client-supplied content type. + (see `services.validate_avatar_upload` in `backend/formy/services.py` for the pattern) rather + than trusting the extension or the client-supplied content type. ## Docs diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5188f..d8b9e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,44 @@ See `AGENTS.md` for the exact steps. ## [Unreleased] +## [0.3.0] - 2026-07-10 + +### Added + +- `label-universe/`: a shared UI copy registry (`labels.json`) read by both the backend + (`backend/formy/labels.py`) and frontend (`frontend/src/lib/i18n.jsx`), so button labels, + headings, notices, and error messages are defined once instead of hardcoded per file. +- Frontend localization into English, Spanish, and Chinese. `UserProfile.language` (default `en`) + stores each account's preference, editable from `/profile` and read/written through + `GET`/`PATCH /api/auth/profile/`. `LanguageProvider`/`useTranslation()` apply it across every + authenticated page; `Layout` syncs it from the fetched profile, and it is cached in + `localStorage` so it applies before that request resolves. + +### Changed + +- Both `Dockerfile`s now build with the repository root as their context instead of their own + directory, so they can copy `label-universe/` in alongside `backend/`/`frontend/`; each nests its + service under `/app/` in the image to keep the same relative path to `label-universe/` + that exists on disk. See the new `Dockerfile.dockerignore` files and `docker-compose.yml`. + +## [0.2.0] - 2026-07-10 + +### Added + +- `backend/formy/exceptions.py`: domain exception layer (`FormNotAcceptingSubmissions`, + `MissingCredentials`, `UsernameAlreadyTaken`, `AvatarTooLarge`, `InvalidAvatarFile`) separating + business-rule violations from plain field validation. +- `services.register_user` and `services.validate_avatar_upload`, moving registration and avatar + validation out of views and into the service layer alongside `create_submission`. + +### Changed + +- Views now only parse requests, call a service function, and translate whatever it raises into an + HTTP response; `RegisterView` and `AvatarUploadView` no longer contain business logic directly. +- Added docstrings (Python, `:param`/`:return`/`:errors`) and JSDoc (`@param`/`@returns`) to every + public function, method, and exported frontend function/component, plus a file header comment on + every backend and frontend source file. See `AGENTS.md` for the exact convention. + ## [0.1.0] - 2026-07-10 ### Added @@ -35,5 +73,7 @@ See `AGENTS.md` for the exact steps. - CI on every pull request: Python lint (ruff), Django test suite, JS lint (eslint), and frontend build checks. -[Unreleased]: https://github.com/FahimFBA/formy/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/FahimFBA/formy/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/FahimFBA/formy/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/FahimFBA/formy/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/FahimFBA/formy/releases/tag/v0.1.0 diff --git a/README.md b/README.md index f02dd7f..02a77f0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ frontend/ src/ React application (pages, components, api client) package.json tailwind.config.js +label-universe/ Shared UI copy (en/es/zh) read by both backend and frontend docker-compose.yml Postgres, Redis, backend, and frontend for a full local/production-like stack .github/workflows/ CI (Python lint, backend tests, JS lint, frontend build) and automated releases ``` @@ -43,6 +44,8 @@ resetting a forgotten password). - `docs/integration-guide.md`: the three ways to reuse Formy elsewhere: the embeddable widget, the REST API from your own frontend, or vendoring the `formy` Django app into an existing project. - `docs/architecture.md`: how the backend and frontend are put together. +- `label-universe/README.md`: the shared UI copy registry (English, Spanish, Chinese) both sides + read from, and how to add a string or a fourth language. - `AGENTS.md`: conventions and rules for anyone (human or AI agent) changing this codebase. - `CHANGELOG.md`: notable changes per release, and how releases are cut. diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index f4bbf06..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,10 +0,0 @@ -.venv/ -__pycache__/ -*.py[cod] -db.sqlite3 -staticfiles/ -.env -.env.* -!.env.example -.git -.pytest_cache/ diff --git a/backend/Dockerfile b/backend/Dockerfile index df2ea1f..dba9ddb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,10 +9,13 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends libpq5 \ && rm -rf /var/lib/apt/lists/* -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY backend/requirements.txt ./backend/requirements.txt +RUN pip install --no-cache-dir -r backend/requirements.txt -COPY . . +COPY backend/ ./backend/ +COPY label-universe/ ./label-universe/ + +WORKDIR /app/backend RUN python manage.py collectstatic --noinput diff --git a/backend/Dockerfile.dockerignore b/backend/Dockerfile.dockerignore new file mode 100644 index 0000000..c72df9e --- /dev/null +++ b/backend/Dockerfile.dockerignore @@ -0,0 +1,12 @@ +frontend/ +backend/.venv/ +backend/**/__pycache__/ +backend/**/*.py[cod] +backend/db.sqlite3 +backend/staticfiles/ +backend/media/ +backend/.env +backend/.env.* +!backend/.env.example +backend/.pytest_cache/ +.git diff --git a/backend/config/asgi.py b/backend/config/asgi.py index 3a5b1ae..e044083 100644 --- a/backend/config/asgi.py +++ b/backend/config/asgi.py @@ -1,4 +1,7 @@ -"""ASGI config for the Formy project.""" +# By: Md. Fahim Bin Amin + +# This file contains the ASGI entry point for the config project. + import os from django.core.asgi import get_asgi_application diff --git a/backend/config/middleware.py b/backend/config/middleware.py index 8387001..c70c50c 100644 --- a/backend/config/middleware.py +++ b/backend/config/middleware.py @@ -1,11 +1,26 @@ +# By: Md. Fahim Bin Amin + +# This file contains a small CORS middleware for cross-origin frontend deployments +# (see DJANGO_CORS_ALLOWED_ORIGINS). Not needed when nginx serves the frontend and +# backend same-origin, as docker-compose.yml does. + from django.conf import settings class SimpleCorsMiddleware: + """ + Adds CORS headers for origins listed in settings.FORMY_CORS_ALLOWED_ORIGINS, and + short-circuits preflight OPTIONS requests to a bare 204. + """ + def __init__(self, get_response): self.get_response = get_response def __call__(self, request): + """ + :param request: the current request + :return: (HttpResponse) the downstream response, with CORS headers attached + """ if request.method == "OPTIONS": response = self.get_response(request) response.status_code = 204 @@ -16,6 +31,10 @@ def __call__(self, request): return response def add_cors_headers(self, request, response): + """ + :param request: the current request, used for its Origin header + :param response: the response to attach CORS headers to, mutated in place + """ origin = request.headers.get("Origin") if origin and origin in settings.FORMY_CORS_ALLOWED_ORIGINS: diff --git a/backend/config/settings.py b/backend/config/settings.py index 44f09db..398a79f 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,3 +1,9 @@ +# By: Md. Fahim Bin Amin + +# This file contains the Django settings for the config project. Values that differ +# between local development, Docker, and production are read from the environment +# (see .env.example), with safe local-dev defaults where possible. + import os from pathlib import Path @@ -5,6 +11,12 @@ def load_env_file(path: Path) -> None: + """ + Loads KEY=VALUE pairs from a .env file into os.environ, without overwriting values + already set in the real environment. A no-op if path does not exist, so Docker and + production (which set real environment variables) are unaffected. + :param path: path to the .env file to load + """ if not path.exists(): return @@ -20,6 +32,12 @@ def load_env_file(path: Path) -> None: def env_bool(name: str, default: bool = False) -> bool: + """ + :param name: environment variable name + :param default: (bool) value to use if the variable is unset + :return: (bool) True if the variable is set to "1", "true", "yes", or "on" + (case-insensitive), False for any other set value, default if unset + """ value = os.environ.get(name) if value is None: return default @@ -27,6 +45,12 @@ def env_bool(name: str, default: bool = False) -> bool: def env_list(name: str, default: str = "") -> list[str]: + """ + :param name: environment variable name + :param default: (str) comma-separated fallback used if the variable is unset + :return: (list of str) the variable's value split on commas, with items trimmed + and empty items dropped + """ raw_value = os.environ.get(name, default) return [item.strip() for item in raw_value.split(",") if item.strip()] diff --git a/backend/config/urls.py b/backend/config/urls.py index a4de117..e090444 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -1,3 +1,8 @@ +# By: Md. Fahim Bin Amin + +# This file contains the project-level URL root: the Django admin, formy's API under +# /api/, and a direct media file server for user-uploaded avatars. + from django.conf import settings from django.contrib import admin from django.urls import include, path, re_path diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py index d564fa5..5286aeb 100644 --- a/backend/config/wsgi.py +++ b/backend/config/wsgi.py @@ -1,4 +1,7 @@ -"""WSGI config for the Formy project.""" +# By: Md. Fahim Bin Amin + +# This file contains the WSGI entry point for the config project, used by gunicorn. + import os from django.core.wsgi import get_wsgi_application diff --git a/backend/formy/admin.py b/backend/formy/admin.py index aa02ada..14a5e6a 100644 --- a/backend/formy/admin.py +++ b/backend/formy/admin.py @@ -1,3 +1,7 @@ +# By: Md. Fahim Bin Amin + +# This file contains the Django admin registrations for formy's models. + from django.contrib import admin from .models import Form, FormSubmission @@ -5,6 +9,10 @@ @admin.register(Form) class FormAdmin(admin.ModelAdmin): + """ + Admin list/search/filter configuration for Form. + """ + list_display = ("name", "slug", "owner", "status", "updated_at") list_filter = ("status",) prepopulated_fields = {"slug": ("name",)} @@ -14,8 +22,12 @@ class FormAdmin(admin.ModelAdmin): @admin.register(FormSubmission) class FormSubmissionAdmin(admin.ModelAdmin): + """ + Admin list/search/filter configuration for FormSubmission. submitted_at is read-only + since submissions should only be created through services.create_submission. + """ + list_display = ("form", "submitted_at") list_filter = ("form",) readonly_fields = ("submitted_at",) search_fields = ("form__name", "form__slug") - diff --git a/backend/formy/apps.py b/backend/formy/apps.py index 836db2d..284317a 100644 --- a/backend/formy/apps.py +++ b/backend/formy/apps.py @@ -1,8 +1,15 @@ +# By: Md. Fahim Bin Amin + +# This file contains the Django app config for formy. + from django.apps import AppConfig class FormyConfig(AppConfig): + """ + App config for the reusable formy Django app. + """ + default_auto_field = "django.db.models.BigAutoField" name = "formy" verbose_name = "Formy" - diff --git a/backend/formy/constants.py b/backend/formy/constants.py index 3f51633..ff39eb5 100644 --- a/backend/formy/constants.py +++ b/backend/formy/constants.py @@ -1 +1,6 @@ +# By: Md. Fahim Bin Amin + +# This file contains shared constant values used across formy's models, views, and +# tests, so a value like the honeypot field name only needs to change in one place. + HONEYPOT_FIELD = "hp_url" diff --git a/backend/formy/exceptions.py b/backend/formy/exceptions.py new file mode 100644 index 0000000..4112253 --- /dev/null +++ b/backend/formy/exceptions.py @@ -0,0 +1,68 @@ +# By: Md. Fahim Bin Amin + +# This file contains the domain-specific exceptions raised by formy's service layer +# (services.py). They exist to separate business-rule violations from plain field +# validation, which stays as django.core.exceptions.ValidationError raised by model +# clean() methods and serializer validate_*() methods. Views catch these and translate +# them into DRF error responses; see AGENTS.md for the full convention. Default +# messages come from label-universe/labels.json (see labels.py) rather than being +# hardcoded here. + +from .labels import LABELS + + +class FormNotAcceptingSubmissions(Exception): + + def __init__(self, message=LABELS["err_form_not_accepting_submissions"]): + """ + Raised when a submission is attempted against a form that is not published. + :param message: human-readable explanation returned to the API caller + """ + self.message = message + super().__init__(message) + + +class MissingCredentials(Exception): + + def __init__(self, message=LABELS["err_missing_credentials"]): + """ + Raised during registration when the username or password is missing. + :param message: human-readable explanation returned to the API caller + """ + self.message = message + super().__init__(message) + + +class UsernameAlreadyTaken(Exception): + + def __init__(self, message=LABELS["err_username_taken"]): + """ + Raised during registration when the requested username already belongs to + another account. + :param message: human-readable explanation returned to the API caller + """ + self.message = message + super().__init__(message) + + +class AvatarTooLarge(Exception): + + def __init__(self, message=LABELS["err_avatar_too_large"]): + """ + Raised when an uploaded avatar exceeds settings.MAX_AVATAR_UPLOAD_BYTES. + :param message: human-readable explanation returned to the API caller + """ + self.message = message + super().__init__(message) + + +class InvalidAvatarFile(Exception): + + def __init__(self, message=LABELS["err_avatar_invalid"]): + """ + Raised when an uploaded avatar fails to decode as an actual image, regardless of + its file extension or client-supplied content type. + :param message: human-readable explanation returned to the API caller + """ + self.message = message + super().__init__(message) diff --git a/backend/formy/labels.py b/backend/formy/labels.py new file mode 100644 index 0000000..2ed59b3 --- /dev/null +++ b/backend/formy/labels.py @@ -0,0 +1,15 @@ +# By: Md. Fahim Bin Amin +# +# Loads the shared label-universe/labels.json registry so default error and response +# messages live in one place instead of being hardcoded across services/views/serializers. +# The API itself does not localize per request, so this exposes the English ("en") +# string for each key; the frontend uses the same file's es/zh strings directly. See +# label-universe/README.md. + +import json +from pathlib import Path + +_LABELS_PATH = Path(__file__).resolve().parent.parent.parent / "label-universe" / "labels.json" +_RAW_LABELS = json.loads(_LABELS_PATH.read_text()) + +LABELS = {key: value["en"] for key, value in _RAW_LABELS.items()} diff --git a/backend/formy/migrations/0005_userprofile_language.py b/backend/formy/migrations/0005_userprofile_language.py new file mode 100644 index 0000000..f29865c --- /dev/null +++ b/backend/formy/migrations/0005_userprofile_language.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.16 on 2026-07-09 20:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('formy', '0004_userprofile'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='language', + field=models.CharField(choices=[('en', 'English'), ('es', 'Spanish'), ('zh', 'Chinese')], default='en', max_length=8), + ), + ] diff --git a/backend/formy/models.py b/backend/formy/models.py index 9df8b7e..aed6ab8 100644 --- a/backend/formy/models.py +++ b/backend/formy/models.py @@ -1,3 +1,10 @@ +# By: Md. Fahim Bin Amin + +# This file contains the domain model: Form (the portable JSON schema and its +# publication state), FormSubmission (a normalized submission plus the schema version +# that was live at submit time), UserProfile (per-user extras that do not belong on +# AUTH_USER_MODEL), and the schema/submission-data validators both models rely on. + import uuid from django.conf import settings @@ -7,6 +14,11 @@ class Form(models.Model): + """ + A form definition: its schema, publication status, and ownership. owner is nullable + so the app works whether or not the host project assigns forms to a user. + """ + class Status(models.TextChoices): DRAFT = "draft", "Draft" PUBLISHED = "published", "Published" @@ -37,9 +49,18 @@ def __str__(self) -> str: return self.name def clean(self) -> None: + """ + :errors: django.core.exceptions.ValidationError if schema is malformed + """ validate_form_schema(self.schema) def save(self, *args, **kwargs) -> None: + """ + Auto-increments schema_version whenever schema actually changes, so + FormSubmission.schema_version can record which version was live at submit time + without editing a form's fields retroactively changing how old submissions are + interpreted. + """ if not self._state.adding: previous_schema = Form.objects.filter(pk=self.pk).values_list("schema", flat=True).first() if previous_schema is not None and previous_schema != self.schema: @@ -48,10 +69,18 @@ def save(self, *args, **kwargs) -> None: @property def is_published(self) -> bool: + """ + :return: (bool) True if this form currently accepts submissions + """ return self.status == self.Status.PUBLISHED class FormSubmission(models.Model): + """ + A single submitted response to a Form, storing the schema_version that was live at + submit time so later edits to the form's fields do not change how this row reads. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) form = models.ForeignKey(Form, related_name="submissions", on_delete=models.CASCADE) data = models.JSONField(default=dict) @@ -66,12 +95,28 @@ def __str__(self) -> str: return f"{self.form.slug} submission {self.submitted_at:%Y-%m-%d %H:%M}" def clean(self) -> None: + """ + :errors: django.core.exceptions.ValidationError if data does not satisfy the + form's current schema (required fields, types, and so on) + """ validate_submission_data(self.form.schema, self.data) class UserProfile(models.Model): + """ + Per-user extras that do not belong on AUTH_USER_MODEL itself: an optional avatar + image (see services.validate_avatar_upload for how uploads are verified before a + UserProfile is saved) and the user's UI language preference. + """ + + class Language(models.TextChoices): + ENGLISH = "en", "English" + SPANISH = "es", "Spanish" + CHINESE = "zh", "Chinese" + user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="profile", on_delete=models.CASCADE) avatar = models.ImageField(upload_to="avatars/", blank=True, null=True) + language = models.CharField(max_length=8, choices=Language.choices, default=Language.ENGLISH) def __str__(self) -> str: return f"{self.user.username} profile" @@ -81,6 +126,13 @@ def __str__(self) -> str: def validate_form_schema(schema: dict) -> None: + """ + Validates a form's schema shape: a dict with a "fields" list, each field having a + unique string name, a supported type, a string label, and (for "select") a + non-empty options list. + :param schema: (dict) the schema to validate + :errors: django.core.exceptions.ValidationError on any malformed field + """ if not isinstance(schema, dict): raise ValidationError("Form schema must be an object.") @@ -115,6 +167,14 @@ def validate_form_schema(schema: dict) -> None: def validate_submission_data(schema: dict, data: dict) -> None: + """ + Validates that submitted data satisfies a form's schema: schema itself must be + valid, data must be an object, and every required field must have a non-empty value. + :param schema: (dict) the form's current schema + :param data: (dict) submitted field values, keyed by field name + :errors: django.core.exceptions.ValidationError if schema is malformed or a + required field is missing/empty + """ validate_form_schema(schema) if not isinstance(data, dict): @@ -126,4 +186,3 @@ def validate_submission_data(schema: dict, data: dict) -> None: if required and data.get(name) in (None, "", []): raise ValidationError(f"{field['label']} is required.") - diff --git a/backend/formy/permissions.py b/backend/formy/permissions.py index 6e9bbf7..dbe3c15 100644 --- a/backend/formy/permissions.py +++ b/backend/formy/permissions.py @@ -1,6 +1,20 @@ +# By: Md. Fahim Bin Amin + +# This file contains DRF permission classes used by formy's views. + from rest_framework.permissions import BasePermission class IsOwner(BasePermission): + """ + Restricts object-level access to the user who owns the object. + """ + def has_object_permission(self, request, view, obj): + """ + :param request: the current request, used for its authenticated user + :param view: the view this permission is attached to + :param obj: the model instance being accessed; must have an owner_id attribute + :return: (bool) True if the requesting user owns obj + """ return obj.owner_id == request.user.id diff --git a/backend/formy/serializers.py b/backend/formy/serializers.py index 5c7c066..4becbc4 100644 --- a/backend/formy/serializers.py +++ b/backend/formy/serializers.py @@ -1,13 +1,25 @@ +# By: Md. Fahim Bin Amin + +# This file contains the DRF serializers exposed under /api/: form CRUD and its public +# read-only counterpart, submissions (read-only, created through services.create_submission +# instead of this layer), and the account-management serializers used by ProfileView, +# ChangePasswordView, and RegisterView. + from django.contrib.auth import get_user_model from django.contrib.auth.password_validation import validate_password from rest_framework import serializers -from .models import Form, FormSubmission, validate_form_schema +from .labels import LABELS +from .models import Form, FormSubmission, UserProfile, validate_form_schema User = get_user_model() class FormSerializer(serializers.ModelSerializer): + """ + Authenticated CRUD representation of a form, owned by the requesting user. + """ + class Meta: model = Form fields = [ @@ -25,17 +37,33 @@ class Meta: read_only_fields = ["id", "schema_version", "created_at", "updated_at"] def validate_schema(self, value): + """ + :param value: (dict) the incoming schema value + :return: value, unchanged, once it passes validation + :errors: rest_framework.serializers.ValidationError (DRF wraps the underlying + django.core.exceptions.ValidationError raised by validate_form_schema) + """ validate_form_schema(value) return value class PublicFormSerializer(serializers.ModelSerializer): + """ + Anonymous, read-only representation of a published form. Excludes owner, status, + schema_version, and timestamps, none of which an anonymous visitor needs. + """ + class Meta: model = Form fields = ["id", "name", "slug", "description", "schema", "success_message"] class FormSubmissionSerializer(serializers.ModelSerializer): + """ + Read-only representation of a submission. All fields are read-only because + submissions are created through services.create_submission, not this serializer. + """ + class Meta: model = FormSubmission fields = ["id", "data", "metadata", "schema_version", "submitted_at"] @@ -43,17 +71,33 @@ class Meta: class SubmissionCreateSerializer(serializers.Serializer): + """ + Validates the shape of an incoming public submission payload before it reaches + services.create_submission. + """ + data = serializers.JSONField(default=dict) class UserSerializer(serializers.ModelSerializer): + """ + Representation of the authenticated user's own account, including a resolved + absolute avatar URL and the user's UI language preference, both stored on + UserProfile rather than on the user model itself. + """ + avatar_url = serializers.SerializerMethodField() + language = serializers.SerializerMethodField() class Meta: model = User - fields = ["username", "first_name", "last_name", "email", "avatar_url"] + fields = ["username", "first_name", "last_name", "email", "avatar_url", "language"] def get_avatar_url(self, user): + """ + :param user: the User instance being serialized + :return: (str or None) absolute avatar URL, or None if the user has no avatar + """ profile = getattr(user, "profile", None) if not profile or not profile.avatar: return None @@ -61,17 +105,64 @@ def get_avatar_url(self, user): url = profile.avatar.url return request.build_absolute_uri(url) if request else url + def get_language(self, user): + """ + :param user: the User instance being serialized + :return: (str) the user's stored language preference ("en", "es", or "zh"), + or UserProfile.Language.ENGLISH if they have no profile row yet + """ + profile = getattr(user, "profile", None) + return profile.language if profile else UserProfile.Language.ENGLISH + + def update(self, instance, validated_data): + """ + Updates the user's own account fields, plus their language preference if + "language" was included in the request. language is read from initial_data + rather than validated_data since it is a SerializerMethodField (read-only by + default) and lives on UserProfile, not on the User model this serializer wraps. + :param instance: the User instance being updated + :param validated_data: validated User model fields (username, first_name, and so on) + :return: the updated User instance + :errors: rest_framework.serializers.ValidationError if language is not one of + UserProfile.Language's values + """ + language = self.initial_data.get("language") + if language is not None: + if language not in UserProfile.Language.values: + raise serializers.ValidationError({"language": LABELS["err_unsupported_language"]}) + profile, _ = UserProfile.objects.get_or_create(user=instance) + profile.language = language + profile.save() + + return super().update(instance, validated_data) + class ChangePasswordSerializer(serializers.Serializer): + """ + Validates a password-change request: the current password must be correct and the + new password must satisfy Django's configured password validators. + """ + old_password = serializers.CharField(write_only=True) new_password = serializers.CharField(write_only=True) def validate_old_password(self, value): + """ + :param value: (str) the claimed current password + :return: value, unchanged, once it is confirmed correct + :errors: rest_framework.serializers.ValidationError if value does not match the + requesting user's current password + """ user = self.context["request"].user if not user.check_password(value): - raise serializers.ValidationError("Current password is incorrect.") + raise serializers.ValidationError(LABELS["err_current_password_incorrect"]) return value def validate_new_password(self, value): + """ + :param value: (str) the requested new password + :return: value, unchanged, once it passes Django's password validators + :errors: rest_framework.serializers.ValidationError if value is too weak/common + """ validate_password(value) return value diff --git a/backend/formy/services.py b/backend/formy/services.py index 0a0a9de..98086c4 100644 --- a/backend/formy/services.py +++ b/backend/formy/services.py @@ -1,13 +1,41 @@ -from django.core.exceptions import ValidationError +# By: Md. Fahim Bin Amin + +# This file contains the business logic for the formy app: submission creation, avatar +# upload validation, and user registration. Views stay thin and only translate the +# exceptions raised here (see exceptions.py) into HTTP responses; models.py only owns +# schema/field validation, not these higher-level rules. + +from django.conf import settings +from django.contrib.auth import get_user_model from django.db import transaction +from PIL import Image, UnidentifiedImageError +from rest_framework.authtoken.models import Token +from .exceptions import ( + AvatarTooLarge, + FormNotAcceptingSubmissions, + InvalidAvatarFile, + MissingCredentials, + UsernameAlreadyTaken, +) from .models import Form, FormSubmission, validate_submission_data +User = get_user_model() + @transaction.atomic def create_submission(*, form: Form, data: dict, metadata: dict | None = None) -> FormSubmission: + """ + Validates and stores a submission against a form's current schema. + :param form: the Form being submitted to + :param data: (dict) submitted field values, keyed by field name + :param metadata: (dict or None) request context to store alongside the submission + (for example IP address, user agent); defaults to an empty dict + :return: the created FormSubmission + :errors: FormNotAcceptingSubmissions, django.core.exceptions.ValidationError + """ if not form.is_published: - raise ValidationError("This form is not accepting submissions.") + raise FormNotAcceptingSubmissions() validate_submission_data(form.schema, data) submission = FormSubmission( @@ -20,3 +48,43 @@ def create_submission(*, form: Form, data: dict, metadata: dict | None = None) - submission.save() return submission + +def validate_avatar_upload(avatar) -> None: + """ + Validates an uploaded avatar file. Checks the size cap first (cheap) and then + decodes the file with Pillow to confirm it is actually an image, since Django's + ImageField.full_clean() alone only checks the file extension. + :param avatar: uploaded file object (django.core.files.uploadedfile.UploadedFile); + left seeked to the start on both success and failure so callers can read it again + :errors: AvatarTooLarge, InvalidAvatarFile + """ + if avatar.size > settings.MAX_AVATAR_UPLOAD_BYTES: + raise AvatarTooLarge() + + try: + Image.open(avatar).verify() + except (UnidentifiedImageError, OSError) as error: + raise InvalidAvatarFile() from error + finally: + avatar.seek(0) + + +def register_user(*, username: str, password: str, email: str = "") -> tuple: + """ + Creates a new user account and issues an API token for it. + :param username: desired username; surrounding whitespace is stripped + :param password: plaintext password to set on the new account + :param email: (str) optional email address; surrounding whitespace is stripped + :return: (User, Token) tuple for the newly created account + :errors: MissingCredentials, UsernameAlreadyTaken + """ + username = username.strip() + email = email.strip() + if not username or not password: + raise MissingCredentials() + if User.objects.filter(username=username).exists(): + raise UsernameAlreadyTaken() + + user = User.objects.create_user(username=username, email=email, password=password) + token, _ = Token.objects.get_or_create(user=user) + return user, token diff --git a/backend/formy/tests.py b/backend/formy/tests.py index 215c182..49d4a8d 100644 --- a/backend/formy/tests.py +++ b/backend/formy/tests.py @@ -1,3 +1,10 @@ +# By: Md. Fahim Bin Amin + +# This file contains the backend test suite: submission creation/validation rules, +# auth/registration, profile and password management, avatar upload validation, form +# ownership scoping, schema versioning, submission export, honeypot handling, and the +# public submission rate throttle. + import tempfile from django.contrib.auth import get_user_model @@ -9,6 +16,7 @@ from rest_framework.test import APITestCase from .constants import HONEYPOT_FIELD +from .exceptions import FormNotAcceptingSubmissions from .models import Form, FormSubmission from .services import create_submission from .throttling import SubmissionRateThrottle @@ -50,7 +58,7 @@ def test_published_form_accepts_valid_submission(self): def test_unpublished_form_rejects_submission(self): form = Form.objects.create(name="Draft", slug="draft", schema=CONTACT_SCHEMA) - with self.assertRaises(ValidationError): + with self.assertRaises(FormNotAcceptingSubmissions): create_submission(form=form, data={"email": "hello@example.com"}) @@ -98,6 +106,30 @@ def test_can_view_own_profile(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["username"], "alice") + def test_default_language_is_english(self): + self.client.force_authenticate(self.alice) + + response = self.client.get("/api/auth/profile/") + + self.assertEqual(response.json()["language"], "en") + + def test_can_update_language(self): + self.client.force_authenticate(self.alice) + + response = self.client.patch("/api/auth/profile/", {"language": "zh"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["language"], "zh") + self.alice.profile.refresh_from_db() + self.assertEqual(self.alice.profile.language, "zh") + + def test_rejects_unsupported_language(self): + self.client.force_authenticate(self.alice) + + response = self.client.patch("/api/auth/profile/", {"language": "fr"}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_can_update_profile_fields(self): self.client.force_authenticate(self.alice) diff --git a/backend/formy/throttling.py b/backend/formy/throttling.py index 95fa841..4ebbfd8 100644 --- a/backend/formy/throttling.py +++ b/backend/formy/throttling.py @@ -1,10 +1,26 @@ +# By: Md. Fahim Bin Amin + +# This file contains DRF throttle classes used by formy's public endpoints. + from rest_framework.throttling import SimpleRateThrottle class SubmissionRateThrottle(SimpleRateThrottle): + """ + Rate limits public form submissions per form and per client, so one abusive client + cannot exhaust the limit for every other visitor of the same form. Rate is set by + DEFAULT_THROTTLE_RATES["submission"] (FORMY_SUBMISSION_THROTTLE_RATE), and requires + a shared cache (Redis) across processes to count correctly; see settings.py. + """ + scope = "submission" def get_cache_key(self, request, view): + """ + :param request: the current request + :param view: the view this throttle is attached to; must expose a "slug" kwarg + :return: (str) cache key unique per form slug and client identity + """ ident = self.get_ident(request) form_slug = view.kwargs.get("slug", "") return self.cache_format % {"scope": self.scope, "ident": f"{form_slug}:{ident}"} diff --git a/backend/formy/urls.py b/backend/formy/urls.py index a5f1420..aaba387 100644 --- a/backend/formy/urls.py +++ b/backend/formy/urls.py @@ -1,3 +1,10 @@ +# By: Md. Fahim Bin Amin + +# This file contains formy's URL routing, included under /api/ by config/urls.py. +# FormViewSet's CRUD routes (list/create/retrieve/update/delete plus the submissions +# and export actions) are registered through the DRF router; every other endpoint is +# a single-purpose APIView registered explicitly below. + from django.urls import include, path from rest_framework.authtoken.views import obtain_auth_token from rest_framework.routers import DefaultRouter diff --git a/backend/formy/views.py b/backend/formy/views.py index 04d97f5..95d029f 100644 --- a/backend/formy/views.py +++ b/backend/formy/views.py @@ -1,21 +1,24 @@ +# By: Md. Fahim Bin Amin + +# This file contains the DRF views exposed under /api/. Business rules (submission +# creation, avatar validation, registration) live in services.py; views only translate +# requests into service/serializer calls and translate the exceptions raised there into +# HTTP responses. + import csv import io from xml.sax.saxutils import escape -from django.conf import settings -from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils import timezone -from PIL import Image, UnidentifiedImageError from reportlab.lib import colors from reportlab.lib.pagesizes import landscape, letter from reportlab.lib.styles import ParagraphStyle from reportlab.lib.units import inch from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle from rest_framework import generics, serializers, status, viewsets -from rest_framework.authtoken.models import Token from rest_framework.decorators import action from rest_framework.parsers import MultiPartParser from rest_framework.permissions import AllowAny, IsAuthenticated @@ -23,6 +26,14 @@ from rest_framework.views import APIView from .constants import HONEYPOT_FIELD +from .exceptions import ( + AvatarTooLarge, + FormNotAcceptingSubmissions, + InvalidAvatarFile, + MissingCredentials, + UsernameAlreadyTaken, +) +from .labels import LABELS from .models import Form, UserProfile from .permissions import IsOwner from .serializers import ( @@ -33,24 +44,38 @@ SubmissionCreateSerializer, UserSerializer, ) -from .services import create_submission +from .services import create_submission, register_user, validate_avatar_upload from .throttling import SubmissionRateThrottle -User = get_user_model() - class FormViewSet(viewsets.ModelViewSet): + """ + Authenticated CRUD for a user's own forms, plus submissions and export actions. + Scoped to request.user via get_queryset(); IsOwner also guards single-object access. + """ + serializer_class = FormSerializer permission_classes = [IsAuthenticated, IsOwner] def get_queryset(self): + """ + :return: (QuerySet) forms owned by the current user only + """ return Form.objects.filter(owner=self.request.user) def perform_create(self, serializer): + """ + Stamps the new form with the current user as owner. + :param serializer: the validated FormSerializer instance being saved + """ serializer.save(owner=self.request.user) @action(detail=True, methods=["get"]) def submissions(self, request, pk=None): + """ + Lists submissions for a single form, paginated the same way as the forms list. + :return: (Response) paginated list of FormSubmissionSerializer data + """ form = self.get_object() page = self.paginate_queryset(form.submissions.all()) serializer = FormSubmissionSerializer(page, many=True) @@ -58,6 +83,11 @@ def submissions(self, request, pk=None): @action(detail=True, methods=["get"]) def export(self, request, pk=None): + """ + Exports all of a form's submissions as CSV, JSON, or PDF. + :query export_format: "csv" (default), "json", or "pdf" + :return: (Response) a file download for csv/pdf, or a plain JSON array for json + """ form = self.get_object() submissions = form.submissions.all() export_format = request.query_params.get("export_format", "csv") @@ -91,6 +121,13 @@ def export(self, request, pk=None): return self._export_csv(form, header, rows) def _export_csv(self, form, header, rows): + """ + Builds a CSV file download for a form's submissions. + :param form: the Form being exported + :param header: (list of str) column headers + :param rows: (list of list of str) one row per submission + :return: (HttpResponse) text/csv attachment + """ buffer = io.StringIO() writer = csv.writer(buffer) writer.writerow(header) @@ -101,6 +138,14 @@ def _export_csv(self, form, header, rows): return response def _export_pdf(self, form, header, rows): + """ + Builds a formatted PDF table of a form's submissions, landscape when there are + more than 4 columns. + :param form: the Form being exported + :param header: (list of str) column headers + :param rows: (list of list of str) one row per submission + :return: (HttpResponse) application/pdf attachment + """ pagesize = landscape(letter) if len(header) > 4 else letter buffer = io.BytesIO() doc = SimpleDocTemplate( @@ -152,6 +197,10 @@ def _export_pdf(self, form, header, rows): class PublicFormDetailView(generics.RetrieveAPIView): + """ + Anonymous, read-only lookup of a single published form by its slug. + """ + queryset = Form.objects.filter(status=Form.Status.PUBLISHED) serializer_class = PublicFormSerializer permission_classes = [AllowAny] @@ -159,10 +208,21 @@ class PublicFormDetailView(generics.RetrieveAPIView): class PublicSubmitView(APIView): + """ + Anonymous submission endpoint for a published form. Honeypot-protected and rate + limited per form and IP via SubmissionRateThrottle. + """ + permission_classes = [AllowAny] throttle_classes = [SubmissionRateThrottle] def post(self, request, slug): + """ + Accepts a submission for the form identified by slug. + :param slug: the form's slug + :return: (Response) 201 with the new submission id, or a translated 400 if the + form is not accepting submissions or the data fails schema validation + """ form = get_object_or_404(Form, slug=slug) if request.data.get(HONEYPOT_FIELD): @@ -185,6 +245,8 @@ def post(self, request, slug): "user_agent": request.headers.get("User-Agent", ""), }, ) + except FormNotAcceptingSubmissions as error: + raise serializers.ValidationError({"detail": error.message}) from error except DjangoValidationError as error: raise serializers.ValidationError({"detail": error.messages}) from error @@ -195,62 +257,91 @@ def post(self, request, slug): class RegisterView(APIView): + """ + Public registration endpoint: creates a user account and returns an API token. + """ + permission_classes = [AllowAny] def post(self, request): - username = request.data.get("username", "").strip() - password = request.data.get("password", "") - email = request.data.get("email", "").strip() - - if not username or not password: - return Response( - {"detail": "username and password are required."}, - status=status.HTTP_400_BAD_REQUEST, + """ + :body username: desired username + :body password: desired password + :body email: (optional) email address + :return: (Response) 201 with the new account's API token, or 400 if the + username/password are missing or the username is already taken + """ + try: + _, token = register_user( + username=request.data.get("username", ""), + password=request.data.get("password", ""), + email=request.data.get("email", ""), ) - if User.objects.filter(username=username).exists(): - return Response({"detail": "username already taken."}, status=status.HTTP_400_BAD_REQUEST) + except (MissingCredentials, UsernameAlreadyTaken) as error: + return Response({"detail": error.message}, status=status.HTTP_400_BAD_REQUEST) - user = User.objects.create_user(username=username, email=email, password=password) - token, _ = Token.objects.get_or_create(user=user) return Response({"token": token.key}, status=status.HTTP_201_CREATED) class ProfileView(generics.RetrieveUpdateAPIView): + """ + Lets the authenticated user view and update their own account details. + """ + serializer_class = UserSerializer permission_classes = [IsAuthenticated] def get_object(self): + """ + :return: the current request's user, so this endpoint always acts on "self" + """ return self.request.user class ChangePasswordView(APIView): + """ + Lets the authenticated user change their own password. + """ + permission_classes = [IsAuthenticated] def post(self, request): + """ + :body old_password: the account's current password, required to confirm identity + :body new_password: the new password to set + :return: (Response) 200 on success, or 400 if old_password is wrong or + new_password fails Django's password validators + """ serializer = ChangePasswordSerializer(data=request.data, context={"request": request}) serializer.is_valid(raise_exception=True) request.user.set_password(serializer.validated_data["new_password"]) request.user.save() - return Response({"detail": "Password updated."}) + return Response({"detail": LABELS["msg_password_updated"]}) class AvatarUploadView(APIView): + """ + Lets the authenticated user upload or remove their profile avatar. + """ + permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser] def post(self, request): + """ + :body avatar: (multipart file) the image to set as the user's avatar + :return: (Response) 200 with the updated profile, or 400 if the file is missing, + too large, or not a valid image + """ avatar = request.FILES.get("avatar") if not avatar: - return Response({"detail": "avatar file is required."}, status=status.HTTP_400_BAD_REQUEST) - if avatar.size > settings.MAX_AVATAR_UPLOAD_BYTES: - return Response({"detail": "avatar must be 5MB or smaller."}, status=status.HTTP_400_BAD_REQUEST) + return Response({"detail": LABELS["err_avatar_required"]}, status=status.HTTP_400_BAD_REQUEST) try: - Image.open(avatar).verify() - except (UnidentifiedImageError, OSError): - return Response({"detail": "avatar must be a valid image file."}, status=status.HTTP_400_BAD_REQUEST) - avatar.seek(0) + validate_avatar_upload(avatar) + except (AvatarTooLarge, InvalidAvatarFile) as error: + return Response({"detail": error.message}, status=status.HTTP_400_BAD_REQUEST) profile, _ = UserProfile.objects.get_or_create(user=request.user) profile.avatar = avatar @@ -264,6 +355,10 @@ def post(self, request): return Response(serializer.data) def delete(self, request): + """ + Removes the current avatar, if any. + :return: (Response) 200 with the updated profile + """ profile = getattr(request.user, "profile", None) if profile and profile.avatar: profile.avatar.delete(save=False) diff --git a/docker-compose.yml b/docker-compose.yml index 00dd514..7f6f182 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,9 @@ services: retries: 5 backend: - build: ./backend + build: + context: . + dockerfile: backend/Dockerfile environment: DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me-in-production} DJANGO_DEBUG: "False" @@ -46,14 +48,15 @@ services: ports: - "8000:8000" volumes: - - media_data:/app/media + - media_data:/app/backend/media command: > sh -c "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3" frontend: build: - context: ./frontend + context: . + dockerfile: frontend/Dockerfile args: VITE_API_BASE_URL: /api ports: diff --git a/docs/api-reference.md b/docs/api-reference.md index 61a4909..1c78f2c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -46,16 +46,20 @@ Authenticated. Returns the current user's profile. "first_name": "", "last_name": "", "email": "alice@example.com", - "avatar_url": null + "avatar_url": null, + "language": "en" } ``` -`avatar_url` is an absolute URL to the uploaded avatar, or `null` if none has been set. +`avatar_url` is an absolute URL to the uploaded avatar, or `null` if none has been set. `language` +is the account's UI language preference, one of `en` (default), `es`, or `zh`; see +`label-universe/README.md` for how the frontend uses it. ### `PATCH /api/auth/profile/` -Authenticated. Update any of `username`, `first_name`, `last_name`, `email`. Send only the fields -you want to change. Returns the same shape as `GET`. +Authenticated. Update any of `username`, `first_name`, `last_name`, `email`, `language`. Send only +the fields you want to change. Returns the same shape as `GET`. `400` with +`{"language": "Unsupported language."}` if `language` is not `en`, `es`, or `zh`. ### `POST /api/auth/profile/avatar/` diff --git a/docs/architecture.md b/docs/architecture.md index 92f848d..cc35e75 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,7 @@ # Architecture -Formy is split into a reusable Django app and a standalone React client. See `docs/api-reference.md` +Formy is split into a reusable Django app and a standalone React client, plus a top-level +`label-universe/` directory both of them read UI copy from. See `docs/api-reference.md` for the full endpoint list, `docs/local-development.md` and `docs/docker-deployment.md` for how to run it, and `docs/integration-guide.md` for the three ways to embed or reuse it elsewhere. @@ -12,22 +13,37 @@ The `backend/formy` package owns the domain model: `AUTH_USER_MODEL`, so the app works whether or not the host project assigns owners). - `FormSubmission` stores normalized submission payloads and the schema version that was live when the submission was made. -- `UserProfile` stores per-user extras that do not belong on `AUTH_USER_MODEL` itself, currently - just an optional `avatar` image. -- `services.py` keeps write behavior (validation, submission creation) out of views so it can be - reused by other Django projects or management commands. +- `UserProfile` stores per-user extras that do not belong on `AUTH_USER_MODEL` itself: an optional + `avatar` image and a `language` preference (`en`, `es`, or `zh`; defaults to `en`) that drives the + frontend's UI language. +- `labels.py` loads `label-universe/labels.json` and exposes its English strings as `LABELS`, used + for default exception messages and API response text so wording is defined once; see + `label-universe/README.md`. +- `services.py` holds business logic and validation (submission creation, avatar upload + validation, user registration) so views stay thin and this logic can be reused by other Django + projects or management commands. Views call into it and translate whatever it raises into an + HTTP response. +- `exceptions.py` holds the domain-specific exceptions services.py raises for business-rule + violations (`FormNotAcceptingSubmissions`, `UsernameAlreadyTaken`, `AvatarTooLarge`, and so on), + kept separate from plain field validation (`django.core.exceptions.ValidationError`, raised by + model `clean()` methods and serializer `validate_*()` methods). - `serializers.py` / `permissions.py` / `views.py` expose a Django REST Framework API: - `FormViewSet`: authenticated CRUD, scoped to `request.user`'s own forms, plus `submissions` (paginated list) and `export` (CSV, JSON, or a formatted PDF) actions. - `PublicFormDetailView` / `PublicSubmitView`: anonymous read/submit by slug, published forms only. Submission is honeypot-protected (`constants.HONEYPOT_FIELD`, silently dropped if filled) and throttled per form and IP (`throttling.SubmissionRateThrottle`, rate configurable - via `FORMY_SUBMISSION_THROTTLE_RATE`). + via `FORMY_SUBMISSION_THROTTLE_RATE`). Rejects to an unpublished form raise + `exceptions.FormNotAcceptingSubmissions`, which the view translates to a 400. - `RegisterView` + DRF's `obtain_auth_token`: token issuance so the API is usable standalone. + `RegisterView` calls `services.register_user`, which raises `MissingCredentials` or + `UsernameAlreadyTaken` for the view to translate into a 400. - `ProfileView`, `ChangePasswordView`, `AvatarUploadView`: a user managing their own account. - Avatar uploads are validated by actually decoding the file with Pillow, not just checking the - extension or the client-supplied content type, since Django's `ImageField.full_clean()` alone - only checks the extension. + Avatar uploads are validated by `services.validate_avatar_upload`, which actually decodes the + file with Pillow rather than just checking the extension or the client-supplied content type, + since Django's `ImageField.full_clean()` alone only checks the extension. `ProfileView` also + reads and writes `language` through `UserSerializer`, which stores it on `UserProfile` rather + than on the user model itself. - `Form.schema_version` auto-increments (in `Form.save()`) whenever `schema` actually changes. `FormSubmission.schema_version` records which version was live at submit time, so editing a form's fields later does not retroactively change how old submissions are interpreted. @@ -58,11 +74,21 @@ The `frontend/src` app is schema-driven and router-based: Cancel button all dismiss it) used anywhere a destructive action needs confirmation, currently form deletion. - `components/Layout.jsx` renders the current user's avatar (from `GET /api/auth/profile/`) in the - header when logged in, falling back to a placeholder icon if no avatar is set. + header when logged in, falling back to a placeholder icon if no avatar is set. It is also the one + place that syncs the active UI language from the fetched profile, since every authenticated page + renders inside it. +- `lib/i18n.jsx` provides `LanguageProvider` (wraps the whole app in `main.jsx`) and a + `useTranslation()` hook (`t(key, params)`, `language`, `setLanguage`) backed by + `label-universe/labels.json`. The active language is cached in `localStorage` so it applies + before the profile request resolves, and `ProfilePage` lets the user change it, persisted through + `PATCH /api/auth/profile/`. Every UI string in the authenticated app goes through `t()` rather + than being hardcoded; see `label-universe/README.md` for the registry itself. `FormRenderer` stays reusable on its own (embedded widgets or a future standalone package), since it only depends on a schema plus values/callbacks, not on routing or auth state. Both it and the -standalone embed widget (below) share the same honeypot field name from `lib/constants.js`. +standalone embed widget (below) share the same honeypot field name from `lib/constants.js`. The +embed widget has no signed-in user to read a language from, so its own chrome text always renders +in English directly from `label-universe/labels.json`. ### Embeddable widget @@ -71,6 +97,15 @@ standalone embed widget (below) share the same honeypot field name from `lib/con form into any HTML page via `
` plus a `