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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 66 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<service>` 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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
42 changes: 41 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<service>` 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
Expand All @@ -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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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.

Expand Down
10 changes: 0 additions & 10 deletions backend/.dockerignore

This file was deleted.

9 changes: 6 additions & 3 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions backend/Dockerfile.dockerignore
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion backend/config/asgi.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 19 additions & 0 deletions backend/config/middleware.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions backend/config/settings.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
# 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

BASE_DIR = Path(__file__).resolve().parent.parent


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

Expand All @@ -20,13 +32,25 @@ 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
return value.strip().lower() in {"1", "true", "yes", "on"}


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()]

Expand Down
5 changes: 5 additions & 0 deletions backend/config/urls.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion backend/config/wsgi.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 13 additions & 1 deletion backend/formy/admin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
# 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


@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",)}
Expand All @@ -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")

9 changes: 8 additions & 1 deletion backend/formy/apps.py
Original file line number Diff line number Diff line change
@@ -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"

5 changes: 5 additions & 0 deletions backend/formy/constants.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading