Skip to content
Merged
8 changes: 7 additions & 1 deletion core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
SchemaRef,
DocumentationItem,
Organization,
Profile
Profile,
PermanentURL
)


Expand All @@ -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']
79 changes: 78 additions & 1 deletion core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
32 changes: 32 additions & 0 deletions core/migrations/0010_permanenturl.py
Original file line number Diff line number Diff line change
@@ -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')],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this index intentional ? If we do want it to make URL lookups fast, wouldn't it be to the URL instead of the object ID?

Together with the unusual dependencies, not just 'core' in the dependencies list above, I wonder if this is necessary?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NM I get it, because it's using the generic foreign key

},
),
]
40 changes: 40 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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 = [
Expand Down Expand Up @@ -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):
Expand Down
57 changes: 53 additions & 4 deletions core/static/css/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -214,6 +222,10 @@ select {
color: var(--text-secondary);
}

.text--warning {
color: var(--warning-color-text);
}

.account-page {
width: 100%;
max-width: 30rem;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
17 changes: 9 additions & 8 deletions core/templates/account/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ <h1>My Schemas</h1>
<a href="{% url 'manage_schema' schema_id=schema.pk %}">
<i data-lucide="square-pen" class="icon--md"></i>
</a>
{% if request.user.profile.organization and schema.published_at|exists_and_is_in_past %}
<a href="{% url 'manage_schema_permanent_urls' schema_id=schema.id %}">
<i data-lucide="link" class="icon--md"></i>
</a>
{% endif %}
{% if not schema.published_at|exists_and_is_in_past %}
<a href="{% url 'manage_schema_delete' schema_id=schema.pk %}">
<a href="{% url 'manage_schema_delete' schema_id=schema.id %}">
<i data-lucide="trash" class="icon--md"></i>
</a>
{% endif %}
Expand All @@ -47,13 +52,9 @@ <h1>My Schemas</h1>
{% if request.user.profile.organization %}
<section class="text--secondary">
<h1>Organization</h1>
<ul>
<li>
<a href="{% url 'organization_detail' organization_id=request.user.profile.organization.id %}">
{{ request.user.profile.organization.name }}
</a>
</li>
</ul>
<a href="{% url 'organization_detail' organization_id=request.user.profile.organization.id %}">
{{ request.user.profile.organization.name }}
</a>
</section>
{% endif %}
<section class="text--secondary">
Expand Down
9 changes: 9 additions & 0 deletions core/templates/core/manage/field.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
class="{% if field.field.required %}field__label--is-required{% endif %}">
{{ field.label }}
</label>
{% if input_prefix %}
<div class="input-group">
<div class="input-group__prefix">
{{ input_prefix }}
</div>
{{ field }}
</div>
{% else %}
{{ field }}
{% endif %}
{% if field.help_text %}
<div class="field__help-text">
{{ field.help_text }}
Expand Down
Loading