diff --git a/.github/prompts/add-django-apidocs.prompt.md b/.github/prompts/add-django-apidocs.prompt.md new file mode 100644 index 00000000..5e683121 --- /dev/null +++ b/.github/prompts/add-django-apidocs.prompt.md @@ -0,0 +1,337 @@ +# Add Django REST Framework OpenAPI Documentation + +Setup and document all API endpoints using drf-spectacular. Execute steps 1-12 sequentially. + +## Critical Rules + +1. **Use actual serializer classes, NEVER strings** (`request=MySerializer` not `request="MySerializer"`) +2. **Consolidate identical response structures** (one `RESPONSE_WITH_STATUS`, not multiple) +3. **All `inline_serializer` go in `openapi.py`**, never in view files +4. **Organize `openapi.py` BEFORE adding decorators** + +## Steps + +### 1. Install + +Add to `requirements.in`: +```txt +drf-spectacular +``` + +Install: +```bash +.venv/bin/pip install drf-spectacular +``` + +### 2. Configure Settings + +In `settings/base.py`, add to `INSTALLED_APPS`: +```python +"drf_spectacular", +``` + +Add/update `REST_FRAMEWORK`: +```python +REST_FRAMEWORK = { + # ... existing settings + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_RENDERER_CLASSES": [ # Required for schema generation + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ], +} +``` + +Add `SPECTACULAR_SETTINGS`: +```python +SPECTACULAR_SETTINGS = { + "TITLE": "API Name", + "DESCRIPTION": "API description", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_PATCH": True, + "SCHEMA_PATH_PREFIX": r"/v1/", # Adjust to match API version + "SCHEMA_PATH_PREFIX_TRIM": True, +} +``` + +### 3. Update URL Routes + +In main `urls.py`: +```python +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +urlpatterns = [ + # ... existing patterns + path( + "api-docs/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), + path( + "api-docs/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path("api-docs/schema/", SpectacularAPIView.as_view(), name="schema"), +] +``` + +### 4. Fix Compatibility + +Search for unsafe `request.accepted_renderer` usage: +```bash +grep -r "request\.accepted_renderer" --include="*.py" . +``` + +Replace with safe pattern: +```python +# Before: +if isinstance(request.accepted_renderer, SomeRenderer): + +# After: +accepted_renderer = getattr(request, "accepted_renderer", None) +if isinstance(accepted_renderer, SomeRenderer): +``` + +### 5. Inventory Endpoints + +Find all views containing: +- `viewsets.ModelViewSet` / `viewsets.ViewSet` +- `APIView` classes +- `@api_view` decorators +- `@action` decorators + +List: HTTP methods, URL patterns, view names, file locations. + +### 6. Add Native Documentation (Priority) + +drf-spectacular auto-documents: + +1. **Model/Serializer `help_text`**: +```python +title = models.CharField(max_length=255, help_text="Hearing title") +n_comments = serializers.SerializerMethodField(help_text="Total comments") +``` + +2. **FilterSet `help_text`**: +```python +title = django_filters.CharFilter( + lookup_expr="icontains", + help_text="Filter by title (case-insensitive)", +) +``` + +3. **Docstrings** (ViewSets, actions): +```python +class HearingViewSet(viewsets.ModelViewSet): + """Manage participatory democracy hearings.""" + + def list(self, request, *args, **kwargs): + """List all hearings with filtering and pagination.""" +``` + +4. **Enum docstrings**: +```python +class Commenting(Enum): + """Commenting modes: NONE, REGISTERED, OPEN, STRONG.""" +``` + +**Implementation order:** +1. FilterSet `help_text` +2. Model field `help_text` +3. Serializer field `help_text` +4. ViewSet/action docstrings (concise) +5. Enum docstrings (concise) + +**Use `@extend_schema` only for:** +- Complex schemas not in serializers +- Custom error responses +- Non-obvious side effects + +### 7. Create/Update openapi.py (BEFORE decorators) + +Create `/democracy/views/openapi.py`: +```python +"""Shared OpenAPI definitions. All inline_serializer go here.""" + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, inline_serializer +from rest_framework import serializers + +# Pagination +PAGINATION_PARAMS = [ + OpenApiParameter("limit", OpenApiTypes.INT, + description="Results per page"), + OpenApiParameter("offset", OpenApiTypes.INT, + description="Offset for pagination"), +] + +# Common responses - consolidate identical structures +RESPONSE_WITH_STATUS = inline_serializer( + name="StatusResponse", + fields={"status": serializers.CharField()}, +) + +# Add more as needed +``` + +**View-specific params stay in view files.** + +### 8. Add @extend_schema Decorators + +**ViewSets:** +```python +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) +from democracy.views.openapi import PAGINATION_PARAMS, RESPONSE_WITH_STATUS +from .serializers import MySerializer, MyCreateSerializer # Import classes + + +@extend_schema_view( + list=extend_schema( + summary="List items", + parameters=PAGINATION_PARAMS + [ + OpenApiParameter("status", OpenApiTypes.STR, + enum=["active", "inactive"]), + ], + ), + retrieve=extend_schema(summary="Get item by ID"), + create=extend_schema( + summary="Create item", + request=MyCreateSerializer, # Actual class + responses={ + 201: MyCreateSerializer, # Actual class + 400: OpenApiResponse(description="Validation error"), + }, + ), + update=extend_schema( + summary="Update item", + request=MyCreateSerializer, + responses={200: MyCreateSerializer}, + ), + partial_update=extend_schema( + summary="Partially update item", + request=MyCreateSerializer, + responses={200: MyCreateSerializer}, + ), + destroy=extend_schema(summary="Delete item"), +) +class MyViewSet(viewsets.ModelViewSet): + """Brief description.""" +``` + +**Custom Actions:** +```python +from democracy.views.openapi import RESPONSE_WITH_STATUS + +@action(detail=True, methods=["post"]) +@extend_schema( + summary="Vote for item", + request=None, + responses={ + 200: RESPONSE_WITH_STATUS, + 400: OpenApiResponse(description="Already voted"), + }, +) +def vote(self, request, pk=None): + pass +``` + +**Function Views:** +```python +from .serializers import RequestSerializer, ResponseSerializer + +@extend_schema( + summary="Brief description", + request=RequestSerializer, # Actual class + responses={200: ResponseSerializer}, # Actual class +) +@api_view(["POST"]) +def my_view(request): + pass +``` + +### 9. Validation Checklist + +Per endpoint: +- [ ] Summary (<100 chars, imperative) +- [ ] Description (if non-trivial) +- [ ] Query params with types +- [ ] Request body (POST/PUT/PATCH) uses actual class +- [ ] Responses use actual classes (not strings) +- [ ] Success responses (200/201/204) +- [ ] Error responses (400/401/403/404) +- [ ] Side effects mentioned +- [ ] Auth requirements clear +- [ ] No `inline_serializer` in decorators +- [ ] Identical responses consolidated + +### 10. Validate Schema + +Run these commands: +```bash +docker compose run --rm django python manage.py spectacular --validate --fail-on-warn +docker compose run --rm django python manage.py spectacular --file openapi-schema.yaml --format openapi-yaml +``` + +**ONLY use these commands for validation.** + +### 11. Run Pre-commit + +After all steps complete: +```bash +pre-commit run --all-files +``` + +Fix any linting errors, if found. + +### 12. Verify + +- [ ] Schema generates without errors/warnings +- [ ] All endpoints visible in openapi schema file (openapi-schema.yaml) +- [ ] Parameters documented +- [ ] Request/response schemas correct + +## Quick Reference + +**Imports (view files):** +```python +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) +from democracy.views.openapi import PAGINATION_PARAMS, RESPONSE_WITH_STATUS +``` + +**Types:** `OpenApiTypes.STR`, `INT`, `BOOL`, `DATE`, `DATETIME`, `UUID` +**Locations:** `OpenApiParameter.QUERY`, `.PATH`, `.HEADER` +**Codes:** 200, 201, 204, 400, 401, 403, 404, 409, 422 + +## Troubleshooting + +**AttributeError: 'Request' has no 'accepted_renderer'** +→ Add `DEFAULT_RENDERER_CLASSES` to `REST_FRAMEWORK` settings + +**Warning: could not resolve "YourSerializer"** +→ Using string instead of class. Import and use actual class. + +**Endpoints not showing** +→ Check viewset registered, `DEFAULT_SCHEMA_CLASS` set, URLs included + +**Schema validation fails** +→ Check imports, no string serializer refs, correct param types + +--- + +**Execute steps 1-12 sequentially. Organize openapi.py (step 7) BEFORE decorators (step 8). List ONLY changes made.** diff --git a/README.md b/README.md index 63769eb0..5e461b19 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -Kerro kantasi -============= +# Kerro kantasi + Kerro kantasi is an implementation of the eponymous REST API for publishing and participating in public hearings. -It has been built to be used together with participatory democrary UI (https://github.com/City-of-Helsinki/kerrokantasi-ui). Nothing should prevent it from being used with other UI implementations. +It has been built to be used together with participatory democrary UI (). Nothing should prevent it from being used with other UI implementations. Kerro kantasi supports hearings with rich metadata and complex structures. Citizens can be asked for freeform input, -multiple choice polls and map based input. See the Helsinki instance of the UI for examples: https://kerrokantasi.hel.fi/. In addition to gathering citizen input, kerrokantasi-ui is also the primary editor +multiple choice polls and map based input. See the Helsinki instance of the UI for examples: . In addition to gathering citizen input, kerrokantasi-ui is also the primary editor interface for hearings. -If you wish to see the raw data used by hearings, you may examine the API instance serving the UI: https://api.hel.fi/kerrokantasi/v1/ +If you wish to see the raw data used by hearings, you may examine the API instance serving the UI: + +## Technology -Technology ----------- Kerro kantasi is implemented using the Python programming language, with Django and Django Rest Framework as the main structural components. @@ -20,33 +20,33 @@ Kerro kantasi has been designed to allow for anonymous participation in hearings Authentication is handled using JWT tokens. All API requests requiring a login must include `Authorization` header containing a signed JWT token. A request including a JWT token with valid signature is processed with the permissions of the user indicated in the token. If the user does not yet exist, an account is created for them. This means that Kerro kantasi is only loosely coupled to the authentication provider. -Docker Installation -------------------- +## Docker Installation ### Development The easiest way to develop is -``` +```sh git clone https://github.com/City-of-Helsinki/kerrokantasi.git cd kerrokantasi ``` -Uncomment line https://github.com/City-of-Helsinki/kerrokantasi/blob/main/compose.yaml#L27-L28 to activate +Uncomment line to activate configuring the dev environment with a local file. Copy the development config file example `config_dev.toml.example` to `config_dev.toml` (read [Configuration](#configuration) below): -``` + +```sh cp config_dev.toml.example config_dev.toml docker compose up dev ``` -and open your browser to http://127.0.0.1:8000/. +and open your browser to . Run tests with -``` +```sh docker compose run dev test ``` @@ -56,36 +56,36 @@ Production setup will require a separate PostGIS database server (see [Prepare d uploaded files. Once you have a PostGIS database server running, -``` +```sh docker run kerrokantasi ``` In production, configuration is done with corresponding environment variables. See `config_dev.env.example` for the environment variables needed to set in production and read [Configuration](#configuration) below. - -Using local Tunnistamo instance for development with docker ------------------------------------------------------------ +## Using local Tunnistamo instance for development with docker ### Set tunnistamo hostname Add the following line to your hosts file (`/etc/hosts` on mac and linux): +``` 127.0.0.1 tunnistamo-backend +``` ### Create a new OAuth app on GitHub -Go to https://github.com/settings/developers/ and add a new app with the following settings: +Go to and add a new app with the following settings: - Application name: can be anything, e.g. local tunnistamo -- Homepage URL: http://tunnistamo-backend:8000 -- Authorization callback URL: http://tunnistamo-backend:8000/accounts/github/login/callback/ +- Homepage URL: +- Authorization callback URL: Save. You'll need the created **Client ID** and **Client Secret** for configuring tunnistamo in the next step. ### Install local tunnistamo -Clone https://github.com/City-of-Helsinki/tunnistamo/. +Clone . Follow the instructions for setting up tunnistamo locally. Before running `docker compose up` set the following settings in tunnistamo roots `docker-compose.env.yaml`: @@ -94,7 +94,9 @@ Follow the instructions for setting up tunnistamo locally. Before running `docke After you've got tunnistamo running locally, ssh to the tunnistamo docker container: -`docker compose exec django bash` +```sh +docker compose exec django bash +``` and execute the following four commands inside your docker container: @@ -122,42 +124,45 @@ SOCIAL_AUTH_TUNNISTAMO_SECRET= +```sh virtualenv -p python3 venv source venv/bin/activate +``` ### Install required packages Install all required packages with pip command: +```sh pip install -r requirements.txt +``` ### Compile translation .mo files +```sh python manage.py compilemessages +``` You will now need to configure Kerro kantasi. Read on. -Configuration -------------- +## Configuration + Kerro kantasi can be configured using either `config_dev.toml` file or environment variables. For production use we recommend using environment variables, especially if you are using containers. WSGI-servers typically allow setting environment variables from their own configuration files. @@ -241,8 +252,7 @@ The syntax varies a bit. Some servers might want the file `kerrokantasi/wsgi.py` In addition you will need to server out static files separately. Configure your HTTP server to serve out the files in directory specified using STATIC_ROOT setting with the URL specified in STATIC_URL setting. -Development processes ---------------------- +## Development processes ### Updating requirements @@ -274,23 +284,29 @@ as expected, commit the changes. To run all tests, execute command in application root directory. +```sh py.test democracy/ kerrokantasi/ +``` Run test against particular issue. +```sh py.test -k test_7 -v +``` Integration and unit tests are in separate folders, to run only specific type of tests use foldername in path. +```sh py.test democracy/tests/unittests py.test democracy/tests/integrationtests +``` ### Internationalization Translations are maintained on [Transifex][tx]. -* To pull new translations from Transifex, run `npm run i18n:pull` -* As a translation maintainer, run `npm run i18n:extract && npm run i18n:push` to push new source files. +- To pull new translations from Transifex, run `npm run i18n:pull` +- As a translation maintainer, run `npm run i18n:extract && npm run i18n:push` to push new source files. [tx]: https://www.transifex.com/city-of-helsinki/kerrokantasi/dashboard/ @@ -300,10 +316,10 @@ This project uses [Ruff](https://docs.astral.sh/ruff/) for code formatting and q Basic `ruff` commands: -* lint: `ruff check` -* apply safe lint fixes: `ruff check --fix` -* check formatting: `ruff format --check` -* format: `ruff format` +- lint: `ruff check` +- apply safe lint fixes: `ruff check --fix` +- check formatting: `ruff format --check` +- format: `ruff format` [`pre-commit`](https://pre-commit.com/) can be used to install and run all the formatting tools as git hooks automatically before a @@ -324,6 +340,6 @@ This can be useful for ignoring e.g. formatting commits, so that it is more clea where the actual code change came from. Configure your git to use it for this project with the following command: -```shell +```sh git config blame.ignoreRevsFile .git-blame-ignore-revs ``` diff --git a/democracy/migrations/0066_alter_contactpersontranslation_title_and_more.py b/democracy/migrations/0066_alter_contactpersontranslation_title_and_more.py new file mode 100644 index 00000000..15d2c174 --- /dev/null +++ b/democracy/migrations/0066_alter_contactpersontranslation_title_and_more.py @@ -0,0 +1,331 @@ +# Generated by Django 5.2.9 on 2026-01-09 10:16 + +import autoslug.fields +import django.contrib.gis.db.models.fields +import django.db.models.deletion +import django.utils.timezone +import djgeojson.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("democracy", "0065_alter_section_commenting_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="contactpersontranslation", + name="title", + field=models.CharField( + help_text="Contact person's job title or role", + max_length=255, + verbose_name="title", + ), + ), + migrations.AlterField( + model_name="hearing", + name="close_at", + field=models.DateTimeField( + default=django.utils.timezone.now, + help_text="Date and time when the hearing closes for participation", + verbose_name="closing time", + ), + ), + migrations.AlterField( + model_name="hearing", + name="force_closed", + field=models.BooleanField( + default=False, + help_text="Manually close the hearing regardless of open/close dates", + verbose_name="force hearing closed", + ), + ), + migrations.AlterField( + model_name="hearing", + name="geojson", + field=djgeojson.fields.GeoJSONField( + blank=True, + help_text="Geographic area related to this hearing in GeoJSON format", + null=True, + verbose_name="area", + ), + ), + migrations.AlterField( + model_name="hearing", + name="geometry", + field=django.contrib.gis.db.models.fields.GeometryCollectionField( + blank=True, + help_text="PostGIS geometry collection for spatial database queries", + null=True, + srid=4326, + verbose_name="area geometry", + ), + ), + migrations.AlterField( + model_name="hearing", + name="n_comments", + field=models.IntegerField( + blank=True, + default=0, + editable=False, + help_text="Cached total count of all comments across all sections", + verbose_name="number of comments", + ), + ), + migrations.AlterField( + model_name="hearing", + name="open_at", + field=models.DateTimeField( + default=django.utils.timezone.now, + help_text="Date and time when the hearing opens for public participation", + verbose_name="opening time", + ), + ), + migrations.AlterField( + model_name="hearing", + name="servicemap_url", + field=models.CharField( + blank=True, + default="", + help_text="URL to the related location in the service map", + max_length=255, + verbose_name="service map URL", + ), + ), + migrations.AlterField( + model_name="hearing", + name="slug", + field=autoslug.fields.AutoSlugField( + blank=True, + editable=True, + help_text="URL-friendly identifier. Leave empty to auto-generate from title", + populate_from="title", + unique=True, + verbose_name="slug", + ), + ), + migrations.AlterField( + model_name="hearingtranslation", + name="borough", + field=models.CharField( + blank=True, + default="", + help_text="Borough or district where the hearing is located", + max_length=200, + verbose_name="borough", + ), + ), + migrations.AlterField( + model_name="hearingtranslation", + name="title", + field=models.CharField( + help_text="The main title of the hearing", + max_length=255, + verbose_name="title", + ), + ), + migrations.AlterField( + model_name="labeltranslation", + name="label", + field=models.CharField( + default="", + help_text="Label for categorizing and filtering hearings", + max_length=200, + verbose_name="label", + ), + ), + migrations.AlterField( + model_name="project", + name="identifier", + field=models.CharField( + blank=True, + db_index=True, + help_text="External identifier for system integration", + max_length=50, + null=True, + verbose_name="identifier", + ), + ), + migrations.AlterField( + model_name="projectphase", + name="project", + field=models.ForeignKey( + help_text="Project this phase belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="phases", + to="democracy.project", + ), + ), + migrations.AlterField( + model_name="projectphasetranslation", + name="description", + field=models.CharField( + blank=True, + help_text="Detailed description of this project phase", + max_length=2048, + verbose_name="description", + ), + ), + migrations.AlterField( + model_name="projectphasetranslation", + name="schedule", + field=models.CharField( + blank=True, + help_text="Timeline and schedule information for this phase", + max_length=2048, + verbose_name="schedule", + ), + ), + migrations.AlterField( + model_name="projectphasetranslation", + name="title", + field=models.CharField( + blank=True, + help_text="Phase title within the project timeline", + max_length=255, + verbose_name="title", + ), + ), + migrations.AlterField( + model_name="projecttranslation", + name="title", + field=models.CharField( + blank=True, + help_text="Project title for grouping related hearings", + max_length=255, + verbose_name="title", + ), + ), + migrations.AlterField( + model_name="section", + name="hearing", + field=models.ForeignKey( + help_text="Hearing this section belongs to", + on_delete=django.db.models.deletion.PROTECT, + related_name="sections", + to="democracy.hearing", + ), + ), + migrations.AlterField( + model_name="section", + name="plugin_data", + field=models.TextField( + blank=True, + help_text="JSON configuration data for the plugin", + verbose_name="plugin data", + ), + ), + migrations.AlterField( + model_name="section", + name="plugin_fullscreen", + field=models.BooleanField( + default=False, + help_text="Whether the plugin should be displayed in fullscreen mode", + ), + ), + migrations.AlterField( + model_name="section", + name="plugin_identifier", + field=models.CharField( + blank=True, + help_text="Identifier for custom plugin functionality (e.g., map visualization)", + max_length=255, + verbose_name="plugin identifier", + ), + ), + migrations.AlterField( + model_name="sectionfiletranslation", + name="caption", + field=models.TextField( + blank=True, + default="", + help_text="File caption or description", + verbose_name="caption", + ), + ), + migrations.AlterField( + model_name="sectionfiletranslation", + name="title", + field=models.CharField( + blank=True, + default="", + help_text="File title for identification", + max_length=255, + verbose_name="title", + ), + ), + migrations.AlterField( + model_name="sectionimagetranslation", + name="alt_text", + field=models.TextField( + blank=True, + default="", + help_text="Alternative text for accessibility and SEO", + verbose_name="alt text", + ), + ), + migrations.AlterField( + model_name="sectionimagetranslation", + name="caption", + field=models.TextField( + blank=True, + default="", + help_text="Image caption displayed to users", + verbose_name="caption", + ), + ), + migrations.AlterField( + model_name="sectionimagetranslation", + name="title", + field=models.CharField( + blank=True, + default="", + help_text="Image title for identification", + max_length=255, + verbose_name="title", + ), + ), + migrations.AlterField( + model_name="sectionpolloptiontranslation", + name="text", + field=models.TextField( + help_text="Poll option choice text", verbose_name="option text" + ), + ), + migrations.AlterField( + model_name="sectionpolltranslation", + name="text", + field=models.TextField( + help_text="Poll question text displayed to users", verbose_name="text" + ), + ), + migrations.AlterField( + model_name="sectiontranslation", + name="abstract", + field=models.TextField( + blank=True, + help_text="Brief summary of the section content", + verbose_name="abstract", + ), + ), + migrations.AlterField( + model_name="sectiontranslation", + name="content", + field=models.TextField( + blank=True, + help_text="Main content body of the section (supports rich text)", + verbose_name="content", + ), + ), + migrations.AlterField( + model_name="sectiontranslation", + name="title", + field=models.CharField( + blank=True, + help_text="Section heading displayed in the hearing", + max_length=255, + verbose_name="title", + ), + ), + ] diff --git a/democracy/models/hearing.py b/democracy/models/hearing.py index e6842c59..ca49ba89 100644 --- a/democracy/models/hearing.py +++ b/democracy/models/hearing.py @@ -36,6 +36,14 @@ def filter_by_id_or_slug(self, id_or_slug): class Hearing(StringIdBaseModel, TranslatableModel, SerializableMixin): + """ + Participatory democracy hearing model. + + Represents a public consultation process where citizens can view information, + provide comments, and participate in polls. Hearings contain sections with + content, have defined open/close periods, and can be associated with projects. + """ + serialize_fields = ( {"name": "id"}, {"name": "title_with_translations"}, @@ -43,25 +51,53 @@ class Hearing(StringIdBaseModel, TranslatableModel, SerializableMixin): {"name": "sections"}, ) - open_at = models.DateTimeField(verbose_name=_("opening time"), default=timezone.now) + open_at = models.DateTimeField( + verbose_name=_("opening time"), + default=timezone.now, + help_text=_("Date and time when the hearing opens for public participation"), + ) close_at = models.DateTimeField( - verbose_name=_("closing time"), default=timezone.now + verbose_name=_("closing time"), + default=timezone.now, + help_text=_("Date and time when the hearing closes for participation"), ) force_closed = models.BooleanField( - verbose_name=_("force hearing closed"), default=False + verbose_name=_("force hearing closed"), + default=False, + help_text=_("Manually close the hearing regardless of open/close dates"), ) translations = TranslatedFields( - title=models.CharField(verbose_name=_("title"), max_length=255), + title=models.CharField( + verbose_name=_("title"), + max_length=255, + help_text=_("The main title of the hearing"), + ), borough=models.CharField( - verbose_name=_("borough"), blank=True, default="", max_length=200 + verbose_name=_("borough"), + blank=True, + default="", + max_length=200, + help_text=_("Borough or district where the hearing is located"), ), ) servicemap_url = models.CharField( - verbose_name=_("service map URL"), default="", max_length=255, blank=True + verbose_name=_("service map URL"), + default="", + max_length=255, + blank=True, + help_text=_("URL to the related location in the service map"), + ) + geojson = GeoJSONField( + blank=True, + null=True, + verbose_name=_("area"), + help_text=_("Geographic area related to this hearing in GeoJSON format"), ) - geojson = GeoJSONField(blank=True, null=True, verbose_name=_("area")) geometry = models.GeometryCollectionField( - blank=True, null=True, verbose_name=_("area geometry") + blank=True, + null=True, + verbose_name=_("area geometry"), + help_text=_("PostGIS geometry collection for spatial database queries"), ) organization = models.ForeignKey( Organization, @@ -86,10 +122,14 @@ class Hearing(StringIdBaseModel, TranslatableModel, SerializableMixin): editable=True, unique=True, blank=True, - help_text=_("You may leave this empty to automatically generate a slug"), + help_text=_("URL-friendly identifier. Leave empty to auto-generate from title"), ) n_comments = models.IntegerField( - verbose_name=_("number of comments"), blank=True, default=0, editable=False + verbose_name=_("number of comments"), + blank=True, + default=0, + editable=False, + help_text=_("Cached total count of all comments across all sections"), ) contact_persons = models.ManyToManyField( ContactPerson, diff --git a/democracy/models/label.py b/democracy/models/label.py index ed46af69..f0b0fe97 100644 --- a/democracy/models/label.py +++ b/democracy/models/label.py @@ -8,7 +8,12 @@ class Label(BaseModel, TranslatableModel): translations = TranslatedFields( - label=models.CharField(verbose_name=_("label"), default="", max_length=200), + label=models.CharField( + verbose_name=_("label"), + default="", + max_length=200, + help_text=_("Label for categorizing and filtering hearings"), + ), ) objects = BaseModelManager.from_queryset(TranslatableQuerySet)() diff --git a/democracy/models/organization.py b/democracy/models/organization.py index 5895a39e..f82c4fd2 100644 --- a/democracy/models/organization.py +++ b/democracy/models/organization.py @@ -70,7 +70,11 @@ class ContactPerson(TranslatableModel, StringIdBaseModel): on_delete=models.PROTECT, ) translations = TranslatedFields( - title=models.CharField(verbose_name=_("title"), max_length=255), + title=models.CharField( + verbose_name=_("title"), + max_length=255, + help_text=_("Contact person's job title or role"), + ), ) name = models.CharField(verbose_name=_("name"), max_length=50) phone = models.CharField(verbose_name=_("phone"), max_length=50) diff --git a/democracy/models/project.py b/democracy/models/project.py index 860e6418..1cd1d99c 100644 --- a/democracy/models/project.py +++ b/democracy/models/project.py @@ -7,15 +7,28 @@ class Project(StringIdBaseModel, TranslatableModel): + """ + Multi-phase project containing related hearings. + + Projects group related hearings into a coherent timeline with multiple phases. + Each hearing is associated with exactly one active phase at a time. + """ + identifier = models.CharField( max_length=50, verbose_name=_("identifier"), db_index=True, blank=True, null=True, + help_text=_("External identifier for system integration"), ) translations = TranslatedFields( - title=models.CharField(verbose_name=_("title"), max_length=255, blank=True), + title=models.CharField( + verbose_name=_("title"), + max_length=255, + blank=True, + help_text=_("Project title for grouping related hearings"), + ), ) objects = BaseModelManager.from_queryset(TranslatableQuerySet)() @@ -24,17 +37,39 @@ def __str__(self): class ProjectPhase(StringIdBaseModel, TranslatableModel): + """ + Individual phase within a multi-phase project. + + Each project consists of multiple phases that represent different stages + or milestones. Hearings are associated with specific phases, and each + hearing must have exactly one active phase. + """ + translations = TranslatedFields( - title=models.CharField(verbose_name=_("title"), max_length=255, blank=True), + title=models.CharField( + verbose_name=_("title"), + max_length=255, + blank=True, + help_text=_("Phase title within the project timeline"), + ), description=models.CharField( - verbose_name=_("description"), max_length=2048, blank=True + verbose_name=_("description"), + max_length=2048, + blank=True, + help_text=_("Detailed description of this project phase"), ), schedule=models.CharField( - verbose_name=_("schedule"), max_length=2048, blank=True + verbose_name=_("schedule"), + max_length=2048, + blank=True, + help_text=_("Timeline and schedule information for this phase"), ), ) project = models.ForeignKey( - Project, related_name="phases", on_delete=models.CASCADE + Project, + related_name="phases", + on_delete=models.CASCADE, + help_text=_("Project this phase belongs to"), ) ordering = models.IntegerField( verbose_name=_("ordering"), default=1, db_index=True, help_text=ORDERING_HELP diff --git a/democracy/models/section.py b/democracy/models/section.py index fcb0e5ea..a599da58 100644 --- a/democracy/models/section.py +++ b/democracy/models/section.py @@ -69,6 +69,14 @@ def save(self, *args, **kwargs): class Section(Commentable, StringIdBaseModel, TranslatableModel, SerializableMixin): + """ + Content section within a hearing. + + Sections are the main content containers within a hearing. Each hearing + has at least one main section and can have additional sections of different + types. Sections can contain images, files, polls, and collect comments. + """ + serialize_fields = ( {"name": "id"}, {"name": "ordering"}, @@ -81,7 +89,10 @@ class Section(Commentable, StringIdBaseModel, TranslatableModel, SerializableMix ) hearing = models.ForeignKey( - Hearing, related_name="sections", on_delete=models.PROTECT + Hearing, + related_name="sections", + on_delete=models.PROTECT, + help_text=_("Hearing this section belongs to"), ) ordering = models.IntegerField( verbose_name=_("ordering"), default=1, db_index=True, help_text=ORDERING_HELP @@ -90,15 +101,40 @@ class Section(Commentable, StringIdBaseModel, TranslatableModel, SerializableMix SectionType, related_name="sections", on_delete=models.PROTECT ) translations = TranslatedFields( - title=models.CharField(verbose_name=_("title"), max_length=255, blank=True), - abstract=models.TextField(verbose_name=_("abstract"), blank=True), - content=models.TextField(verbose_name=_("content"), blank=True), + title=models.CharField( + verbose_name=_("title"), + max_length=255, + blank=True, + help_text=_("Section heading displayed in the hearing"), + ), + abstract=models.TextField( + verbose_name=_("abstract"), + blank=True, + help_text=_("Brief summary of the section content"), + ), + content=models.TextField( + verbose_name=_("content"), + blank=True, + help_text=_("Main content body of the section (supports rich text)"), + ), ) plugin_identifier = models.CharField( - verbose_name=_("plugin identifier"), blank=True, max_length=255 + verbose_name=_("plugin identifier"), + blank=True, + max_length=255, + help_text=_( + "Identifier for custom plugin functionality (e.g., map visualization)" + ), + ) + plugin_data = models.TextField( + verbose_name=_("plugin data"), + blank=True, + help_text=_("JSON configuration data for the plugin"), + ) + plugin_fullscreen = models.BooleanField( + default=False, + help_text=_("Whether the plugin should be displayed in fullscreen mode"), ) - plugin_data = models.TextField(verbose_name=_("plugin data"), blank=True) - plugin_fullscreen = models.BooleanField(default=False) objects = SerializableBaseModelManager.from_queryset(TranslatableQuerySet)() class Meta: @@ -203,10 +239,24 @@ class SectionImage( ) translations = TranslatedFields( title=models.CharField( - verbose_name=_("title"), max_length=255, blank=True, default="" + verbose_name=_("title"), + max_length=255, + blank=True, + default="", + help_text=_("Image title for identification"), + ), + caption=models.TextField( + verbose_name=_("caption"), + blank=True, + default="", + help_text=_("Image caption displayed to users"), + ), + alt_text=models.TextField( + verbose_name=_("alt text"), + blank=True, + default="", + help_text=_("Alternative text for accessibility and SEO"), ), - caption=models.TextField(verbose_name=_("caption"), blank=True, default=""), - alt_text=models.TextField(verbose_name=_("alt text"), blank=True, default=""), ) objects = SerializableBaseModelManager.from_queryset(TranslatableQuerySet)() @@ -251,9 +301,18 @@ class SectionFile( ) translations = TranslatedFields( title=models.CharField( - verbose_name=_("title"), max_length=255, blank=True, default="" + verbose_name=_("title"), + max_length=255, + blank=True, + default="", + help_text=_("File title for identification"), + ), + caption=models.TextField( + verbose_name=_("caption"), + blank=True, + default="", + help_text=_("File caption or description"), ), - caption=models.TextField(verbose_name=_("caption"), blank=True, default=""), ) objects = SerializableBaseModelManager.from_queryset(TranslatableQuerySet)() @@ -411,7 +470,10 @@ class SectionPoll(BasePoll, SerializableMixin): ) section = models.ForeignKey(Section, related_name="polls", on_delete=models.PROTECT) translations = TranslatedFields( - text=models.TextField(verbose_name=_("text")), + text=models.TextField( + verbose_name=_("text"), + help_text=_("Poll question text displayed to users"), + ), ) objects = SerializableBaseModelManager() @@ -454,7 +516,10 @@ def text_with_translations(self): SectionPoll, related_name="options", on_delete=models.PROTECT ) translations = TranslatedFields( - text=models.TextField(verbose_name=_("option text")), + text=models.TextField( + verbose_name=_("option text"), + help_text=_("Poll option choice text"), + ), ) objects = SerializableBaseModelManager() diff --git a/democracy/views/comment.py b/democracy/views/comment.py index 6acff1d9..9f9e69e1 100644 --- a/democracy/views/comment.py +++ b/democracy/views/comment.py @@ -4,6 +4,7 @@ from django.db.models import Prefetch from django.utils import timezone from django.utils.encoding import force_str as force_text +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import permissions, response, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.settings import api_settings @@ -14,6 +15,7 @@ from democracy.models.comment import BaseComment from democracy.renderers import GeoJSONRenderer from democracy.views.base import AdminsSeeUnpublishedMixin, CreatedBySerializer +from democracy.views.openapi import RESPONSE_WITH_STATUS from democracy.views.utils import GeoJSONField COMMENT_FIELDS = [ @@ -295,6 +297,21 @@ def destroy(self, request, *args, **kwargs): return response.Response(status=status.HTTP_204_NO_CONTENT) + @extend_schema( + summary="Vote for a comment", + description=( + "Add a vote to support a comment. " + "Authenticated users can vote once, anonymous users can vote if allowed. " + "Requires that voting is enabled for the hearing section." + ), + request=None, + responses={ + 200: RESPONSE_WITH_STATUS, + 201: RESPONSE_WITH_STATUS, + 304: OpenApiResponse(description="Already voted for this comment"), + 403: OpenApiResponse(description="Voting not allowed or closed"), + }, + ) @action(detail=True, methods=["post"]) def vote(self, request, **kwargs): resp = self._check_may_vote(request) @@ -326,6 +343,21 @@ def vote(self, request, **kwargs): {"status": "Vote has been added"}, status=status.HTTP_201_CREATED ) + @extend_schema( + summary="Flag a comment for moderation", + description=( + "Flag a comment for review by moderators. " + "Only organization admins of the hearing's organization can flag comments." + ), + request=None, + responses={ + 200: RESPONSE_WITH_STATUS, + 304: OpenApiResponse(description="Comment already flagged"), + 403: OpenApiResponse( + description="Not authorized to flag comments in this hearing" + ), + }, + ) @action(detail=True, methods=["post"]) def flag(self, request, **kwargs): instance = self.get_object() @@ -347,6 +379,19 @@ def flag(self, request, **kwargs): add_audit_logged_object_ids(self.request, instance) return response.Response({"status": "comment flagged"}) + @extend_schema( + summary="Remove vote from a comment", + description=( + "Remove previously cast vote from a comment. " + "Requires authentication. Only registered users can remove their votes." + ), + request=None, + responses={ + 204: OpenApiResponse(description="Vote successfully removed"), + 304: OpenApiResponse(description="You have not voted for this comment"), + 403: OpenApiResponse(description="Authentication required"), + }, + ) @action(detail=True, methods=["post"]) def unvote(self, request, **kwargs): # Return 403 if user is not authenticated diff --git a/democracy/views/contact_person.py b/democracy/views/contact_person.py index bc3f7da9..18701d1b 100644 --- a/democracy/views/contact_person.py +++ b/democracy/views/contact_person.py @@ -1,8 +1,14 @@ +from drf_spectacular.utils import ( + OpenApiResponse, + extend_schema, + extend_schema_view, +) from rest_framework import mixins, permissions, serializers, viewsets from audit_log.views import AuditLogApiView from democracy.models import ContactPerson, Organization from democracy.pagination import DefaultLimitPagination +from democracy.views.openapi import PAGINATION_PARAMS from democracy.views.utils import TranslatableSerializer @@ -45,12 +51,76 @@ def create(self, validated_data): return super().create(validated_data) +@extend_schema_view( + list=extend_schema( + summary="List contact persons", + description=( + "Retrieve paginated list of contact persons. " + "Requires authentication and user must belong to an organization." + ), + parameters=PAGINATION_PARAMS, + ), + retrieve=extend_schema( + summary="Get contact person details", + description=( + "Retrieve detailed information about a specific contact person. " + "Requires authentication and user must belong to an organization." + ), + ), + create=extend_schema( + summary="Create contact person", + description=( + "Create a new contact person for the user's organization. " + "If organization is not specified, user's default organization is used." + ), + responses={ + 201: ContactPersonSerializer, + 403: OpenApiResponse( + description="User without organization cannot create contact persons" + ), + }, + ), + update=extend_schema( + summary="Update contact person", + description=( + "Update an existing contact person. " + "Requires authentication and user must belong to an organization." + ), + responses={ + 200: ContactPersonSerializer, + 403: OpenApiResponse( + description="User without organization cannot update contact persons" + ), + }, + ), + partial_update=extend_schema( + summary="Partially update contact person", + description=( + "Partially update an existing contact person. " + "Requires authentication and user must belong to " + "an organization." + ), + responses={ + 200: ContactPersonSerializer, + 403: OpenApiResponse( + description="User without organization cannot update contact persons" + ), + }, + ), +) class ContactPersonViewSet( AuditLogApiView, viewsets.ReadOnlyModelViewSet, mixins.CreateModelMixin, mixins.UpdateModelMixin, ): + """ + API endpoint for contact persons. + + Manages contact persons associated with organizations. Contact persons are used + in hearings to provide points of contact for the public. + """ + serializer_class = ContactPersonSerializer queryset = ContactPerson.objects.select_related("organization").order_by("name") permission_classes = [permissions.IsAuthenticated, ContactPersonPermission] diff --git a/democracy/views/hearing.py b/democracy/views/hearing.py index 0147a8a6..6c08d77c 100644 --- a/democracy/views/hearing.py +++ b/democracy/views/hearing.py @@ -5,6 +5,13 @@ from django.db import transaction from django.db.models import Prefetch, Q from django.utils import timezone +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) from rest_framework import filters, permissions, response, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError @@ -29,6 +36,14 @@ from democracy.views.contact_person import ContactPersonSerializer from democracy.views.hearing_report import HearingReport from democracy.views.label import LabelSerializer +from democracy.views.openapi import ( + BBOX_PARAM, + HEARING_FILTER_PARAMS, + HEARING_ORDERING_PARAM, + INCLUDE_PARAM, + PAGINATION_PARAMS, + RESPONSE_WITH_STATUS, +) from democracy.views.project import ( ProjectCreateUpdateSerializer, ProjectFieldSerializer, @@ -55,23 +70,40 @@ class HearingFilterSet(django_filters.rest_framework.FilterSet): open_at_lte = django_filters.IsoDateTimeFilter( - field_name="open_at", lookup_expr="lte" + field_name="open_at", + lookup_expr="lte", + help_text="Filter hearings opening at or before this date", ) open_at_gt = django_filters.IsoDateTimeFilter( - field_name="open_at", lookup_expr="gt" + field_name="open_at", + lookup_expr="gt", + help_text="Filter hearings opening after this date", ) title = django_filters.CharFilter( - lookup_expr="icontains", field_name="translations__title", distinct=True + lookup_expr="icontains", + field_name="translations__title", + distinct=True, + help_text="Filter by title (case-insensitive contains)", ) label = django_filters.Filter( field_name="labels__id", lookup_expr="in", distinct=True, widget=django_filters.widgets.CSVWidget, + help_text="Filter by label ID (comma-separated for multiple)", + ) + following = django_filters.BooleanFilter( + method="filter_following", + help_text="Filter hearings followed by current user (requires authentication)", + ) + open = django_filters.BooleanFilter( + method="filter_open", + help_text="Filter by open/closed status (true for currently open hearings)", + ) + created_by = django_filters.CharFilter( + method="filter_created_by", + help_text="Filter by creator ('me' for current user or organization name)", ) - following = django_filters.BooleanFilter(method="filter_following") - open = django_filters.BooleanFilter(method="filter_open") - created_by = django_filters.CharFilter(method="filter_created_by") def filter_following(self, queryset, name, value): if value and self.request.user.is_authenticated: @@ -558,8 +590,9 @@ def get_fields(self): fields.pop("contact_persons") request = self.context.get("request", None) if request: + accepted_renderer = getattr(request, "accepted_renderer", None) if not request.GET.get("include", None) == "geojson" and not isinstance( - request.accepted_renderer, GeoJSONRenderer + accepted_renderer, GeoJSONRenderer ): fields.pop("geojson") return fields @@ -582,9 +615,97 @@ class Meta: ] +@extend_schema_view( + list=extend_schema( + summary="List all hearings", + description=( + "Retrieve a paginated list of hearings. " + "Supports filtering by various parameters including status, " + "labels, and dates." + ), + parameters=( + PAGINATION_PARAMS + + HEARING_FILTER_PARAMS + + HEARING_ORDERING_PARAM + + BBOX_PARAM + + INCLUDE_PARAM + ), + ), + retrieve=extend_schema( + summary="Get hearing details", + description=( + "Retrieve detailed information about a specific hearing by ID or slug. " + "Unpublished hearings require preview code or admin access." + ), + parameters=[ + OpenApiParameter( + "preview", + OpenApiTypes.STR, + description="Preview code for unpublished hearings", + location=OpenApiParameter.QUERY, + ), + ], + ), + create=extend_schema( + summary="Create new hearing", + description=( + "Create a new hearing. " + "Requires authentication and user must belong to an organization." + ), + responses={ + 201: HearingCreateUpdateSerializer, + 403: OpenApiResponse( + description="User without organization cannot create hearings" + ), + }, + ), + update=extend_schema( + summary="Update hearing", + description=( + "Update an existing hearing. " + "Requires authentication and user must belong to an organization." + ), + responses={ + 200: HearingCreateUpdateSerializer, + 403: OpenApiResponse( + description="User without organization cannot update hearings" + ), + }, + ), + partial_update=extend_schema( + summary="Partially update hearing", + description=( + "Partially update an existing hearing. " + "Requires authentication and user must belong to an organization." + ), + responses={ + 200: HearingCreateUpdateSerializer, + 403: OpenApiResponse( + description="User without organization cannot update hearings" + ), + }, + ), + destroy=extend_schema( + summary="Delete hearing", + description=( + "Soft delete an unpublished hearing with no comments. " + "Requires authentication and user must belong to an organization." + ), + responses={ + 200: RESPONSE_WITH_STATUS, + 403: OpenApiResponse( + description="Cannot delete published hearings or hearings with comments" + ), + }, + ), +) class HearingViewSet(AdminsSeeUnpublishedMixin, AuditLogApiView, viewsets.ModelViewSet): """ - API endpoint for hearings. + API endpoint for managing participatory democracy hearings. + + Hearings are the core objects representing public consultation processes. + They contain sections with content, collect comments, and can be associated + with projects. """ model = Hearing @@ -693,6 +814,19 @@ def get_object(self): self.check_object_permissions(self.request, obj) return obj + @extend_schema( + summary="Follow a hearing", + description=( + "Add current user as a follower of the hearing. " + "Followed hearings can be filtered using the 'following' parameter." + ), + request=None, + responses={ + 201: RESPONSE_WITH_STATUS, + 304: OpenApiResponse(description="Already following this hearing"), + 401: OpenApiResponse(description="Authentication required"), + }, + ) @action(detail=True, methods=["post"]) def follow(self, request, pk=None): hearing = self.get_object() @@ -711,6 +845,16 @@ def follow(self, request, pk=None): {"status": "You follow a hearing now"}, status=status.HTTP_201_CREATED ) + @extend_schema( + summary="Unfollow a hearing", + description="Remove current user as a follower of the hearing.", + request=None, + responses={ + 204: OpenApiResponse(description="Successfully unfollowed"), + 304: OpenApiResponse(description="Not following this hearing"), + 401: OpenApiResponse(description="Authentication required"), + }, + ) @action(detail=True, methods=["post"]) def unfollow(self, request, pk=None): hearing = self.get_object() @@ -727,6 +871,18 @@ def unfollow(self, request, pk=None): status=status.HTTP_304_NOT_MODIFIED, ) + @extend_schema( + summary="Generate hearing report", + description=( + "Generate and download a report for the hearing " + "with all comments and statistics." + ), + responses={ + 200: OpenApiResponse( + description="Report file (format depends on implementation)" + ), + }, + ) @action(detail=True, methods=["get"]) def report(self, request, pk=None): context = self.get_serializer_context() @@ -735,6 +891,21 @@ def report(self, request, pk=None): ) return report.get_response() + @extend_schema( + summary="Generate PowerPoint report", + description=( + "Generate and download a PowerPoint presentation report for the hearing. " + "Requires authentication and user must belong to an organization." + ), + responses={ + 200: OpenApiResponse(description="PowerPoint file"), + 403: OpenApiResponse( + description=( + "User without organization cannot generate PowerPoint reports" + ) + ), + }, + ) @action(detail=True, methods=["get"]) def report_pptx(self, request, pk=None): user = request.user @@ -749,6 +920,14 @@ def report_pptx(self, request, pk=None): ) return report.get_response() + @extend_schema( + summary="Get hearings as map data", + description=( + "Retrieve hearings in a format suitable for map visualization. " + "Returns simplified hearing data with geographic information." + ), + parameters=PAGINATION_PARAMS, + ) @action(detail=False, methods=["get"]) def map(self, request): queryset = self.filter_queryset(self.get_queryset()) diff --git a/democracy/views/label.py b/democracy/views/label.py index 30ae5147..5a479d47 100644 --- a/democracy/views/label.py +++ b/democracy/views/label.py @@ -1,15 +1,23 @@ import django_filters +from drf_spectacular.utils import ( + OpenApiResponse, + extend_schema, + extend_schema_view, +) from rest_framework import mixins, permissions, response, serializers, status, viewsets from audit_log.views import AuditLogApiView from democracy.models import Label from democracy.pagination import DefaultLimitPagination +from democracy.views.openapi import LABEL_FILTER_PARAMS, PAGINATION_PARAMS from democracy.views.utils import TranslatableSerializer class LabelFilterSet(django_filters.rest_framework.FilterSet): label = django_filters.CharFilter( - lookup_expr="icontains", field_name="translations__label" + lookup_expr="icontains", + field_name="translations__label", + help_text="Filter by label text (case-insensitive contains)", ) class Meta: @@ -23,9 +31,40 @@ class Meta: fields = ("id", "label") +@extend_schema_view( + list=extend_schema( + summary="List labels", + description="Retrieve paginated list of labels used for categorizing hearings.", + parameters=PAGINATION_PARAMS + LABEL_FILTER_PARAMS, + ), + retrieve=extend_schema( + summary="Get label details", + description="Retrieve detailed information about a specific label.", + ), + create=extend_schema( + summary="Create label", + description=( + "Create a new label. " + "Requires authentication and user must belong to an organization." + ), + responses={ + 201: LabelSerializer, + 403: OpenApiResponse( + description="User without organization cannot create labels" + ), + }, + ), +) class LabelViewSet( AuditLogApiView, viewsets.ReadOnlyModelViewSet, mixins.CreateModelMixin ): + """ + API endpoint for labels. + + Labels are used to categorize and tag hearings for easier filtering and + organization. + """ + serializer_class = LabelSerializer queryset = Label.objects.all().prefetch_related("translations") pagination_class = DefaultLimitPagination diff --git a/democracy/views/openapi.py b/democracy/views/openapi.py new file mode 100644 index 00000000..5cbc549d --- /dev/null +++ b/democracy/views/openapi.py @@ -0,0 +1,247 @@ +""" +Shared OpenAPI parameter and response definitions for API documentation. + +This module contains reusable OpenApiParameter objects and response serializers +that are used across multiple viewsets to keep decorator definitions clean and DRY. +""" + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, inline_serializer +from rest_framework import serializers + +# ============================================================================ +# Pagination Parameters +# ============================================================================ + +PAGINATION_PARAMS = [ + OpenApiParameter( + "limit", + OpenApiTypes.INT, + description="Number of results per page", + ), + OpenApiParameter( + "offset", + OpenApiTypes.INT, + description="Offset for pagination", + ), +] + +# ============================================================================ +# Common Query Parameters +# ============================================================================ + +AUTHORIZATION_CODE_PARAM = [ + OpenApiParameter( + "authorization_code", + OpenApiTypes.STR, + description="Authorization code for viewing comment", + location=OpenApiParameter.QUERY, + ), +] + +ORDERING_PARAM = [ + OpenApiParameter( + "ordering", + OpenApiTypes.STR, + description="Sort field (prefix - for descending order)", + ), +] + +HEARING_ORDERING_PARAM = [ + OpenApiParameter( + "ordering", + OpenApiTypes.STR, + description=( + "Sort field: created_at, close_at, open_at, n_comments (prefix - for desc)" + ), + ), +] + +BBOX_PARAM = [ + OpenApiParameter( + "bbox", + OpenApiTypes.STR, + description="Bounding box filter: min_lon,min_lat,max_lon,max_lat", + ), +] + +INCLUDE_PARAM = [ + OpenApiParameter( + "include", + OpenApiTypes.STR, + description="Include additional data (e.g., 'plugin_data', 'geojson')", + ), +] + +# ============================================================================ +# Comment-Related Parameters +# ============================================================================ + +COMMENT_ORDERING_PARAM = [ + OpenApiParameter( + "ordering", + OpenApiTypes.STR, + description="Sort field: created_at, n_votes (prefix - for desc)", + ), +] + +COMMENT_FILTER_PARAMS = [ + OpenApiParameter( + "authorization_code", + OpenApiTypes.STR, + description="Authorization code for viewing specific comments", + ), +] + +COMMON_COMMENT_PARAMS = ( + COMMENT_FILTER_PARAMS + COMMENT_ORDERING_PARAM + BBOX_PARAM + INCLUDE_PARAM +) + +# ============================================================================ +# Root-Level Comment Filter Parameters +# ============================================================================ + +ROOT_COMMENT_FILTER_PARAMS = [ + OpenApiParameter( + "hearing", + OpenApiTypes.STR, + description="Filter by hearing ID", + ), + OpenApiParameter( + "section", + OpenApiTypes.STR, + description="Filter by section ID", + ), + OpenApiParameter( + "comment", + OpenApiTypes.STR, + description="Filter by parent comment ID", + ), + OpenApiParameter( + "label", + OpenApiTypes.STR, + description="Filter by label ID", + ), + OpenApiParameter( + "pinned", + OpenApiTypes.BOOL, + description="Filter for pinned comments", + ), + OpenApiParameter( + "created_by", + OpenApiTypes.STR, + description="Filter by creator ('me' for current user)", + ), + OpenApiParameter( + "created_at__lt", + OpenApiTypes.DATETIME, + description="Filter comments created before this date", + ), + OpenApiParameter( + "created_at__gt", + OpenApiTypes.DATETIME, + description="Filter comments created after this date", + ), +] + +# ============================================================================ +# Hearing Filter Parameters +# ============================================================================ + +HEARING_FILTER_PARAMS = [ + OpenApiParameter( + "title", + OpenApiTypes.STR, + description="Filter by title (case-insensitive contains)", + ), + OpenApiParameter( + "open_at_lte", + OpenApiTypes.DATETIME, + description="Filter hearings opening at or before this date", + ), + OpenApiParameter( + "open_at_gt", + OpenApiTypes.DATETIME, + description="Filter hearings opening after this date", + ), + OpenApiParameter( + "label", + OpenApiTypes.STR, + description="Filter by label ID (comma-separated for multiple)", + ), + OpenApiParameter( + "published", + OpenApiTypes.BOOL, + description="Filter by published status", + ), + OpenApiParameter( + "open", + OpenApiTypes.BOOL, + description="Filter by open/closed status", + ), + OpenApiParameter( + "following", + OpenApiTypes.BOOL, + description="Filter hearings followed by current user", + ), + OpenApiParameter( + "created_by", + OpenApiTypes.STR, + description="Filter by creator ('me' for current user or organization name)", + ), +] + +# ============================================================================ +# Section Filter Parameters +# ============================================================================ + +SECTION_FILTER_PARAMS = [ + OpenApiParameter( + "hearing", + OpenApiTypes.STR, + description="Filter by hearing ID", + ), + OpenApiParameter( + "type", + OpenApiTypes.STR, + description="Filter by section type identifier", + ), +] + +SECTION_IMAGE_FILTER_PARAMS = [ + OpenApiParameter( + "hearing", + OpenApiTypes.STR, + description="Filter by hearing ID", + ), + OpenApiParameter( + "section", + OpenApiTypes.STR, + description="Filter by section ID", + ), +] + +# ============================================================================ +# Label Filter Parameters +# ============================================================================ + +LABEL_FILTER_PARAMS = [ + OpenApiParameter( + "label", + OpenApiTypes.STR, + description="Filter by label text (case-insensitive contains)", + ), +] + +# ============================================================================ +# Common Response Serializers +# ============================================================================ + +# Generic status response used across all endpoints +# Returns: {"status": "string message"} +# This consolidated response is used for all actions that return a simple +# status message, including voting, flagging, following, and deletion operations. +RESPONSE_WITH_STATUS = inline_serializer( + name="StatusResponse", + fields={"status": serializers.CharField()}, +) diff --git a/democracy/views/organization.py b/democracy/views/organization.py index 7517758c..24dbc5c7 100644 --- a/democracy/views/organization.py +++ b/democracy/views/organization.py @@ -1,8 +1,10 @@ +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import mixins, permissions, serializers from rest_framework.viewsets import GenericViewSet from democracy.models import Organization from democracy.pagination import DefaultLimitPagination +from democracy.views.openapi import PAGINATION_PARAMS class OrganizationSerializer(serializers.ModelSerializer): @@ -11,7 +13,21 @@ class Meta: fields = ("name", "external_organization") +@extend_schema_view( + list=extend_schema( + summary="List organizations", + description="Retrieve paginated list of all organizations in the system.", + parameters=PAGINATION_PARAMS, + ), +) class OrganizationViewSet(mixins.ListModelMixin, GenericViewSet): + """ + API endpoint for organizations. + + Organizations are entities that create and manage hearings. Read-only endpoint + for listing available organizations. + """ + serializer_class = OrganizationSerializer queryset = Organization.objects.all() permission_classes = (permissions.IsAuthenticatedOrReadOnly,) diff --git a/democracy/views/project.py b/democracy/views/project.py index fbbee41e..b21a696b 100644 --- a/democracy/views/project.py +++ b/democracy/views/project.py @@ -1,8 +1,10 @@ +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import serializers, viewsets from rest_framework.exceptions import ValidationError from democracy.models import Project, ProjectPhase from democracy.pagination import DefaultLimitPagination +from democracy.views.openapi import PAGINATION_PARAMS from democracy.views.utils import ( NestedPKRelatedField, TranslatableSerializer, @@ -191,7 +193,32 @@ def _update_phase(self, instance, phase_data, project): return serializer.save(project=project) +@extend_schema_view( + list=extend_schema( + summary="List projects", + description=( + "Retrieve paginated list of all projects. " + "Projects contain multiple phases, which can have associated hearings." + ), + parameters=PAGINATION_PARAMS, + ), + retrieve=extend_schema( + summary="Get project details", + description=( + "Retrieve detailed information about a specific project, " + "including all phases and associated hearings." + ), + ), +) class ProjectViewSet(viewsets.ReadOnlyModelViewSet): + """ + API endpoint for projects. + + Projects are containers for organizing related hearings across multiple phases. + Each project can have multiple phases, and each phase can contain multiple hearings. + Read-only endpoint. + """ + serializer_class = ProjectSerializer queryset = Project.objects.all() pagination_class = DefaultLimitPagination diff --git a/democracy/views/section.py b/democracy/views/section.py index e68b254f..e2fa937a 100644 --- a/democracy/views/section.py +++ b/democracy/views/section.py @@ -9,6 +9,13 @@ from django.views.generic import View from django.views.generic.detail import SingleObjectMixin from django_sendfile import sendfile +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) from easy_thumbnails.files import get_thumbnailer from rest_framework import permissions, serializers, viewsets from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError @@ -31,6 +38,11 @@ BaseFileSerializer, BaseImageSerializer, ) +from democracy.views.openapi import ( + PAGINATION_PARAMS, + SECTION_FILTER_PARAMS, + SECTION_IMAGE_FILTER_PARAMS, +) from democracy.views.utils import ( Base64FileField, Base64ImageField, @@ -39,6 +51,29 @@ filter_by_hearing_visible, ) +# Section-specific OpenAPI parameters +SECTION_IMAGE_PARAMS = [ + OpenApiParameter( + "section_type", + OpenApiTypes.STR, + description="Filter by section type identifier", + ), + OpenApiParameter( + "dim", + OpenApiTypes.STR, + description="Image dimensions for thumbnail (e.g., '640x480')", + ), +] + +DIM_PARAM = [ + OpenApiParameter( + "dim", + OpenApiTypes.STR, + description="Image dimensions for thumbnail (e.g., '640x480')", + location=OpenApiParameter.QUERY, + ), +] + class ThumbnailImageSerializer(BaseImageSerializer): """ @@ -489,7 +524,29 @@ def to_representation(self, instance): return data +@extend_schema_view( + list=extend_schema( + summary="List sections for a hearing", + description=( + "Retrieve all sections belonging to a specific hearing. " + "Sections contain the content structure of a hearing." + ), + ), + retrieve=extend_schema( + summary="Get section details", + description=( + "Retrieve detailed information about a specific section within a hearing." + ), + ), +) class SectionViewSet(AdminsSeeUnpublishedMixin, viewsets.ReadOnlyModelViewSet): + """ + API endpoint for hearing sections. + + Sections are the content blocks within a hearing. Each hearing has multiple sections + that organize the content and can collect comments. + """ + serializer_class = SectionSerializer model = Section @@ -565,8 +622,14 @@ def to_internal_value(self, value): class ImageFilterSet(django_filters.rest_framework.FilterSet): - hearing = django_filters.CharFilter(field_name="section__hearing__id") - section_type = django_filters.CharFilter(field_name="section__type__identifier") + hearing = django_filters.CharFilter( + field_name="section__hearing__id", + help_text="Filter by hearing ID", + ) + section_type = django_filters.CharFilter( + field_name="section__type__identifier", + help_text="Filter by section type identifier", + ) class Meta: model = SectionImage @@ -574,7 +637,80 @@ class Meta: # root level SectionImage endpoint +@extend_schema_view( + list=extend_schema( + summary="List section images", + description=( + "Retrieve paginated list of section images across all hearings. " + "Can be filtered by hearing or section." + ), + parameters=( + PAGINATION_PARAMS + SECTION_IMAGE_FILTER_PARAMS + SECTION_IMAGE_PARAMS + ), + ), + retrieve=extend_schema( + summary="Get section image details", + description="Retrieve details of a specific section image.", + parameters=DIM_PARAM, + ), + create=extend_schema( + summary="Create section image", + description=( + "Upload a new image to a section. Requires organization admin permissions." + ), + responses={ + 201: RootSectionImageSerializer, + 403: OpenApiResponse( + description="Only organization admin can create section images" + ), + }, + ), + update=extend_schema( + summary="Update section image", + description=( + "Update an existing section image. Requires organization admin permissions." + ), + responses={ + 200: RootSectionImageSerializer, + 403: OpenApiResponse( + description="Only organization admin can update section images" + ), + }, + ), + partial_update=extend_schema( + summary="Partially update section image", + description=( + "Partially update an existing section image. " + "Requires organization admin permissions." + ), + responses={ + 200: RootSectionImageSerializer, + 403: OpenApiResponse( + description="Only organization admin can update section images" + ), + }, + ), + destroy=extend_schema( + summary="Delete section image", + description=( + "Soft delete a section image. Requires organization admin permissions." + ), + responses={ + 204: OpenApiResponse(description="Image successfully deleted"), + 403: OpenApiResponse( + description="Only organization admin can delete section images" + ), + }, + ), +) class ImageViewSet(AdminsSeeUnpublishedMixin, AuditLogApiView, viewsets.ModelViewSet): + """ + API endpoint for section images. + + Allows management of images attached to hearing sections. Images support + thumbnailing via the 'dim' query parameter. + """ + model = SectionImage serializer_class = RootSectionImageSerializer pagination_class = DefaultLimitPagination @@ -693,7 +829,76 @@ class RootFileBase64Serializer(RootFileSerializer): file = Base64FileField() +@extend_schema_view( + list=extend_schema( + summary="List section files", + description="Retrieve paginated list of files attached to hearing sections.", + parameters=PAGINATION_PARAMS, + ), + retrieve=extend_schema( + summary="Get section file details", + description="Retrieve details of a specific section file.", + ), + create=extend_schema( + summary="Upload section file", + description=( + "Upload a new file to a section. " + "Supports both multipart/form-data and base64 encoded files. " + "Requires organization admin permissions." + ), + responses={ + 201: RootFileSerializer, + 403: OpenApiResponse( + description="Only organization admin can create section files" + ), + }, + ), + update=extend_schema( + summary="Update section file", + description=( + "Update an existing section file. Requires organization admin permissions." + ), + responses={ + 200: RootFileSerializer, + 403: OpenApiResponse( + description="Only organization admin can update section files" + ), + }, + ), + partial_update=extend_schema( + summary="Partially update section file", + description=( + "Partially update an existing section file. " + "Requires organization admin permissions." + ), + responses={ + 200: RootFileSerializer, + 403: OpenApiResponse( + description="Only organization admin can update section files" + ), + }, + ), + destroy=extend_schema( + summary="Delete section file", + description=( + "Soft delete a section file. Requires organization admin permissions." + ), + responses={ + 204: OpenApiResponse(description="File successfully deleted"), + 403: OpenApiResponse( + description="Only organization admin can delete section files" + ), + }, + ), +) class FileViewSet(AdminsSeeUnpublishedMixin, AuditLogApiView, viewsets.ModelViewSet): + """ + API endpoint for section files. + + Allows management of files (PDFs, documents, etc.) attached to hearing sections. + Supports both multipart and base64 encoded file uploads. + """ + model = SectionFile pagination_class = DefaultLimitPagination permission_classes = (permissions.IsAuthenticatedOrReadOnly,) @@ -782,8 +987,14 @@ class Meta(SectionSerializer.Meta): class SectionFilterSet(django_filters.rest_framework.FilterSet): - hearing = django_filters.CharFilter(field_name="hearing_id") - type = django_filters.CharFilter(field_name="type__identifier") + hearing = django_filters.CharFilter( + field_name="hearing_id", + help_text="Filter by hearing ID", + ) + type = django_filters.CharFilter( + field_name="type__identifier", + help_text="Filter by section type identifier", + ) class Meta: model = Section @@ -812,7 +1023,27 @@ def file_qs_for_request(request): # root level Section endpoint +@extend_schema_view( + list=extend_schema( + summary="List all sections", + description=( + "Retrieve paginated list of all sections across all hearings. " + "Can be filtered by hearing or section type." + ), + parameters=PAGINATION_PARAMS + SECTION_FILTER_PARAMS, + ), + retrieve=extend_schema( + summary="Get section details", + description="Retrieve detailed information about a specific section.", + ), +) class RootSectionViewSet(AdminsSeeUnpublishedMixin, viewsets.ReadOnlyModelViewSet): + """ + Root-level API endpoint for sections across all hearings. + + Provides read-only access to all sections with filtering capabilities. + """ + serializer_class = RootSectionSerializer model = Section pagination_class = DefaultLimitPagination diff --git a/democracy/views/section_comment.py b/democracy/views/section_comment.py index 6c6ac243..7d086f1a 100644 --- a/democracy/views/section_comment.py +++ b/democracy/views/section_comment.py @@ -6,6 +6,11 @@ from django.db.transaction import atomic from django.utils.functional import cached_property from django.utils.translation import gettext as _ +from drf_spectacular.utils import ( + OpenApiResponse, + extend_schema, + extend_schema_view, +) from rest_framework import filters, response, serializers, status from rest_framework.exceptions import ValidationError from rest_framework.relations import PrimaryKeyRelatedField @@ -33,6 +38,12 @@ CommentImageSerializer, ) from democracy.views.label import LabelSerializer +from democracy.views.openapi import ( + AUTHORIZATION_CODE_PARAM, + COMMON_COMMENT_PARAMS, + PAGINATION_PARAMS, + ROOT_COMMENT_FILTER_PARAMS, +) from democracy.views.utils import ( GeoJSONField, GeometryBboxFilterBackend, @@ -309,7 +320,76 @@ def to_representation(self, instance): return data +@extend_schema_view( + list=extend_schema( + summary="List section comments", + description=( + "Retrieve paginated list of comments for hearing sections. " + "Comments can be filtered and ordered." + ), + parameters=PAGINATION_PARAMS + COMMON_COMMENT_PARAMS, + ), + retrieve=extend_schema( + summary="Get comment details", + description="Retrieve detailed information about a specific comment.", + parameters=AUTHORIZATION_CODE_PARAM, + ), + create=extend_schema( + summary="Create comment", + description=( + "Post a new comment to a hearing section. " + "Can include poll answers, images, and geographic data." + ), + responses={ + 201: SectionCommentCreateUpdateSerializer, + 400: OpenApiResponse( + description=( + "Validation error (e.g., commenting closed, invalid poll answer)" + ) + ), + 403: OpenApiResponse(description="User not allowed to comment"), + }, + ), + update=extend_schema( + summary="Update comment", + description=( + "Update an existing comment. Requires authorization code or ownership." + ), + responses={ + 200: SectionCommentCreateUpdateSerializer, + 400: OpenApiResponse(description="Validation error"), + 403: OpenApiResponse(description="Not authorized to edit this comment"), + }, + ), + partial_update=extend_schema( + summary="Partially update comment", + description=( + "Partially update an existing comment. " + "Requires authorization code or ownership." + ), + responses={ + 200: SectionCommentCreateUpdateSerializer, + 403: OpenApiResponse(description="Not authorized to edit this comment"), + }, + ), + destroy=extend_schema( + summary="Delete comment", + description="Soft delete a comment. Requires authorization code or ownership.", + responses={ + 204: OpenApiResponse(description="Comment successfully deleted"), + 403: OpenApiResponse(description="Not authorized to delete this comment"), + }, + ), +) class SectionCommentViewSet(BaseCommentViewSet): + """ + API endpoint for section comments. + + Handles comments posted to hearing sections. Supports poll voting, image + attachments, geographic data, and threaded replies. Comments can be + moderated by organization admins. + """ + model = SectionComment serializer_class = SectionCommentSerializer edit_serializer_class = SectionCommentCreateUpdateSerializer @@ -536,19 +616,36 @@ class Meta(SectionCommentSerializer.Meta): class CommentFilterSet(django_filters.rest_framework.FilterSet): - hearing = django_filters.CharFilter(field_name="section__hearing__id") - label = django_filters.Filter(field_name="label__id") + hearing = django_filters.CharFilter( + field_name="section__hearing__id", + help_text="Filter by hearing ID", + ) + label = django_filters.Filter( + field_name="label__id", + help_text="Filter by label ID", + ) created_at__lt = django_filters.IsoDateTimeFilter( - field_name="created_at", lookup_expr="lt" + field_name="created_at", + lookup_expr="lt", + help_text="Filter comments created before this date", ) created_at__gt = django_filters.rest_framework.IsoDateTimeFilter( - field_name="created_at", lookup_expr="gt" + field_name="created_at", + lookup_expr="gt", + help_text="Filter comments created after this date", ) comment = django_filters.ModelChoiceFilter( - queryset=SectionComment.objects.everything() + queryset=SectionComment.objects.everything(), + help_text="Filter by parent comment ID", + ) + section = django_filters.CharFilter( + field_name="section__id", + help_text="Filter by section ID", + ) + created_by = django_filters.CharFilter( + method="filter_created_by", + help_text="Filter by creator ('me' for current user)", ) - section = django_filters.CharFilter(field_name="section__id") - created_by = django_filters.CharFilter(method="filter_created_by") class Meta: model = SectionComment @@ -571,7 +668,81 @@ def filter_created_by(self, queryset, name, value: str): # root level SectionComment endpoint +@extend_schema_view( + list=extend_schema( + summary="List all comments", + description=( + "Retrieve paginated list of comments across all hearings and sections. " + "For privacy, author names are removed unless filtered by specific " + "hearing, section, or user. Can be filtered and ordered." + ), + parameters=( + PAGINATION_PARAMS + ROOT_COMMENT_FILTER_PARAMS + COMMON_COMMENT_PARAMS + ), + ), + retrieve=extend_schema( + summary="Get comment details", + description="Retrieve detailed information about a specific comment.", + parameters=AUTHORIZATION_CODE_PARAM, + ), + create=extend_schema( + summary="Create comment (root endpoint)", + description=( + "Post a new comment to any hearing section. " + "Can include poll answers, images, and geographic data. " + "The section or comment to reply to must be specified in the request body." + ), + responses={ + 201: RootSectionCommentCreateUpdateSerializer, + 400: OpenApiResponse( + description=( + "Validation error (e.g., commenting closed, invalid poll answer, " + "section not specified)" + ) + ), + 403: OpenApiResponse(description="User not allowed to comment"), + }, + ), + update=extend_schema( + summary="Update comment (root endpoint)", + description=( + "Update an existing comment. Requires authorization code or ownership." + ), + responses={ + 200: RootSectionCommentCreateUpdateSerializer, + 400: OpenApiResponse(description="Validation error"), + 403: OpenApiResponse(description="Not authorized to edit this comment"), + }, + ), + partial_update=extend_schema( + summary="Partially update comment (root endpoint)", + description=( + "Partially update an existing comment. " + "Requires authorization code or ownership." + ), + responses={ + 200: RootSectionCommentCreateUpdateSerializer, + 403: OpenApiResponse(description="Not authorized to edit this comment"), + }, + ), + destroy=extend_schema( + summary="Delete comment (root endpoint)", + description="Soft delete a comment. Requires authorization code or ownership.", + responses={ + 204: OpenApiResponse(description="Comment successfully deleted"), + 403: OpenApiResponse(description="Not authorized to delete this comment"), + }, + ), +) class CommentViewSet(SectionCommentViewSet): + """ + Root-level API endpoint for comments across all hearings. + + Provides access to all comments with extensive filtering capabilities. + Author names are removed for privacy unless the query is filtered by + specific hearing, section, or user. + """ + serializer_class = RootSectionCommentSerializer edit_serializer_class = RootSectionCommentCreateUpdateSerializer pagination_class = DefaultLimitPagination diff --git a/democracy/views/user.py b/democracy/views/user.py index 71f353f5..53a3280f 100644 --- a/democracy/views/user.py +++ b/democracy/views/user.py @@ -1,4 +1,5 @@ from django.contrib.auth import get_user_model +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import permissions, serializers, viewsets from democracy.models import SectionPollAnswer @@ -40,7 +41,30 @@ def get_answered_questions(self, obj): ) +@extend_schema_view( + list=extend_schema( + summary="Get current user data", + description=( + "Retrieve data for the currently authenticated user. " + "Returns only the authenticated user's own data." + ), + ), + retrieve=extend_schema( + summary="Get user data by UUID", + description=( + "Retrieve user data by UUID. Users can only access their own data." + ), + ), +) class UserDataViewSet(viewsets.ReadOnlyModelViewSet): + """ + API endpoint for user data. + + Provides access to current user's profile data including voting history, + followed hearings, and organization memberships. Users can only access + their own data. + """ + serializer_class = UserDataSerializer permission_classes = (permissions.IsAuthenticated,) lookup_field = "uuid" diff --git a/kerrokantasi/settings/base.py b/kerrokantasi/settings/base.py index 0795b91a..54aa6667 100644 --- a/kerrokantasi/settings/base.py +++ b/kerrokantasi/settings/base.py @@ -203,13 +203,14 @@ def sentry_traces_sampler(sampling_context: SamplingContext) -> float: "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", - # disable Django’s development server static file handling + # disable Django's development server static file handling "whitenoise.runserver_nostatic", "django.contrib.staticfiles", "django.contrib.sites", "mptt", "nested_admin", "rest_framework", + "drf_spectacular", "reversion", "corsheaders", "easy_thumbnails", @@ -297,6 +298,7 @@ def sentry_traces_sampler(sampling_context: SamplingContext) -> float: CORS_URLS_REGEX = r"^/[a-z0-9-]*/?v1/.*$" REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_AUTHENTICATION_CLASSES": ( "kerrokantasi.oidc.StrongApiTokenAuthentication", "django.contrib.auth.backends.ModelBackend", @@ -306,11 +308,34 @@ def sentry_traces_sampler(sampling_context: SamplingContext) -> float: "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly" ], + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ], "DEFAULT_VERSION": "1", "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", "TEST_REQUEST_DEFAULT_FORMAT": "json", } +SPECTACULAR_SETTINGS = { + "TITLE": "Kerrokantasi API", + "DESCRIPTION": """Kerrokantasi participatory democracy API. + +Authentication: +- API Token Authentication via OIDC (Bearer token in Authorization header) +- Session Authentication for browsable API +- Anonymous read access for public content + """, + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_PATCH": True, + "SCHEMA_PATH_PREFIX": r"/v1/", + "SCHEMA_PATH_PREFIX_TRIM": True, + "SERVERS": [ + {"url": "http://localhost:8086/v1/", "description": "Development server"}, + ], +} + RESILIENT_LOGGER = { "origin": "kerrokantasi", diff --git a/kerrokantasi/urls.py b/kerrokantasi/urls.py index d85cb0a8..7d636ff5 100644 --- a/kerrokantasi/urls.py +++ b/kerrokantasi/urls.py @@ -5,6 +5,11 @@ from django.views.decorators.cache import never_cache from django.views.decorators.http import require_safe from django.views.generic.base import RedirectView +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) from helusers.admin_site import admin from nested_admin import urls as nested_admin_urls @@ -34,6 +39,13 @@ def readiness(*_, **__): path("v1/", include(helsinki_notification.contrib.rest_framework.urls)), path("nested_admin/", include(nested_admin_urls)), path("gdpr-api/", include("helsinki_gdpr.urls")), + path("api-docs/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), + path( + "api-docs/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path("api-docs/schema/", SpectacularAPIView.as_view(), name="schema"), path("healthz", healthz, name="healthz"), path("readiness", readiness, name="readiness"), path("", RedirectView.as_view(url="v1/")), diff --git a/requirements.in b/requirements.in index f3ebec7c..d67ab53c 100644 --- a/requirements.in +++ b/requirements.in @@ -31,3 +31,4 @@ django-helsinki-notification[rest_framework]@https://github.com/City-of-Helsinki django-logger-extra@https://github.com/City-of-Helsinki/django-logger-extra/archive/refs/tags/v0.1.0.zip django-resilient-logger@https://github.com/City-of-Helsinki/django-resilient-logger/archive/refs/tags/v0.3.7.zip uwsgi +drf-spectacular diff --git a/requirements.txt b/requirements.txt index 850f9753..358f825e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,8 @@ attrs==25.4.0 \ --hash=sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373 # via # cattrs + # jsonschema + # referencing # requests-cache authlib==1.6.6 \ --hash=sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e \ @@ -336,6 +338,7 @@ django==5.2.9 \ # djangorestframework # drf-nested-routers # drf-oidc-auth + # drf-spectacular # easy-thumbnails # helsinki-profile-gdpr-api # jsonfield @@ -427,6 +430,7 @@ djangorestframework==3.16.1 \ # django-parler-rest # drf-nested-routers # drf-oidc-auth + # drf-spectacular # helsinki-profile-gdpr-api drf-nested-routers==0.94.2 \ --hash=sha256:74dbdceeae2a32f8668ba0df8e3eeabeb9b1c64d2621d914901ae653e4e3bcff \ @@ -436,6 +440,10 @@ drf-oidc-auth==3.0.0 \ --hash=sha256:243b44ffaf9bbb4a26445370616ab6b666a541479649e6bd3319ede502ea723b \ --hash=sha256:9b3ee051e86709f2bb8bb9a7e9bb975b7d9a1cf2c55cadcdd5b6889454f99075 # via helsinki-profile-gdpr-api +drf-spectacular==0.29.0 \ + --hash=sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc \ + --hash=sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a + # via -r requirements.in easy-thumbnails==2.10.1 \ --hash=sha256:24462d63dd31543ef1585538b2bfefe0db96d3409bb431c70b81548fb2cfc5be \ --hash=sha256:a50aa5f99387546c35ab5ba1ea9b3cbbc5658e65601cd34949f62137c32c222e @@ -466,6 +474,10 @@ idna==3.11 \ # via # requests # url-normalize +inflection==0.5.1 \ + --hash=sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417 \ + --hash=sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2 + # via drf-spectacular ipython==9.6.0 \ --hash=sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731 \ --hash=sha256:5f77efafc886d2f023442479b8149e7d86547ad0a979e9da9f045d252f648196 @@ -482,6 +494,14 @@ jsonfield==3.2.0 \ --hash=sha256:ca4f6bf89c819f293e77074d613c0021e3c4e8521be95c73d03caecb4372e1ee \ --hash=sha256:ca53871bc3308ae4f4cddc3b4f99ed5c6fc6abb1832fbfb499bc6da566c70e4a # via -r requirements.in +jsonschema==4.25.1 \ + --hash=sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63 \ + --hash=sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85 + # via drf-spectacular +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema langdetect==1.0.9 \ --hash=sha256:7cbc0746252f19e76f77c0b1690aadf01963be835ef0cd4b56dddf2a8f1dfc2a \ --hash=sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0 @@ -861,7 +881,15 @@ pyyaml==6.0.3 \ --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 - # via django-munigeo + # via + # django-munigeo + # drf-spectacular +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications requests==2.32.5 \ --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf @@ -880,6 +908,125 @@ requests-oauthlib==2.0.0 \ --hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 \ --hash=sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9 # via social-auth-core +rpds-py==0.30.0 \ + --hash=sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f \ + --hash=sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136 \ + --hash=sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3 \ + --hash=sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7 \ + --hash=sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65 \ + --hash=sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4 \ + --hash=sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169 \ + --hash=sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf \ + --hash=sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4 \ + --hash=sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2 \ + --hash=sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c \ + --hash=sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4 \ + --hash=sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3 \ + --hash=sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6 \ + --hash=sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7 \ + --hash=sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89 \ + --hash=sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85 \ + --hash=sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6 \ + --hash=sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa \ + --hash=sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb \ + --hash=sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6 \ + --hash=sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87 \ + --hash=sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856 \ + --hash=sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4 \ + --hash=sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f \ + --hash=sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53 \ + --hash=sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229 \ + --hash=sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad \ + --hash=sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23 \ + --hash=sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db \ + --hash=sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038 \ + --hash=sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27 \ + --hash=sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00 \ + --hash=sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18 \ + --hash=sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083 \ + --hash=sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c \ + --hash=sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738 \ + --hash=sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898 \ + --hash=sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e \ + --hash=sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7 \ + --hash=sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08 \ + --hash=sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6 \ + --hash=sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551 \ + --hash=sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e \ + --hash=sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288 \ + --hash=sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df \ + --hash=sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0 \ + --hash=sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2 \ + --hash=sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05 \ + --hash=sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0 \ + --hash=sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464 \ + --hash=sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5 \ + --hash=sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404 \ + --hash=sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7 \ + --hash=sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139 \ + --hash=sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394 \ + --hash=sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb \ + --hash=sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15 \ + --hash=sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff \ + --hash=sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed \ + --hash=sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6 \ + --hash=sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e \ + --hash=sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95 \ + --hash=sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d \ + --hash=sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950 \ + --hash=sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3 \ + --hash=sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5 \ + --hash=sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97 \ + --hash=sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e \ + --hash=sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e \ + --hash=sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b \ + --hash=sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd \ + --hash=sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad \ + --hash=sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8 \ + --hash=sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425 \ + --hash=sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221 \ + --hash=sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d \ + --hash=sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825 \ + --hash=sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51 \ + --hash=sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e \ + --hash=sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f \ + --hash=sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8 \ + --hash=sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f \ + --hash=sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d \ + --hash=sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07 \ + --hash=sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877 \ + --hash=sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31 \ + --hash=sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58 \ + --hash=sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94 \ + --hash=sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28 \ + --hash=sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000 \ + --hash=sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1 \ + --hash=sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1 \ + --hash=sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7 \ + --hash=sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7 \ + --hash=sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40 \ + --hash=sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d \ + --hash=sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0 \ + --hash=sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84 \ + --hash=sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f \ + --hash=sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a \ + --hash=sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7 \ + --hash=sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419 \ + --hash=sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8 \ + --hash=sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a \ + --hash=sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9 \ + --hash=sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be \ + --hash=sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed \ + --hash=sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a \ + --hash=sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d \ + --hash=sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324 \ + --hash=sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f \ + --hash=sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2 \ + --hash=sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f \ + --hash=sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5 + # via + # jsonschema + # referencing rsa==4.9.1 \ --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 \ --hash=sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75 @@ -926,6 +1073,11 @@ typing-extensions==4.15.0 \ # elasticsearch # psycopg # python-pptx + # referencing +uritemplate==4.2.0 \ + --hash=sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e \ + --hash=sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686 + # via drf-spectacular url-normalize==2.2.1 \ --hash=sha256:3deb687587dc91f7b25c9ae5162ffc0f057ae85d22b1e15cf5698311247f567b \ --hash=sha256:74a540a3b6eba1d95bdc610c24f2c0141639f3ba903501e61a52a8730247ff37