Skip to content

Commit a456b0c

Browse files
committed
added new shortlinks page
1 parent fe710e1 commit a456b0c

File tree

13 files changed

+604
-2
lines changed

13 files changed

+604
-2
lines changed

hknweb/candidate/admin/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
from hknweb.candidate.admin.bitbyteactivity import BitByteActivityAdmin
33
from hknweb.candidate.admin.officer_challenge import OffChallengeAdmin
44
from hknweb.candidate.admin.logistics import LogisticsAdmin
5+
from hknweb.candidate.admin.shortlink import ShortLinkAdmin
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from django.contrib import admin
2+
3+
from hknweb.candidate.models import ShortLink
4+
5+
6+
@admin.register(ShortLink)
7+
class ShortLinkAdmin(admin.ModelAdmin):
8+
fields = ["slug", "destination_url", "description", "created_by", "active"]
9+
list_display = ("slug", "destination_url", "click_count", "created_by", "active", "created_at")
10+
list_filter = ["active", "created_at"]
11+
search_fields = ["slug", "destination_url", "description"]
12+
readonly_fields = ["click_count", "created_at", "updated_at"]
13+
14+
actions = ["activate", "deactivate", "reset_clicks"]
15+
16+
def activate(self, request, queryset):
17+
queryset.update(active=True)
18+
19+
activate.short_description = "Activate selected shortlinks"
20+
21+
def deactivate(self, request, queryset):
22+
queryset.update(active=False)
23+
24+
deactivate.short_description = "Deactivate selected shortlinks"
25+
26+
def reset_clicks(self, request, queryset):
27+
queryset.update(click_count=0)
28+
29+
reset_clicks.short_description = "Reset click count to 0"

hknweb/candidate/forms.py

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
from django import forms
22
from django.contrib.auth.models import User
3+
from django.core.validators import URLValidator
4+
from django.core.exceptions import ValidationError
35

46
from dal import autocomplete
57

6-
from hknweb.candidate.models import BitByteActivity, OffChallenge
8+
import csv
9+
import re
10+
11+
from hknweb.candidate.models import BitByteActivity, OffChallenge, ShortLink
712

813

914
TEXT_AREA_STYLE = (
@@ -38,3 +43,137 @@ class Meta:
3843
def __init__(self, *args, **kwargs):
3944
super(BitByteRequestForm, self).__init__(*args, **kwargs)
4045
self.fields["participants"].queryset = User.objects.order_by("username")
46+
47+
48+
class CreateShortLinkForm(forms.ModelForm):
49+
"""
50+
Form for creating a single shortlink.
51+
"""
52+
53+
class Meta:
54+
model = ShortLink
55+
fields = ["slug", "destination_url", "description"]
56+
widgets = {
57+
"slug": forms.TextInput(attrs={"placeholder": "e.g., discord, apply"}),
58+
"destination_url": forms.URLInput(
59+
attrs={"placeholder": "https://example.com"}
60+
),
61+
"description": forms.TextInput(
62+
attrs={"placeholder": "Optional description"}
63+
),
64+
}
65+
help_texts = {
66+
"slug": "Short code (letters, numbers, hyphens, underscores only)",
67+
"destination_url": "Full URL to redirect to",
68+
"description": "Optional description for reference",
69+
}
70+
71+
def clean_slug(self):
72+
slug = self.cleaned_data.get("slug")
73+
if slug and not re.match(r"^[a-zA-Z0-9-_]+$", slug):
74+
raise ValidationError(
75+
"Slug can only contain letters, numbers, hyphens, and underscores"
76+
)
77+
return slug
78+
79+
80+
class ImportShortLinksForm(forms.Form):
81+
"""
82+
Form for importing shortlinks from CSV.
83+
Expected CSV format: "In url,Out url,Creator,..." (additional columns ignored)
84+
"""
85+
86+
file = forms.FileField(
87+
help_text='Upload a CSV file with columns: "In url", "Out url", "Creator"'
88+
)
89+
90+
REQUIRED_CSV_FIELDNAMES = {"In url", "Out url", "Creator"}
91+
SLUG_PATTERN = re.compile(r"^[a-zA-Z0-9-_]+$")
92+
93+
def clean_file(self):
94+
file_wrapper = self.cleaned_data["file"]
95+
96+
# Check file extension
97+
if not file_wrapper.name.endswith(".csv"):
98+
raise ValidationError("File must be a CSV file")
99+
100+
return file_wrapper
101+
102+
def save(self, user):
103+
"""
104+
Process the CSV and create/update shortlinks.
105+
Returns tuple: (created_count, updated_count, errors)
106+
"""
107+
file_wrapper = self.cleaned_data["file"]
108+
109+
# Decode file and parse CSV
110+
decoded_file = file_wrapper.read().decode("utf-8").splitlines()
111+
reader = csv.DictReader(decoded_file)
112+
rows = list(reader)
113+
114+
# Validate fieldnames
115+
uploaded_fieldnames = set(reader.fieldnames)
116+
if not self.REQUIRED_CSV_FIELDNAMES.issubset(uploaded_fieldnames):
117+
missing = self.REQUIRED_CSV_FIELDNAMES.difference(uploaded_fieldnames)
118+
raise forms.ValidationError(
119+
f"CSV is missing required columns: {', '.join(missing)}"
120+
)
121+
122+
# Process rows
123+
url_validator = URLValidator()
124+
created_count = 0
125+
updated_count = 0
126+
errors = []
127+
128+
for i, row in enumerate(rows, start=2): # Start at 2 (header is row 1)
129+
slug = row["In url"].strip()
130+
destination_url = row["Out url"].strip()
131+
creator_name = row["Creator"].strip()
132+
133+
# Skip empty rows
134+
if not slug and not destination_url:
135+
continue
136+
137+
# Validate slug
138+
if not slug:
139+
errors.append(f"Row {i}: Missing slug")
140+
continue
141+
142+
if not self.SLUG_PATTERN.match(slug):
143+
errors.append(
144+
f"Row {i}: Invalid slug '{slug}' (only letters, numbers, hyphens, underscores allowed)"
145+
)
146+
continue
147+
148+
# Validate destination URL
149+
if not destination_url:
150+
errors.append(f"Row {i}: Missing destination URL for slug '{slug}'")
151+
continue
152+
153+
try:
154+
url_validator(destination_url)
155+
except ValidationError:
156+
errors.append(f"Row {i}: Invalid URL '{destination_url}' for slug '{slug}'")
157+
continue
158+
159+
# Create or update shortlink
160+
try:
161+
shortlink, created = ShortLink.objects.update_or_create(
162+
slug=slug,
163+
defaults={
164+
"destination_url": destination_url,
165+
"description": f"Created by {creator_name}",
166+
"created_by": user,
167+
"active": True,
168+
},
169+
)
170+
171+
if created:
172+
created_count += 1
173+
else:
174+
updated_count += 1
175+
176+
except Exception as e:
177+
errors.append(f"Row {i}: Error processing slug '{slug}': {str(e)}")
178+
179+
return created_count, updated_count, errors
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Generated by Django 4.2.17 on 2025-11-21 02:08
2+
3+
from django.conf import settings
4+
import django.core.validators
5+
from django.db import migrations, models
6+
import django.db.models.deletion
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
("candidate", "0015_logistics_mandatory_events"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="ShortLink",
18+
fields=[
19+
(
20+
"id",
21+
models.AutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
(
29+
"slug",
30+
models.SlugField(
31+
help_text="Short code for the URL (e.g., 'discord', 'apply')",
32+
max_length=100,
33+
unique=True,
34+
validators=[
35+
django.core.validators.RegexValidator(
36+
message="Slug can only contain letters, numbers, hyphens, and underscores",
37+
regex="^[a-zA-Z0-9-_]+$",
38+
)
39+
],
40+
),
41+
),
42+
(
43+
"destination_url",
44+
models.URLField(
45+
help_text="Full URL to redirect to", max_length=2000
46+
),
47+
),
48+
(
49+
"description",
50+
models.CharField(
51+
blank=True,
52+
help_text="Optional description for officers",
53+
max_length=500,
54+
),
55+
),
56+
("created_at", models.DateTimeField(auto_now_add=True)),
57+
("updated_at", models.DateTimeField(auto_now=True)),
58+
(
59+
"click_count",
60+
models.PositiveIntegerField(
61+
default=0,
62+
help_text="Number of times this shortlink has been used",
63+
),
64+
),
65+
(
66+
"active",
67+
models.BooleanField(
68+
default=True, help_text="Whether this shortlink is active"
69+
),
70+
),
71+
(
72+
"created_by",
73+
models.ForeignKey(
74+
help_text="Officer who created this shortlink",
75+
null=True,
76+
on_delete=django.db.models.deletion.SET_NULL,
77+
related_name="created_shortlinks",
78+
to=settings.AUTH_USER_MODEL,
79+
),
80+
),
81+
],
82+
options={
83+
"verbose_name": "Short Link",
84+
"verbose_name_plural": "Short Links",
85+
"ordering": ["-created_at"],
86+
},
87+
),
88+
]

hknweb/candidate/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
from hknweb.candidate.models.officer_challenge import OffChallenge
33
from hknweb.candidate.models.announcement import Announcement
44
from hknweb.candidate.models.logistics import Logistics, EventReq, MiscReq, FormReq
5+
from hknweb.candidate.models.shortlink import ShortLink
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from django.db import models
2+
from django.contrib.auth.models import User
3+
from django.core.validators import RegexValidator
4+
5+
6+
class ShortLink(models.Model):
7+
"""
8+
Model for URL shortlinks (e.g., hkn.mu/discord -> https://discord.gg/xyz)
9+
"""
10+
11+
slug = models.SlugField(
12+
max_length=100,
13+
unique=True,
14+
db_index=True,
15+
validators=[
16+
RegexValidator(
17+
regex=r"^[a-zA-Z0-9-_]+$",
18+
message="Slug can only contain letters, numbers, hyphens, and underscores",
19+
)
20+
],
21+
help_text="Short code for the URL (e.g., 'discord', 'apply')",
22+
)
23+
destination_url = models.URLField(
24+
max_length=2000, help_text="Full URL to redirect to"
25+
)
26+
description = models.CharField(
27+
max_length=500, blank=True, help_text="Optional description for officers"
28+
)
29+
created_by = models.ForeignKey(
30+
User,
31+
on_delete=models.SET_NULL,
32+
null=True,
33+
related_name="created_shortlinks",
34+
help_text="Officer who created this shortlink",
35+
)
36+
created_at = models.DateTimeField(auto_now_add=True)
37+
updated_at = models.DateTimeField(auto_now=True)
38+
click_count = models.PositiveIntegerField(
39+
default=0, help_text="Number of times this shortlink has been used"
40+
)
41+
active = models.BooleanField(
42+
default=True, help_text="Whether this shortlink is active"
43+
)
44+
45+
class Meta:
46+
ordering = ["-created_at"]
47+
verbose_name = "Short Link"
48+
verbose_name_plural = "Short Links"
49+
50+
def __str__(self):
51+
return f"{self.slug} -> {self.destination_url}"
52+
53+
def increment_click_count(self):
54+
"""Increment the click counter atomically"""
55+
self.click_count = models.F("click_count") + 1
56+
self.save(update_fields=["click_count"])

hknweb/candidate/urls.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,20 @@
4747
views.UserAutocomplete.as_view(),
4848
name="autocomplete_user",
4949
),
50+
# Shortlinks
51+
path(
52+
"shortlinks",
53+
views.manage_shortlinks,
54+
name="manage_shortlinks",
55+
),
56+
path(
57+
"shortlinks/create",
58+
views.create_shortlink,
59+
name="create_shortlink",
60+
),
61+
path(
62+
"shortlinks/import",
63+
views.import_shortlinks,
64+
name="import_shortlinks",
65+
),
5066
]

hknweb/candidate/views/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@
1111
checkoff_event,
1212
)
1313
from hknweb.candidate.views.autocomplete import OfficerAutocomplete, UserAutocomplete
14+
from hknweb.candidate.views.shortlinks import (
15+
manage_shortlinks,
16+
create_shortlink,
17+
import_shortlinks,
18+
redirect_shortlink,
19+
)

hknweb/candidate/views/officer_portal.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
from hknweb.utils import login_and_access_level, GROUP_TO_ACCESSLEVEL
99
from hknweb.events.models import Rsvp
1010

11-
from hknweb.candidate.models import OffChallenge, BitByteActivity, Logistics
11+
from hknweb.candidate.models import OffChallenge, BitByteActivity, Logistics, ShortLink
1212
from hknweb.candidate.views.candidate_portal import get_logistics
1313

1414

1515
@login_and_access_level(GROUP_TO_ACCESSLEVEL["officer"])
1616
def officer_portal(request):
17+
# Get shortlinks count for Quick Links section (always available)
18+
shortlinks = ShortLink.objects.filter(active=True)
19+
1720
context = {
1821
"logistics": {
1922
"challenges": OffChallenge.objects.filter(
@@ -23,6 +26,7 @@ def officer_portal(request):
2326
participants__exact=request.user
2427
).order_by("-request_date"),
2528
},
29+
"shortlinks": shortlinks,
2630
}
2731

2832
logistics = get_logistics()

0 commit comments

Comments
 (0)