Implement v1 URL shortener (Bearer API, public 302, landing, CI) (#2)
Deploy Beta / unit-tests (push) Successful in 4s
Deploy Beta / docker (push) Successful in 13s
Deploy Beta / deploy-beta (push) Successful in 2m38s

## Summary

- Standalone Django 6 shortener: Bearer `/api/links/` (create/list/detail/disable) and public `GET /<code>` 302
- Host split, target-host allowlist, named rotatable tokens; `short_url` from `PUBLIC_SHORT_URL`
- Landing page, DEBUG-only `/debug/` mint form, Django admin
- Docker/compose (host **8005**), Gitea CI like monica_site (PR tests, beta on merge, prod button)
- Caller contract in `API.md`

Closes #1. Infra follow-up: [server-infra#22](ai_ml_operations/server-infra#22).

## Test plan
- [ ] `cd site && uv run python manage.py test`
- [ ] `docker compose up --build` → http://127.0.0.1:8005/
- [ ] `POST /api/links/` with `Bearer monica:dev-only-token` → 201
- [ ] `GET /<code>` → 302 to allowlisted https URL
- [ ] No Bearer → 401; non-allowlisted host → 400
- [ ] `/debug/` only when `DEBUG=true`

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-08-30 04:55:33 -07:00
parent 4baaa4b33c
commit 630b770c12
48 changed files with 3469 additions and 2 deletions
+158
View File
@@ -0,0 +1,158 @@
from django.contrib import admin, messages
from django.forms import ModelForm, ValidationError as FormValidationError
from django.utils.html import format_html
from links.models import Click, ShortLink
from links.services import ValidationError, generate_code, validate_target_url
admin.site.site_header = "URL shortener"
admin.site.site_title = "Shortener admin"
admin.site.index_title = "Links and clicks"
class ShortLinkAdminForm(ModelForm):
class Meta:
model = ShortLink
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if "code" in self.fields:
self.fields["code"].required = False
if "created_by_token" in self.fields:
self.fields["created_by_token"].required = False
def clean_target_url(self):
raw = self.cleaned_data.get("target_url") or ""
try:
return validate_target_url(raw)
except ValidationError as exc:
raise FormValidationError(str(exc)) from exc
def clean(self):
cleaned = super().clean()
if not self.instance.pk and not cleaned.get("code"):
cleaned["code"] = generate_code()
if not cleaned.get("created_by_token"):
cleaned["created_by_token"] = "admin"
return cleaned
class ClickInline(admin.TabularInline):
model = Click
extra = 0
can_delete = False
show_change_link = True
max_num = 0
readonly_fields = ("occurred_at", "ip_hash", "user_agent", "referrer")
fields = ("occurred_at", "ip_hash", "user_agent", "referrer")
def has_add_permission(self, request, obj=None):
return False
@admin.register(ShortLink)
class ShortLinkAdmin(admin.ModelAdmin):
form = ShortLinkAdminForm
list_display = (
"code",
"short_url_display",
"target_url",
"title",
"is_active",
"click_count",
"created_by_token",
"expires_at",
"created_at",
)
list_filter = ("is_active", "created_by_token", "created_at")
search_fields = ("code", "target_url", "external_ref", "title")
date_hierarchy = "created_at"
ordering = ("-created_at",)
list_per_page = 50
actions = ("disable_links",)
inlines = (ClickInline,)
readonly_fields = (
"id",
"code",
"created_by_token",
"click_count",
"created_at",
"updated_at",
"short_url_display",
)
fieldsets = (
(
None,
{
"fields": (
"code",
"short_url_display",
"target_url",
"title",
"external_ref",
"is_active",
"expires_at",
)
},
),
(
"Attribution",
{"fields": ("created_by_token", "click_count", "created_at", "updated_at", "id")},
),
)
def get_readonly_fields(self, request, obj=None):
if obj:
return self.readonly_fields + ("target_url",)
return self.readonly_fields
@admin.display(description="Short URL")
def short_url_display(self, obj: ShortLink) -> str:
if not obj.code:
return ""
return format_html('<a href="{0}">{0}</a>', obj.public_short_url)
def save_model(self, request, obj, form, change):
if not change:
if not obj.code:
obj.code = generate_code()
if not obj.created_by_token:
obj.created_by_token = (
request.user.get_username() if request.user.is_authenticated else "admin"
)
super().save_model(request, obj, form, change)
@admin.action(description="Disable selected links")
def disable_links(self, request, queryset):
updated = queryset.filter(is_active=True).update(is_active=False)
self.message_user(
request,
f"Disabled {updated} link(s).",
messages.SUCCESS if updated else messages.INFO,
)
@admin.register(Click)
class ClickAdmin(admin.ModelAdmin):
list_display = ("link", "occurred_at", "ip_hash_short", "user_agent_short")
list_filter = ("occurred_at",)
search_fields = ("link__code", "ip_hash", "user_agent", "referrer")
date_hierarchy = "occurred_at"
readonly_fields = ("id", "link", "occurred_at", "ip_hash", "user_agent", "referrer")
ordering = ("-occurred_at",)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.display(description="IP hash")
def ip_hash_short(self, obj: Click) -> str:
return (obj.ip_hash[:12] + "") if obj.ip_hash else ""
@admin.display(description="User agent")
def user_agent_short(self, obj: Click) -> str:
ua = obj.user_agent or ""
return (ua[:48] + "") if len(ua) > 48 else (ua or "")