Closes #11. ## Summary - Serve Django admin on LAN IP `10.0.0.128` (not `piha.li`). Compose now publishes `0.0.0.0:8005` and passes `SHORT_ADMIN_HOSTS` into the container. - Admin index has a campaign mint form: domain, campaign, source, metric. Save builds `https://{domain}/?utm_campaign=&utm_source=&utm_medium=` (metric) and shows a copyable short URL. - Public `/admin/` on `piha.lc` / `piha.li` stays 404. ## Test plan - [ ] `cd site && uv run python manage.py test` - [ ] Recreate compose (`docker compose up --build`) so `WEB_BIND` / `SHORT_ADMIN_HOSTS` take effect - [ ] From another LAN machine: `http://10.0.0.128:8005/admin/` (staff login) shows the mint form - [ ] Save a link for an allowlisted domain, copy the short URL, confirm it 302s - [ ] `https://piha.li/admin` still 404 Reviewed-on: #12
212 lines
6.5 KiB
Python
212 lines
6.5 KiB
Python
from django.contrib import admin, messages
|
|
from django.forms import ModelForm, ValidationError as FormValidationError
|
|
from django.utils.html import format_html
|
|
|
|
from links.forms import QuickMintForm, allowlisted_domain_choices
|
|
from links.models import Click, ShortLink
|
|
from links.services import (
|
|
CodeCollisionError,
|
|
ValidationError,
|
|
create_link,
|
|
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 "—")
|
|
|
|
|
|
def _quick_mint_context(request, extra_context=None):
|
|
extra = extra_context.copy() if extra_context else {}
|
|
extra.setdefault("quick_mint_form", QuickMintForm())
|
|
extra.setdefault("created_link", None)
|
|
extra.setdefault("allowed_domains", allowlisted_domain_choices())
|
|
return extra
|
|
|
|
|
|
def _mint_from_form(request, form: QuickMintForm):
|
|
token_name = request.user.get_username() if request.user.is_authenticated else "admin"
|
|
campaign = form.cleaned_data["campaign"].strip()
|
|
return create_link(
|
|
target_url=form.cleaned_data["target_url"],
|
|
title=campaign,
|
|
external_ref="",
|
|
expires_at=None,
|
|
token_name=token_name or "admin",
|
|
)
|
|
|
|
|
|
_orig_index = admin.site.index
|
|
|
|
|
|
def _admin_index(request, extra_context=None):
|
|
extra = _quick_mint_context(request, extra_context)
|
|
form = QuickMintForm(request.POST or None)
|
|
extra["quick_mint_form"] = form
|
|
if request.method == "POST":
|
|
if form.is_valid():
|
|
try:
|
|
link, minted = _mint_from_form(request, form)
|
|
except CodeCollisionError:
|
|
form.add_error(None, "Could not allocate a unique code.")
|
|
else:
|
|
extra["created_link"] = link
|
|
extra["quick_mint_form"] = QuickMintForm()
|
|
messages.success(
|
|
request,
|
|
"Short link created." if minted else "Existing active link returned.",
|
|
)
|
|
return _orig_index(request, extra)
|
|
|
|
|
|
admin.site.index = _admin_index
|