added shortener app

This commit is contained in:
Alexander Karpov 2023-01-16 00:30:01 +03:00
parent 899f837e7f
commit 222138193b
16 changed files with 364 additions and 1 deletions

View File

@ -0,0 +1,59 @@
# Generated by Django 4.1.5 on 2023-01-15 18:20
from django.db import migrations
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
("files", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="basefile",
options={},
),
migrations.AlterModelOptions(
name="folder",
options={},
),
migrations.AlterField(
model_name="basefile",
name="created",
field=model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
migrations.AlterField(
model_name="basefile",
name="modified",
field=model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
migrations.AlterField(
model_name="folder",
name="created",
field=model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
migrations.AlterField(
model_name="folder",
name="modified",
field=model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
]

View File

@ -7,7 +7,7 @@
SlugField, SlugField,
TextField, TextField,
) )
from django_extensions.db.models import TimeStampedModel from model_utils.models import TimeStampedModel
from akarpov.utils.files import user_file_upload_mixin from akarpov.utils.files import user_file_upload_mixin

View File

View File

13
akarpov/shortener/apps.py Normal file
View File

@ -0,0 +1,13 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class ShortenerConfig(AppConfig):
name = "akarpov.shortener"
verbose_name = _("Link shortener")
def ready(self):
try:
import akarpov.shortener.signals # noqa F401
except ImportError:
pass

View File

@ -0,0 +1,90 @@
# Generated by Django 4.1.5 on 2023-01-15 18:20
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Link",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
("source", models.URLField()),
("slug", models.SlugField()),
("enabled", models.BooleanField(default=True)),
("viewed", models.IntegerField(default=0)),
(
"creator",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="links",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="LinkViewMeta",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("viewed", models.DateTimeField(auto_now_add=True)),
("ip", models.GenericIPAddressField()),
("user_agent", models.CharField(max_length=200)),
(
"link",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="views",
to="shortener.link",
),
),
],
),
]

View File

View File

@ -0,0 +1,29 @@
from django.db import models
from model_utils.models import TimeStampedModel
class Link(TimeStampedModel):
source = models.URLField(blank=False)
slug = models.SlugField()
creator = models.ForeignKey(
"users.User", related_name="links", null=True, on_delete=models.SET_NULL
)
enabled = models.BooleanField(default=True)
viewed = models.IntegerField(default=0)
def __str__(self):
return f"link to {self.source}"
class LinkViewMeta(models.Model):
# TODO: move to mem, delete within 7 days
link = models.ForeignKey(Link, related_name="views", on_delete=models.CASCADE)
viewed = models.DateTimeField(auto_now_add=True)
ip = models.GenericIPAddressField()
user_agent = models.CharField(max_length=200)
def __str__(self):
return f"view on {self.link.source}"

View File

@ -0,0 +1,28 @@
from django.conf import settings
from akarpov.shortener.models import Link
from akarpov.utils.generators import generate_charset, get_pk_from_uuid, get_str_uuid
lenght = settings.SHORTENER_SLUG_LENGTH
def generate_slug(pk: int) -> str:
if settings.SHORTENER_ADD_SLUG:
slug = generate_charset(lenght)
return slug + get_str_uuid(pk)
return get_str_uuid(pk)
def get_link_from_slug(slug: str, check_whole=False) -> Link | bool:
if settings.SHORTENER_ADD_SLUG and not check_whole:
payload = slug[lenght:]
pk = get_pk_from_uuid(payload)
try:
return Link.objects.get(pk=pk)
except Link.DoesNotExist:
return get_link_from_slug(slug, check_whole=True)
pk = get_pk_from_uuid(slug)
try:
return Link.objects.get(pk=pk)
except Link.DoesNotExist:
return False

View File

@ -0,0 +1,12 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from akarpov.shortener.models import Link
from akarpov.shortener.services import generate_slug
@receiver(post_save, sender=Link)
def link_on_create(sender, instance: Link, created, **kwargs):
if created:
instance.slug = generate_slug(instance.id)
instance.save(update_fields=["slug"])

View File

View File

80
akarpov/utils/consts.py Normal file
View File

@ -0,0 +1,80 @@
URL_BASE = {
"a": 0,
"b": 1,
"c": 2,
"d": 3,
"e": 4,
"f": 5,
"g": 6,
"h": 7,
"i": 8,
"j": 9,
"k": 10,
"l": 11,
"m": 12,
"n": 13,
"o": 14,
"p": 15,
"q": 16,
"r": 17,
"s": 18,
"t": 19,
"u": 20,
"v": 21,
"w": 22,
"x": 23,
"y": 24,
"z": 25,
"A": 26,
"B": 27,
"C": 28,
"D": 29,
"E": 30,
"F": 31,
"G": 32,
"H": 33,
"I": 34,
"J": 35,
"K": 36,
"L": 37,
"M": 38,
"N": 39,
"O": 40,
"P": 41,
"Q": 42,
"R": 43,
"S": 44,
"T": 45,
"U": 46,
"V": 47,
"W": 48,
"X": 49,
"Y": 50,
"Z": 51,
"0": 52,
"1": 53,
"2": 54,
"3": 55,
"4": 56,
"5": 57,
"6": 58,
"7": 59,
"8": 60,
"9": 61,
";": 62,
",": 63,
":": 64,
"@": 65,
"&": 66,
"+": 67,
"-": 68,
"_": 69,
".": 70,
"!": 71,
"~": 72,
"*": 73,
"'": 74,
"(": 75,
")": 76,
"#": 77,
}

View File

@ -3,6 +3,11 @@
from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.auth.tokens import PasswordResetTokenGenerator
from akarpov.utils.consts import URL_BASE
from akarpov.utils.nums import to_base
URL_CHARACTERS = string.ascii_letters + string.digits + ";,:@&+-_.!~*'()#"
class TokenGenerator(PasswordResetTokenGenerator): class TokenGenerator(PasswordResetTokenGenerator):
"""token processor for user""" """token processor for user"""
@ -16,6 +21,19 @@ def generate_charset(length: int) -> str:
return "".join(random.choice(string.ascii_letters) for _ in range(length)) return "".join(random.choice(string.ascii_letters) for _ in range(length))
def get_str_uuid(pk: int) -> str:
return to_base(pk, list(URL_CHARACTERS))
def get_pk_from_uuid(slug: str) -> int:
res = 0
for i, el in enumerate(slug[::-1]):
if el not in URL_BASE:
raise ValueError
res += URL_BASE[el] * 78**i
return res
def _rand255(): def _rand255():
return random.randint(0, 255) return random.randint(0, 255)

27
akarpov/utils/nums.py Normal file
View File

@ -0,0 +1,27 @@
def number_to_base(n: int, b: int) -> list[int]:
if n == 0:
return [0]
digits = []
while n:
digits.append(int(n % b))
n //= b
return digits[::-1]
def to_base(n: int, base: list) -> str:
if n == 0:
return ""
b = len(base)
res = ""
while n:
res += base[int(n % b)]
n //= b
return res[::-1]
def from_base(expr: str, base: int) -> int:
return sum(
[int(character) * base**index for index, character in enumerate(expr[::-1])]
)

View File

@ -140,6 +140,7 @@
"akarpov.users", "akarpov.users",
"akarpov.blog", "akarpov.blog",
"akarpov.files", "akarpov.files",
"akarpov.shortener",
"akarpov.pipeliner", "akarpov.pipeliner",
"akarpov.tools.qr", "akarpov.tools.qr",
] ]
@ -507,3 +508,9 @@
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
DRF_CHUNKED_UPLOAD_ABSTRACT_MODEL = False DRF_CHUNKED_UPLOAD_ABSTRACT_MODEL = False
DRF_CHUNKED_UPLOAD_PATH = "uploads/chucked/" DRF_CHUNKED_UPLOAD_PATH = "uploads/chucked/"
# SHORTENER
# ------------------------------------------------------------------------------
SHORTENER_ADD_SLUG = True
SHORTENER_SLUG_LENGTH = 3