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/forms.py b/core/forms.py
index 10d76e5..147fe1e 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,80 @@ 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.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
+ self.fields['slug'].label = "New unique URL for " + self.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_schema_slug(self):
+ return clean_permanent_url_slug(
+ organization=self.schema.created_by.profile.organization,
+ slug=self.cleaned_data['schema_slug']
+ )
+
+ def clean(self):
+ 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):
+ return self.schema_ref_permanent_url_formset.is_valid() and super().is_valid()
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..4f503b7 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
@@ -20,6 +23,41 @@ class Meta:
def create(cls, created_by):
return cls(created_by=created_by)
+class PermanentURLManager(models.Manager):
+ BASE_URL = f'https://{settings.PERMANENT_URL_HOST}/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.
+ indexes = [
+ models.Index(fields=["content_type", "object_id"])
+ ]
+
class PublicSchemaManager(models.Manager):
def get_queryset(self):
@@ -36,6 +74,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 +287,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):
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/account/profile.html b/core/templates/account/profile.html
index deb4229..313bb94 100644
--- a/core/templates/account/profile.html
+++ b/core/templates/account/profile.html
@@ -28,8 +28,13 @@
My Schemas
+ {% if request.user.profile.organization and schema.published_at|exists_and_is_in_past %}
+
+
+
+ {% endif %}
{% if not schema.published_at|exists_and_is_in_past %}
-
+
{% endif %}
@@ -47,13 +52,9 @@ My Schemas
{% if request.user.profile.organization %}
{% endif %}
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 %}
+
+ {% 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..a62439b
--- /dev/null
+++ b/core/templates/core/manage/permanent_urls.html
@@ -0,0 +1,53 @@
+{% extends "core/layouts/base.html" %}
+{% load filters %}
+{% block head_title %}
+Permanent URLs for {{ schema.name }}
+{% endblock %}
+{% block page_content %}
+
+
+ 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
+
+
+
+{% endblock %}
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 %}
diff --git a/core/urls.py b/core/urls.py
index 5b27f98..13b82aa 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -5,13 +5,15 @@
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"),
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("organization/", views.organization_detail, name="organization_detail")
+ path("manage/schema//permanent-urls", views.manage_schema_permanent_urls, name="manage_schema_permanent_urls"),
+ path("organization/", views.organization_detail, name="organization_detail"),
+ path("o/", views.permanent_url_redirect, name="permanent_url_redirect")
]
diff --git a/core/views.py b/core/views.py
index 9085bfe..50afea4 100644
--- a/core/views.py
+++ b/core/views.py
@@ -7,16 +7,23 @@
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 django.conf import settings
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 +316,76 @@ 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():
+ schema_slug = form.cleaned_data.get('schema_slug')
+ if schema_slug:
+ PermanentURL.objects.create_from_slug(
+ created_by=request.user,
+ 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)
+
+ return render(request, "core/manage/permanent_urls.html", {
+ 'schema': schema,
+ 'form': form,
+ 'prefix': prefix
+ })
+
+
+def permanent_url_redirect(request, partial_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}{request.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/'
diff --git a/schemaindex/settings/testing.py b/schemaindex/settings/testing.py
index 9b5ed21..fc86428 100644
--- a/schemaindex/settings/testing.py
+++ b/schemaindex/settings/testing.py
@@ -1 +1,4 @@
from .base import *
+
+PERMANENT_URL_HOST = 'testserver'
+ALLOWED_HOSTS = [PERMANENT_URL_HOST]
diff --git a/tests/factories.py b/tests/factories.py
index 19a6297..f487228 100644
--- a/tests/factories.py
+++ b/tests/factories.py
@@ -1,33 +1,77 @@
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('company')
+ slug = factory.Sequence(lambda n: f'orgslug-{n}')
+
+
+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
- name = factory.Faker('bs')
+ name = factory.Faker('catch_phrase')
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,13 +86,36 @@ 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
- name = factory.Faker('bs')
+ name = factory.Faker('words', nb=3)
description = factory.Faker('paragraph')
schema = factory.SubFactory(SchemaFactory)
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.Sequence(lambda n: f'permanenturlslug-{n}')
+
+ @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.'
+
diff --git a/tests/test_views.py b/tests/test_views.py
index 0a01fd6..040c1fb 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -1,11 +1,17 @@
import pytest
import requests_mock
from tests.factories import (
- SchemaFactory, UserFactory, SchemaRefFactory,
- DocumentationItemFactory
+ SchemaFactory,
+ UserFactory,
+ 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
@@ -172,4 +178,44 @@ 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_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
+