From 96e6304f553d598a5ac04217955e44fc5273aa28 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Wed, 21 Jan 2026 11:32:55 -0600 Subject: [PATCH 01/12] Add PermanentURL model --- core/admin.py | 8 ++++++- core/migrations/0010_permanenturl.py | 32 ++++++++++++++++++++++++++++ core/models.py | 19 +++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 core/migrations/0010_permanenturl.py diff --git a/core/admin.py b/core/admin.py index ef51d0f..cb683d9 100644 --- a/core/admin.py +++ b/core/admin.py @@ -5,7 +5,8 @@ SchemaRef, DocumentationItem, Organization, - Profile + Profile, + PermanentURL ) @@ -32,3 +33,8 @@ class OrganizationAdmin(admin.ModelAdmin): @register(Profile) class ProfileAdmin(admin.ModelAdmin): list_display = ['user', 'organization'] + + +@register(PermanentURL) +class PermanentURLAdmin(admin.ModelAdmin): + list_display = ['content_object', 'url'] diff --git a/core/migrations/0010_permanenturl.py b/core/migrations/0010_permanenturl.py new file mode 100644 index 0000000..6437b31 --- /dev/null +++ b/core/migrations/0010_permanenturl.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.5 on 2026-02-02 18:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0009_organization_profile'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PermanentURL', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('object_id', models.PositiveBigIntegerField()), + ('url', models.URLField()), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='contenttypes.contenttype')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [models.Index(fields=['content_type', 'object_id'], name='core_perman_content_aa1d0b_idx')], + }, + ), + ] diff --git a/core/models.py b/core/models.py index f49dc52..e9475f5 100644 --- a/core/models.py +++ b/core/models.py @@ -4,6 +4,9 @@ from django.contrib.auth.models import User from django.utils import timezone from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.core.validators import RegexValidator from urllib.parse import urlparse import requests from .utils import guess_specification_language_by_extension, guess_language_by_extension @@ -21,6 +24,20 @@ def create(cls, created_by): return cls(created_by=created_by) +class PermanentURL(BaseModel): + content_type = models.ForeignKey(ContentType, on_delete=models.RESTRICT) + object_id = models.PositiveBigIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + url = models.URLField() + + class Meta: + # As of writing, Django does *not* automatically create an index + # on the GenericForeignKey as it does with ForeignKey. + indexes = [ + models.Index(fields=["content_type", "object_id"]) + ] + + class PublicSchemaManager(models.Manager): def get_queryset(self): return ( @@ -36,6 +53,7 @@ class Schema(BaseModel): public_objects = PublicSchemaManager() name = models.CharField(max_length=200) published_at = models.DateTimeField(blank=True, null=True) + permanent_urls = GenericRelation(PermanentURL, related_query_name="schema") class Meta: indexes = [ @@ -248,6 +266,7 @@ def has_same_domain_and_path(self, other_url): class SchemaRef(ReferenceItem): schema = models.ForeignKey(Schema, on_delete=models.CASCADE) + permanent_urls = GenericRelation(PermanentURL, related_query_name="schemaref") @property def language(self): From a2978e433f434eb692ba8a06b5297f96b00ca6b8 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Wed, 28 Jan 2026 16:33:10 -0600 Subject: [PATCH 02/12] Add form to create permanent URLS --- core/forms.py | 73 ++++++++++++++++++- core/models.py | 23 +++++- core/static/css/site.css | 57 ++++++++++++++- core/templates/core/manage/field.html | 9 +++ .../templates/core/manage/permanent_urls.html | 28 +++++++ core/urls.py | 1 + core/views.py | 56 ++++++++++++-- 7 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 core/templates/core/manage/permanent_urls.html diff --git a/core/forms.py b/core/forms.py index 10d76e5..6caf25a 100644 --- a/core/forms.py +++ b/core/forms.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError from django.db.models import Q import requests -from .models import DocumentationItem, SchemaRef, Schema +from .models import DocumentationItem, SchemaRef, Schema, PermanentURL from .utils import guess_specification_language_by_extension @@ -208,3 +208,74 @@ def is_valid(self): return is_form_valid and is_documentation_items_formset_valid and is_schema_refs_formset_valid +def clean_permanent_url_slug(organization, slug): + proposed_url = PermanentURL.objects.get_url_for_slug( + organization=organization, + slug=slug + ) + if PermanentURL.objects.filter(url=proposed_url).exists(): + raise ValidationError('This URL is already in use.') + return slug + + +class SchemaRefPermanentURLForm(forms.Form): + slug = forms.SlugField( + max_length=300, + required=False, + widget=forms.TextInput(attrs={'placeholder': 'my-schema.json'}) + ) + + def set_schema_ref(self, schema_ref, fallback_name): + self.schema_ref = schema_ref + self.fields['slug'].help_text = "This URL will route to the Schemas.Pub listing for " + schema_ref.url + schema_name = schema_ref.name or fallback_name + self.fields['slug'].label = "New unique URL for " + schema_name + + def clean_slug(self): + return clean_permanent_url_slug( + organization=self.schema_ref.created_by.profile.organization, + slug=self.cleaned_data['slug'] + ) + + +class PermanentURLsForm(forms.Form): + schema_slug = forms.SlugField( + label="New unique URL for schema", + max_length=300, + help_text="This URL will route to the Schemas.Pub listing for your schema.", + widget=forms.TextInput(attrs={'placeholder': 'my-schema.json'}), + required=False + ) + + def __init__(self, *args, schema, **kwargs): + super().__init__(*args, **kwargs) + self.schema = schema + schema_refs = self.schema.schemaref_set.all() + # Create a formset with one form per SchemaRef + SchemaRefPermanentURLFormsetFactory = forms.formset_factory( + SchemaRefPermanentURLForm, + extra=len(schema_refs), + max_num=len(schema_refs), + ) + self.schema_ref_permanent_url_formset = SchemaRefPermanentURLFormsetFactory( + *args, + **kwargs + ) + for index, schema_ref_form in enumerate(self.schema_ref_permanent_url_formset): + schema_ref_form.set_schema_ref(schema_refs[index], f"definition {index + 1}") + + def clean_slug(self): + return clean_permanent_url_slug( + organization=self.schema.created_by.profile.organization, + slug=self.cleaned_data['schema_slug'] + ) + + def clean(self): + # TODO: make sure there are no duplicate slugs + self.schema_ref_permanent_url_formset.clean() + cleaned_data = super().clean() + return cleaned_data + + def is_valid(self): + is_form_valid = super().is_valid() + return is_form_valid and self.schema_ref_permanent_url_formset.is_valid() diff --git a/core/models.py b/core/models.py index e9475f5..247e852 100644 --- a/core/models.py +++ b/core/models.py @@ -23,13 +23,34 @@ class Meta: def create(cls, created_by): return cls(created_by=created_by) +class PermanentURLManager(models.Manager): + BASE_URL = 'https://id.schemas.pub/o/' + + def get_url_for_slug(self, *, organization, slug): + return self.BASE_URL + organization.slug + '/' + slug + + def create_from_slug(self, *, created_by, slug, **kwargs): + """ + Computes the URL from the user's organization and a slug. + """ + url = self.get_url_for_slug( + organization=created_by.profile.organization, + slug=slug + ) + kwargs.update( + created_by=created_by, + url=url + ) + return super().create(**kwargs) + class PermanentURL(BaseModel): + objects = PermanentURLManager() content_type = models.ForeignKey(ContentType, on_delete=models.RESTRICT) object_id = models.PositiveBigIntegerField() content_object = GenericForeignKey("content_type", "object_id") url = models.URLField() - + class Meta: # As of writing, Django does *not* automatically create an index # on the GenericForeignKey as it does with ForeignKey. diff --git a/core/static/css/site.css b/core/static/css/site.css index 0389aae..2bccd20 100644 --- a/core/static/css/site.css +++ b/core/static/css/site.css @@ -8,6 +8,9 @@ html { --danger-color: #b91c1c; --danger-color-highlight: #991b1b; --danger-color-text: #f87171; + --warning-color: #a16207; + --warning-color-highlight: #854d0e; + --warning-color-text: #facc15; --logo-color: #fde047; --text-link-color: #818cf8; --text-link-color-highlight: #a5b4fc; @@ -58,13 +61,18 @@ a:hover { } input, -select { - border-radius: 8px; +select, +.input-group .input-group__prefix { padding: 0.5rem; + border: 1px solid #666; background: #444; +} + +input, +select { + border-radius: 8px; color: var(--text-primary); width: 100%; - border: 1px solid #666; } .nav-header { @@ -214,6 +222,10 @@ select { color: var(--text-secondary); } +.text--warning { + color: var(--warning-color-text); +} + .account-page { width: 100%; max-width: 30rem; @@ -275,7 +287,8 @@ a.button--primary:hover { .button--prominent, .button--confirm, -.button--danger { +.button--danger, +.button--warning { padding: 0.5rem 1rem; border-radius: 6px; box-shadow: 0 4px 4px -1px rgba(0, 0, 0, 0.1); @@ -295,6 +308,15 @@ a.button--primary:hover { color: var(--text-primary); } +.button--warning { + background: var(--warning-color); +} + +.button--warning:hover { + background: var(--warning-color-highlight); + color: var(--text-primary); +} + .button--prominent:hover, .button--confirm:hover { background: var(--primary-color-highlight); @@ -378,6 +400,24 @@ a.button--primary:hover { cursor: pointer; } +.field .input-group { + display: flex; + align-items: center; + width: 100%; +} + +.field .input-group .input-group__prefix { + white-space: nowrap; + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + border-right-width: 0; +} + +.field .input-group input { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + .split-field-container { width: 100%; display: flex; @@ -913,3 +953,12 @@ a.badge--w3c { padding: 1rem; gap: 1rem; } + +/* Permanent URL management page */ +.manage-permanent-urls { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem; + gap: 1rem; +} diff --git a/core/templates/core/manage/field.html b/core/templates/core/manage/field.html index 5bd87a0..252cf16 100644 --- a/core/templates/core/manage/field.html +++ b/core/templates/core/manage/field.html @@ -4,7 +4,16 @@ class="{% if field.field.required %}field__label--is-required{% endif %}"> {{ field.label }} + {% if input_prefix %} +
+
+ {{ input_prefix }} +
+ {{ field }} +
+ {% else %} {{ field }} + {% endif %} {% if field.help_text %}
{{ field.help_text }} diff --git a/core/templates/core/manage/permanent_urls.html b/core/templates/core/manage/permanent_urls.html new file mode 100644 index 0000000..8d1a99b --- /dev/null +++ b/core/templates/core/manage/permanent_urls.html @@ -0,0 +1,28 @@ +{% extends "core/layouts/base.html" %} +{% load filters %} +{% block head_title %} +Permanent URLs for {{ schema.name }} +{% endblock %} +{% block page_content %} + +
+

Permanent URLs for "{{ schema.name }}"

+
+
+ {% csrf_token %} + {% for field in form %} + {% include "core/manage/field.html" with field=field input_prefix=prefix %} + {% endfor %} + {% for subform in form.schema_ref_permanent_url_formset %} + {% include "core/manage/field.html" with field=subform.slug input_prefix=prefix %} + {% endfor %} + {{ form.schema_ref_permanent_url_formset.management_form }} +

+ Once created, a permanent URL cannot be removed except by a Schemas.Pub administrator. + However, you can create as many permanent URLs for a schema as you need. +

+ +
+
+
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 5b27f98..0b888dc 100644 --- a/core/urls.py +++ b/core/urls.py @@ -12,6 +12,7 @@ path("manage/schema/new", views.manage_schema, name="manage_schema_new"), path("manage/schema//delete", views.manage_schema_delete, name="manage_schema_delete"), path("manage/schema//publish", views.manage_schema_publish, name="manage_schema_publish"), + path("manage/schema//permanent-urls", views.manage_schema_permanent_urls, name="manage_schema_permanent_urls"), path("organization/", views.organization_detail, name="organization_detail") ] diff --git a/core/views.py b/core/views.py index 9085bfe..c8dcd5a 100644 --- a/core/views.py +++ b/core/views.py @@ -7,16 +7,22 @@ from django.contrib.auth.decorators import login_required from django.utils import timezone from django.core.exceptions import PermissionDenied +from django.http import Http404 from functools import wraps import cmarkgfm import bleach from .models import (Schema, - DocumentationItem, - SchemaRef, - DocumentationItem, - Organization) -from .forms import SchemaForm, DocumentationItemForm - + DocumentationItem, + SchemaRef, + DocumentationItem, + Organization, + PermanentURL +) +from .forms import ( + SchemaForm, + DocumentationItemForm, + PermanentURLsForm +) MAX_SCHEMA_RESULT_COUNT = 30 @@ -309,3 +315,41 @@ def organization_detail(request, organization_id): return render(request, "core/organizations/detail.html", { 'organization': organization, }) + + +@login_required +def manage_schema_permanent_urls(request, schema_id): + if not request.user.profile.organization: + raise Http404 + schema = get_object_or_404( + Schema.public_objects, + id=schema_id, + created_by=request.user, + ) + prefix = PermanentURL.objects.get_url_for_slug( + organization=request.user.profile.organization, + slug='' + ) + if request.method == 'POST': + form = PermanentURLsForm(request.POST, schema=schema) + if form.is_valid(): + PermanentURL.objects.create_from_slug( + created_by=request.user, + content_object=schema, + slug=form.cleaned_data['schema_slug'] + ) + for subform in form.schema_ref_permanent_url_formset: + PermanentURL.objects.create_from_slug( + created_by=request.user, + content_object=subform.schema_ref, + slug=subform.cleaned_data['slug'] + ) + form = PermanentURLsForm(schema=schema) + else: + form = PermanentURLsForm(schema=schema) + + return render(request, "core/manage/permanent_urls.html", { + 'schema': schema, + 'form': form, + 'prefix': prefix + }) From d2b3f58e7a80e1c509ddc3d25050174addb8265c Mon Sep 17 00:00:00 2001 From: alexbainter Date: Mon, 2 Feb 2026 12:59:19 -0600 Subject: [PATCH 03/12] List existing permanent URLs on the management page --- core/forms.py | 6 ++-- .../templates/core/manage/permanent_urls.html | 34 ++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/core/forms.py b/core/forms.py index 6caf25a..4127338 100644 --- a/core/forms.py +++ b/core/forms.py @@ -227,9 +227,9 @@ class SchemaRefPermanentURLForm(forms.Form): def set_schema_ref(self, schema_ref, fallback_name): self.schema_ref = schema_ref + self.name = schema_ref.name or fallback_name self.fields['slug'].help_text = "This URL will route to the Schemas.Pub listing for " + schema_ref.url - schema_name = schema_ref.name or fallback_name - self.fields['slug'].label = "New unique URL for " + schema_name + self.fields['slug'].label = "New unique URL for " + self.name def clean_slug(self): return clean_permanent_url_slug( @@ -262,7 +262,7 @@ def __init__(self, *args, schema, **kwargs): **kwargs ) for index, schema_ref_form in enumerate(self.schema_ref_permanent_url_formset): - schema_ref_form.set_schema_ref(schema_refs[index], f"definition {index + 1}") + schema_ref_form.set_schema_ref(schema_refs[index], f"Definition {index + 1}") def clean_slug(self): return clean_permanent_url_slug( diff --git a/core/templates/core/manage/permanent_urls.html b/core/templates/core/manage/permanent_urls.html index 8d1a99b..fb97e12 100644 --- a/core/templates/core/manage/permanent_urls.html +++ b/core/templates/core/manage/permanent_urls.html @@ -8,6 +8,34 @@

Permanent URLs for "{{ schema.name }}"

+

Permanent URLs can only be removed by a Schemas.Pub administrator.

+

Existing URLs

+ {% if schema.permanent_urls.count > 0 %} + + {% else %} +

None

+ {% endif %} + {% for schema_ref in schema.schemaref_set.all %} + {% if schema_ref.permanent_urls.count > 0 %} +

+ {% if schema_ref.name %} + {{ schema_ref.name }} + {% else %} + Definition {{ forloop.counter }} + {% endif %} +

+ + {% endif %} + {% endfor %} +

Create URLs

{% csrf_token %} {% for field in form %} @@ -17,11 +45,7 @@

Permanent URLs for "{{ schema.name }}"

{% include "core/manage/field.html" with field=subform.slug input_prefix=prefix %} {% endfor %} {{ form.schema_ref_permanent_url_formset.management_form }} -

- Once created, a permanent URL cannot be removed except by a Schemas.Pub administrator. - However, you can create as many permanent URLs for a schema as you need. -

- +
From 049b6fe891099ec7f154dd12bfd11ee5640b1865 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Mon, 2 Feb 2026 13:32:38 -0600 Subject: [PATCH 04/12] Add links to permanent URL management page --- core/templates/account/profile.html | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/core/templates/account/profile.html b/core/templates/account/profile.html index deb4229..b894bad 100644 --- a/core/templates/account/profile.html +++ b/core/templates/account/profile.html @@ -28,8 +28,13 @@

My Schemas

+ {% if request.user.profile.organization %} + + + + {% endif %} {% if not schema.published_at|exists_and_is_in_past %} - + {% endif %} @@ -47,13 +52,9 @@

My Schemas

{% if request.user.profile.organization %}

Organization

- + + {{ request.user.profile.organization.name }} +
{% endif %}
From bb1114923c7ac852632198ec6a139989e4e99828 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Mon, 2 Feb 2026 13:37:14 -0600 Subject: [PATCH 05/12] Add permanent URLs to detail pages --- core/templates/core/schemas/detail.html | 10 ++++++++++ core/templates/core/schemas/detail_schema_ref.html | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/core/templates/core/schemas/detail.html b/core/templates/core/schemas/detail.html index 9240170..37cd395 100644 --- a/core/templates/core/schemas/detail.html +++ b/core/templates/core/schemas/detail.html @@ -50,5 +50,15 @@
Added on
{{ schema.created_at|date }}
+ {% if schema.permanent_urls.count > 0 %} +
  • +
    Permanent URL{{ schema.permanent_urls.count|pluralize }}
    + +
  • + {% endif %} {% endblock %} diff --git a/core/templates/core/schemas/detail_schema_ref.html b/core/templates/core/schemas/detail_schema_ref.html index e599221..ac6d3e0 100644 --- a/core/templates/core/schemas/detail_schema_ref.html +++ b/core/templates/core/schemas/detail_schema_ref.html @@ -47,5 +47,15 @@

    Unnamed definition

    Added on
    {{ schema_ref.created_at|date }}
    + {% if schema_ref.permanent_urls.count > 0 %} +
  • +
    Permanent URL{{ schema_ref.permanent_urls.count|pluralize }}
    + +
  • + {% endif %} {% endblock %} From 97e0d23603b97670863a04b30a060f20688b9afa Mon Sep 17 00:00:00 2001 From: alexbainter Date: Mon, 2 Feb 2026 14:45:31 -0600 Subject: [PATCH 06/12] Add permanent url redirect view --- core/models.py | 2 +- core/urls.py | 3 ++- core/views.py | 32 +++++++++++++++++++++++++++++ schemaindex/settings/development.py | 1 + schemaindex/settings/production.py | 4 +++- schemaindex/settings/staging.py | 3 ++- 6 files changed, 41 insertions(+), 4 deletions(-) diff --git a/core/models.py b/core/models.py index 247e852..4f503b7 100644 --- a/core/models.py +++ b/core/models.py @@ -24,7 +24,7 @@ def create(cls, created_by): return cls(created_by=created_by) class PermanentURLManager(models.Manager): - BASE_URL = 'https://id.schemas.pub/o/' + BASE_URL = f'https://{settings.PERMANENT_URL_HOST}/o/' def get_url_for_slug(self, *, organization, slug): return self.BASE_URL + organization.slug + '/' + slug diff --git a/core/urls.py b/core/urls.py index 0b888dc..c33f643 100644 --- a/core/urls.py +++ b/core/urls.py @@ -13,6 +13,7 @@ path("manage/schema//delete", views.manage_schema_delete, name="manage_schema_delete"), path("manage/schema//publish", views.manage_schema_publish, name="manage_schema_publish"), path("manage/schema//permanent-urls", views.manage_schema_permanent_urls, name="manage_schema_permanent_urls"), - path("organization/", views.organization_detail, name="organization_detail") + path("organization/", views.organization_detail, name="organization_detail"), + path("", views.permanent_url_redirect, name="permanent_url_redirect") ] diff --git a/core/views.py b/core/views.py index c8dcd5a..f3aad5c 100644 --- a/core/views.py +++ b/core/views.py @@ -8,6 +8,7 @@ from django.utils import timezone from django.core.exceptions import PermissionDenied from django.http import Http404 +from django.conf import settings from functools import wraps import cmarkgfm import bleach @@ -353,3 +354,34 @@ def manage_schema_permanent_urls(request, schema_id): 'form': form, 'prefix': prefix }) + + +def permanent_url_redirect(request, path): + host = request.get_host() + # Get rid of any port number + host = host.split(':', 1)[0] + if host != settings.PERMANENT_URL_HOST: + raise Http404 + + # This will match non-secure (http) requests + # to secure (https) values, but that's fine. + # We redirect all http to https in production + # and this way makes local testing easier. + full_url = f"https://{host}/{path}" + matching_url = get_object_or_404( + PermanentURL, + url=full_url + ) + if isinstance(matching_url.content_object, Schema): + schema = matching_url.content_object + return redirect('schema_detail', schema_id=schema.id) + elif isinstance(matching_url.content_object, SchemaRef): + schema_ref = matching_url.content_object + return redirect( + 'schema_ref_detail', + schema_id=schema_ref.schema.id, + schema_ref_id=schema_ref.id + ) + else: + raise Http404 + diff --git a/schemaindex/settings/development.py b/schemaindex/settings/development.py index 0486c1d..cd37494 100644 --- a/schemaindex/settings/development.py +++ b/schemaindex/settings/development.py @@ -2,3 +2,4 @@ DEBUG = True ALLOWED_HOSTS = ["localhost", "127.0.0.1"] +PERMANENT_URL_HOST = "localhost" diff --git a/schemaindex/settings/production.py b/schemaindex/settings/production.py index e94eaf7..b9aebc3 100644 --- a/schemaindex/settings/production.py +++ b/schemaindex/settings/production.py @@ -6,10 +6,12 @@ from .base import * DEBUG = False +PERMANENT_URL_HOST = 'id.schemas.pub' ALLOWED_HOSTS = [ 'schemas.pub', 'www.schemas.pub', - 'schemaindex-prod-run-768243509223.us-central1.run.app' + 'schemaindex-prod-run-768243509223.us-central1.run.app', + PERMANENT_URL_HOST ] SITE_URL = 'https://schemas.pub' CSRF_TRUSTED_ORIGINS = ['https://' + url for url in ALLOWED_HOSTS] diff --git a/schemaindex/settings/staging.py b/schemaindex/settings/staging.py index 9e5320d..893c042 100644 --- a/schemaindex/settings/staging.py +++ b/schemaindex/settings/staging.py @@ -1,6 +1,7 @@ from .production import * ALLOWED_HOSTS = ['schemaindex-stg-run-799626592344.us-central1.run.app'] +PERMANENT_URL_HOST = ALLOWED_HOSTS[0] SITE_URL = 'https://schemaindex-stg-run-799626592344.us-central1.run.app' CSRF_TRUSTED_ORIGINS = ['https://' + url for url in ALLOWED_HOSTS] GS_BUCKET_NAME = 'schemaindex-stg-storage' @@ -13,4 +14,4 @@ # Buckets URLs for static and media files STATIC_URL = f'https://storage.googleapis.com/{GS_BUCKET_NAME}/site-assets/' -MEDIA_URL = f'https://storage.googleapis.com/{GS_BUCKET_NAME}/schemas/' \ No newline at end of file +MEDIA_URL = f'https://storage.googleapis.com/{GS_BUCKET_NAME}/schemas/' From 075b2f62dbbc0d292044abb46c9fd271aee9d3d9 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Mon, 2 Feb 2026 14:53:40 -0600 Subject: [PATCH 07/12] Fix submitting management form with empty values --- core/views.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/core/views.py b/core/views.py index f3aad5c..aa551b9 100644 --- a/core/views.py +++ b/core/views.py @@ -334,17 +334,21 @@ def manage_schema_permanent_urls(request, schema_id): if request.method == 'POST': form = PermanentURLsForm(request.POST, schema=schema) if form.is_valid(): - PermanentURL.objects.create_from_slug( - created_by=request.user, - content_object=schema, - slug=form.cleaned_data['schema_slug'] - ) - for subform in form.schema_ref_permanent_url_formset: + schema_slug = form.cleaned_data.get('schema_slug') + if schema_slug: PermanentURL.objects.create_from_slug( created_by=request.user, - content_object=subform.schema_ref, - slug=subform.cleaned_data['slug'] + content_object=schema, + slug=schema_slug ) + for subform in form.schema_ref_permanent_url_formset: + schema_ref_slug = subform.cleaned_data.get('slug') + if schema_ref_slug: + PermanentURL.objects.create_from_slug( + created_by=request.user, + content_object=subform.schema_ref, + slug=schema_ref_slug + ) form = PermanentURLsForm(schema=schema) else: form = PermanentURLsForm(schema=schema) From 75ae6c1ea2fddc877b74e7123e6b90b8d0efe3b8 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Tue, 3 Feb 2026 12:08:51 -0600 Subject: [PATCH 08/12] Add unique check for new URL items --- core/forms.py | 12 +++++++++--- core/templates/core/manage/permanent_urls.html | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/core/forms.py b/core/forms.py index 4127338..f267022 100644 --- a/core/forms.py +++ b/core/forms.py @@ -271,11 +271,17 @@ def clean_slug(self): ) def clean(self): - # TODO: make sure there are no duplicate slugs self.schema_ref_permanent_url_formset.clean() cleaned_data = super().clean() + # Make sure none of the slugs are the same + schema_slug = cleaned_data.get('schema_slug') + slugs = {schema_slug} if schema_slug else set() + for schema_ref_form in self.schema_ref_permanent_url_formset: + schema_ref_slug = schema_ref_form.cleaned_data.get('slug') + if schema_ref_slug in slugs: + raise ValidationError('Each URL must be unique') + return cleaned_data def is_valid(self): - is_form_valid = super().is_valid() - return is_form_valid and self.schema_ref_permanent_url_formset.is_valid() + return self.schema_ref_permanent_url_formset.is_valid() and super().is_valid() diff --git a/core/templates/core/manage/permanent_urls.html b/core/templates/core/manage/permanent_urls.html index fb97e12..a62439b 100644 --- a/core/templates/core/manage/permanent_urls.html +++ b/core/templates/core/manage/permanent_urls.html @@ -45,6 +45,7 @@

    Create URLs

    {% include "core/manage/field.html" with field=subform.slug input_prefix=prefix %} {% endfor %} {{ form.schema_ref_permanent_url_formset.management_form }} + {{ form.non_field_errors }}
    From 6b39e6c7363e8eebbf8c3a64fef47128e28633a9 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Tue, 3 Feb 2026 17:14:36 -0600 Subject: [PATCH 09/12] Add unit tests --- core/forms.py | 4 +- core/urls.py | 2 +- schemaindex/settings/testing.py | 3 ++ tests/factories.py | 69 ++++++++++++++++++++++++++++++++- tests/test_forms.py | 57 ++++++++++++++++++++++++++- 5 files changed, 129 insertions(+), 6 deletions(-) diff --git a/core/forms.py b/core/forms.py index f267022..147fe1e 100644 --- a/core/forms.py +++ b/core/forms.py @@ -264,7 +264,7 @@ def __init__(self, *args, schema, **kwargs): for index, schema_ref_form in enumerate(self.schema_ref_permanent_url_formset): schema_ref_form.set_schema_ref(schema_refs[index], f"Definition {index + 1}") - def clean_slug(self): + def clean_schema_slug(self): return clean_permanent_url_slug( organization=self.schema.created_by.profile.organization, slug=self.cleaned_data['schema_slug'] @@ -279,7 +279,7 @@ def clean(self): for schema_ref_form in self.schema_ref_permanent_url_formset: schema_ref_slug = schema_ref_form.cleaned_data.get('slug') if schema_ref_slug in slugs: - raise ValidationError('Each URL must be unique') + raise ValidationError('Each URL must be unique.') return cleaned_data diff --git a/core/urls.py b/core/urls.py index c33f643..a2e61d9 100644 --- a/core/urls.py +++ b/core/urls.py @@ -5,7 +5,7 @@ urlpatterns = [ path("", views.index, name="index"), path("about", views.about), - path("schemas//", views.schema_detail, name="schema_detail"), + path("schemas/", views.schema_detail, name="schema_detail"), path("schemas//definition/", views.schema_ref_detail, name="schema_ref_detail"), path("account/profile/", views.account_profile, name="account_profile"), path("manage/schema/", views.manage_schema, name="manage_schema"), diff --git a/schemaindex/settings/testing.py b/schemaindex/settings/testing.py index 9b5ed21..eca8f33 100644 --- a/schemaindex/settings/testing.py +++ b/schemaindex/settings/testing.py @@ -1 +1,4 @@ from .base import * + +PERMANENT_URL_HOST = 'localhost' +ALLOWED_HOSTS = ['localhost'] diff --git a/tests/factories.py b/tests/factories.py index 19a6297..9cc465b 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,25 +1,65 @@ from factory.django import DjangoModelFactory from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.db.models.signals import post_save import factory from datetime import timezone from core.models import ( - BaseModel, Schema, ReferenceItem, SchemaRef, DocumentationItem + BaseModel, + Schema, + ReferenceItem, + SchemaRef, + DocumentationItem, + Organization, + Profile, + PermanentURL ) +class ProfileFactory(DjangoModelFactory): + class Meta: + model = Profile + user = factory.SubFactory('tests.factories.UserFactory', profile=None) + + +@factory.django.mute_signals(post_save) class UserFactory(DjangoModelFactory): class Meta: model = User + skip_postgeneration_save = True username = factory.Faker('user_name') email = factory.Faker('email') password = factory.django.Password('testpassword') + profile = factory.RelatedFactory( + ProfileFactory, + factory_related_name='user', + ) class BaseModelFactory(DjangoModelFactory): created_by = factory.SubFactory(UserFactory) +class OrganizationFactory(BaseModelFactory): + class Meta: + model = Organization + + name = factory.Faker('bs') + slug = factory.Faker('slug') + + +class OrganizationProfileFactory(ProfileFactory): + organization = factory.SubFactory(OrganizationFactory) + + +class OrganizationUserFactory(UserFactory): + profile = factory.RelatedFactory( + OrganizationProfileFactory, + factory_related_name='user', + ) + + class SchemaFactory(BaseModelFactory): class Meta: model = Schema @@ -28,6 +68,10 @@ class Meta: published_at = factory.Faker("past_datetime", tzinfo=timezone.utc) +class OrganizationSchemaFactory(SchemaFactory): + created_by = factory.SubFactory(OrganizationUserFactory) + + class ReferenceItemFactory(BaseModelFactory): class Meta: model = ReferenceItem @@ -42,6 +86,11 @@ class Meta: schema = factory.SubFactory(SchemaFactory) +class OrganizationSchemaRefFactory(SchemaRefFactory): + schema = factory.SubFactory(OrganizationSchemaFactory) + created_by = factory.LazyAttribute(lambda instance: instance.schema.created_by) + + class DocumentationItemFactory(ReferenceItemFactory): class Meta: model = DocumentationItem @@ -52,3 +101,21 @@ class Meta: role = factory.Iterator(DocumentationItem.DocumentationItemRole.values) format = factory.Iterator(DocumentationItem.DocumentationItemFormat.values) + +# To create a PermanentURLFactory instance, +# you *must* pass a slug and a content_object. +class PermanentURLFactory(DjangoModelFactory): + class Meta: + model = PermanentURL + + slug = factory.Faker('slug') + + @classmethod + def _create(cls, model_class, *args, **kwargs): + slug = kwargs.pop('slug') + content_object=kwargs.get('content_object') + return model_class.objects.create_from_slug( + created_by=content_object.created_by, + slug=slug, + **kwargs + ) diff --git a/tests/test_forms.py b/tests/test_forms.py index 313302c..150eef6 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -1,8 +1,16 @@ import pytest import requests_mock from urllib.parse import urlparse, urlunparse -from core.forms import SchemaForm, clean_url -from tests.factories import SchemaFactory, SchemaRefFactory +from django.contrib.contenttypes.models import ContentType +from core.forms import SchemaForm, clean_url, PermanentURLsForm +from core.models import Schema +from tests.factories import ( + SchemaFactory, + SchemaRefFactory, + OrganizationSchemaRefFactory, + OrganizationSchemaFactory, + PermanentURLFactory +) from core.models import DocumentationItem @pytest.mark.django_db @@ -85,3 +93,48 @@ def test_schema_management_form_requires_one_schema_ref(): error = form.non_field_errors()[0] assert error == 'A schema must have at least one definition' + +@pytest.mark.django_db +@pytest.mark.parametrize( + "attempted_slug_field_name", + ["schema_slug", "form-0-slug"] +) +def test_schema_permanent_url_form_prevents_duplicate_urls(attempted_slug_field_name): + managed_schema_ref = OrganizationSchemaRefFactory() + other_schema = SchemaFactory(created_by=managed_schema_ref.schema.created_by) + duplicate_slug_value = 'duplicate_slug' + other_schema_permanent_url = PermanentURLFactory( + content_object=other_schema, + slug=duplicate_slug_value + ) + form_data = { + 'schema_slug': '', + 'form-0-slug': '', + 'form-TOTAL_FORMS': 1, + 'form-INITIAL_FORMS': 1 + } + form_data[attempted_slug_field_name] = duplicate_slug_value + form = PermanentURLsForm(schema=managed_schema_ref.schema, data=form_data) + assert not form.is_valid() + if attempted_slug_field_name == 'schema_slug': + error = form['schema_slug'].errors[0] + else: + error = form.schema_ref_permanent_url_formset.errors[0]['slug'][0] + assert error == 'This URL is already in use.' + + +@pytest.mark.django_db +def test_schema_permanent_url_form_prevents_new_duplicate_urls(): + managed_schema_ref = OrganizationSchemaRefFactory() + duplicate_slug_value = 'duplicate_slug' + form_data = { + 'schema_slug': duplicate_slug_value, + 'form-0-slug': duplicate_slug_value, + 'form-TOTAL_FORMS': 1, + 'form-INITIAL_FORMS': 1 + } + form = PermanentURLsForm(schema=managed_schema_ref.schema, data=form_data) + assert not form.is_valid() + error = form.non_field_errors()[0] + assert error == 'Each URL must be unique.' + From d7973e6c4e0ae93b8752471e75e837594fbdeb64 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Wed, 4 Feb 2026 10:34:33 -0600 Subject: [PATCH 10/12] Update pattern for permanent URL view and add tests --- core/urls.py | 2 +- core/views.py | 4 ++-- schemaindex/settings/testing.py | 4 ++-- tests/test_views.py | 27 ++++++++++++++++++++++++--- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/core/urls.py b/core/urls.py index a2e61d9..13b82aa 100644 --- a/core/urls.py +++ b/core/urls.py @@ -14,6 +14,6 @@ path("manage/schema//publish", views.manage_schema_publish, name="manage_schema_publish"), path("manage/schema//permanent-urls", views.manage_schema_permanent_urls, name="manage_schema_permanent_urls"), path("organization/", views.organization_detail, name="organization_detail"), - path("", views.permanent_url_redirect, name="permanent_url_redirect") + path("o/", views.permanent_url_redirect, name="permanent_url_redirect") ] diff --git a/core/views.py b/core/views.py index aa551b9..50afea4 100644 --- a/core/views.py +++ b/core/views.py @@ -360,7 +360,7 @@ def manage_schema_permanent_urls(request, schema_id): }) -def permanent_url_redirect(request, path): +def permanent_url_redirect(request, partial_path): host = request.get_host() # Get rid of any port number host = host.split(':', 1)[0] @@ -371,7 +371,7 @@ def permanent_url_redirect(request, path): # to secure (https) values, but that's fine. # We redirect all http to https in production # and this way makes local testing easier. - full_url = f"https://{host}/{path}" + full_url = f"https://{host}{request.path}" matching_url = get_object_or_404( PermanentURL, url=full_url diff --git a/schemaindex/settings/testing.py b/schemaindex/settings/testing.py index eca8f33..fc86428 100644 --- a/schemaindex/settings/testing.py +++ b/schemaindex/settings/testing.py @@ -1,4 +1,4 @@ from .base import * -PERMANENT_URL_HOST = 'localhost' -ALLOWED_HOSTS = ['localhost'] +PERMANENT_URL_HOST = 'testserver' +ALLOWED_HOSTS = [PERMANENT_URL_HOST] diff --git a/tests/test_views.py b/tests/test_views.py index 0a01fd6..fc18d3c 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,8 +1,12 @@ import pytest import requests_mock from tests.factories import ( - SchemaFactory, UserFactory, SchemaRefFactory, - DocumentationItemFactory + SchemaFactory, + UserFactory, + SchemaRefFactory, + DocumentationItemFactory, + OrganizationSchemaFactory, + PermanentURLFactory ) from core.models import Schema, DocumentationItem from django.test import Client @@ -172,4 +176,21 @@ def test_published_schemas_cannot_be_deleted(): post_response = client.post(f'/manage/schema/{schema.id}/delete', follow=True) assert post_response.status_code == 403 assert Schema.objects.filter(id=schema.id).exists() - + + +@pytest.mark.django_db +def test_invalid_permanent_urls_404(): + client = Client() + response = client.get('/o/bad/path') + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_matching_permanent_urls_redirect(): + slug = 'test' + schema = OrganizationSchemaFactory() + permanent_url = PermanentURLFactory(content_object=schema, slug=slug) + client = Client() + response = client.get(f'/o/{schema.created_by.profile.organization.slug}/{slug}', follow=True) + assert response.status_code == 200 + From 9f38f07f134545df27180ca8317e03fbcd7dd738 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Wed, 4 Feb 2026 10:53:57 -0600 Subject: [PATCH 11/12] Hide permanent url management form link for private schemas --- core/templates/account/profile.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/templates/account/profile.html b/core/templates/account/profile.html index b894bad..313bb94 100644 --- a/core/templates/account/profile.html +++ b/core/templates/account/profile.html @@ -28,7 +28,7 @@

    My Schemas

    - {% if request.user.profile.organization %} + {% if request.user.profile.organization and schema.published_at|exists_and_is_in_past %} From b98072f48787b10b91822cd0708d12be5328ee84 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Wed, 4 Feb 2026 11:30:56 -0600 Subject: [PATCH 12/12] Add a couple more tests --- tests/factories.py | 10 +++++----- tests/test_views.py | 27 ++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/tests/factories.py b/tests/factories.py index 9cc465b..f487228 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -45,8 +45,8 @@ class OrganizationFactory(BaseModelFactory): class Meta: model = Organization - name = factory.Faker('bs') - slug = factory.Faker('slug') + name = factory.Faker('company') + slug = factory.Sequence(lambda n: f'orgslug-{n}') class OrganizationProfileFactory(ProfileFactory): @@ -64,7 +64,7 @@ class SchemaFactory(BaseModelFactory): class Meta: model = Schema - name = factory.Faker('bs') + name = factory.Faker('catch_phrase') published_at = factory.Faker("past_datetime", tzinfo=timezone.utc) @@ -95,7 +95,7 @@ class DocumentationItemFactory(ReferenceItemFactory): class Meta: model = DocumentationItem - name = factory.Faker('bs') + name = factory.Faker('words', nb=3) description = factory.Faker('paragraph') schema = factory.SubFactory(SchemaFactory) role = factory.Iterator(DocumentationItem.DocumentationItemRole.values) @@ -108,7 +108,7 @@ class PermanentURLFactory(DjangoModelFactory): class Meta: model = PermanentURL - slug = factory.Faker('slug') + slug = factory.Sequence(lambda n: f'permanenturlslug-{n}') @classmethod def _create(cls, model_class, *args, **kwargs): diff --git a/tests/test_views.py b/tests/test_views.py index fc18d3c..040c1fb 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -6,10 +6,12 @@ SchemaRefFactory, DocumentationItemFactory, OrganizationSchemaFactory, + OrganizationSchemaRefFactory, PermanentURLFactory ) from core.models import Schema, DocumentationItem from django.test import Client +from pytest_django.asserts import assertRedirects @pytest.mark.django_db @@ -186,11 +188,34 @@ def test_invalid_permanent_urls_404(): @pytest.mark.django_db -def test_matching_permanent_urls_redirect(): +def test_matching_permanent_urls_redirect_to_schemas(): slug = 'test' schema = OrganizationSchemaFactory() permanent_url = PermanentURLFactory(content_object=schema, slug=slug) client = Client() response = client.get(f'/o/{schema.created_by.profile.organization.slug}/{slug}', follow=True) assert response.status_code == 200 + assertRedirects(response, f'/schemas/{schema.id}') + + +@pytest.mark.django_db +def test_matching_permanent_urls_redirect_to_schema_refs(): + slug = 'test' + schema_ref = OrganizationSchemaRefFactory() + permanent_url = PermanentURLFactory(content_object=schema_ref, slug=slug) + client = Client() + with requests_mock.Mocker() as m: + m.get(schema_ref.url, text='{}') + response = client.get(f'/o/{schema_ref.created_by.profile.organization.slug}/{slug}', follow=True) + assert response.status_code == 200 + assertRedirects(response, f'/schemas/{schema_ref.schema.id}/definition/{schema_ref.id}') + + +@pytest.mark.django_db +def test_permanent_urlmanagement_form_404_for_private_schema(): + schema = SchemaFactory(published_at=None) + client = Client() + client.force_login(schema.created_by) + response = client.get(f'/schemas/{schema.id}/permanent-urls', follow=True) + assert response.status_code == 404