Functionality update

This commit is contained in:
Scott Wallace 2021-12-27 18:34:31 +00:00
parent 4636acd05d
commit 8dd63e33f0
Signed by: scott
GPG key ID: AA742FDC5AFE2A72
11 changed files with 243 additions and 53 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.pyenv/ .pyenv/
.vscode/ .vscode/
__pycache__/ __pycache__/
slinky.db

2
config.yaml Normal file
View file

@ -0,0 +1,2 @@
---
db: sqlite:///slinky.db

View file

@ -1,5 +1,6 @@
flask flask
flask_bootstrap flask_bootstrap
flask_wtf flask_wtf
pyyaml
waitress waitress
Flask-SQLAlchemy Flask-SQLAlchemy

View file

@ -4,12 +4,28 @@ Main code
import random import random
import string import string
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
import sqlalchemy
from slinky import db from slinky import db
@dataclass
class Shortcode:
"""
Simple dataclass to allow for typing of Shortcodes
"""
id: int # pylint: disable=invalid-name
shortcode: str
url: str
fixed_views: int
expiry: str
def random_string(length: int = 4) -> str: def random_string(length: int = 4) -> str:
""" """
Create a random, alphanumeric string of the length specified Create a random, alphanumeric string of the length specified
@ -25,34 +41,80 @@ def random_string(length: int = 4) -> str:
return ''.join(random.SystemRandom().choice(allowed_chars) for _ in range(length)) return ''.join(random.SystemRandom().choice(allowed_chars) for _ in range(length))
def add_shortcode( class Slinky:
url: str,
length: int = 4,
fixed_views: int = 0,
expiry: datetime = datetime.max,
) -> str:
""" """
Add a shortcode to the DB Class for Slinky
Args:
url (str): URL to redirect to
fixed_views (int, optional): number of views to serve before expiring.
Defaults to 0 (no limit).
expiry (int, optional): date of expiry. Defaults to 0 (no limit).
Returns:
str: shortcode for the redirect
""" """
session = db.session()
shortcode = random_string(length=length)
dbentry = db.ShortURL(
shortcode=shortcode,
url=url,
fixed_views=fixed_views,
expiry=expiry,
)
session.add(dbentry)
session.commit()
session.close()
return shortcode def __init__(self, url: str) -> None:
self.db = db.ShortcodeDB(url) # pylint: disable=invalid-name
self.session = self.db.session()
def add(
self,
url: str,
length: int = 4,
fixed_views: int = -1,
expiry: datetime = datetime.max,
) -> str:
"""
Add a shortcode to the DB
Args:
url (str): URL to redirect to
fixed_views (int, optional): number of views to serve before expiring.
Defaults to 0 (no limit).
expiry (int, optional): date of expiry. Defaults to 0 (no limit).
Returns:
str: shortcode for the redirect
"""
shortcode = random_string(length=length)
if self.get(shortcode).url:
raise ValueError(f'Shortcode {shortcode} already exists')
dbentry = db.ShortURL(
shortcode=shortcode,
url=url,
fixed_views=fixed_views,
expiry=expiry,
)
self.session.add(dbentry)
self.session.commit()
return shortcode
def get(self, shortcode: str) -> Shortcode:
"""
Return a Shortcode object for a given shortcode
Args:
shortcode (str): the shortcode to look up
Returns:
Shortcode: full Shortcode object for the given shortcode
"""
entry = self.session.query(db.ShortURL).filter_by(shortcode=shortcode).first()
if entry:
return Shortcode(
entry.id,
entry.shortcode,
entry.url,
entry.fixed_views,
entry.expiry,
)
return Shortcode(0, '', '', 0, '1970-01-01 00:00:00.000000')
def remove_view(self, sc_id: int) -> None:
"""
Reduce the fixed views count by one
Args:
id (int): ID of the DB entry to reduce the fixed_views count
"""
self.session.query(db.ShortURL).filter_by(id=sc_id).update(
{db.ShortURL.fixed_views: db.ShortURL.fixed_views - 1}
)
self.session.commit()

View file

@ -1,6 +1,8 @@
""" """
DB component DB component
""" """
from dataclasses import dataclass
from sqlalchemy import Column, Integer, String, create_engine from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@ -8,7 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker
Base = declarative_base() Base = declarative_base()
class ShortURL(Base): # pylint: disable=too-few-public-methods class ShortURL(Base): # type: ignore[misc, valid-type] # pylint: disable=too-few-public-methods
""" """
Class to describe the DB schema for ShortURLs Class to describe the DB schema for ShortURLs
""" """
@ -22,14 +24,21 @@ class ShortURL(Base): # pylint: disable=too-few-public-methods
expiry = Column(Integer, unique=False, nullable=False) expiry = Column(Integer, unique=False, nullable=False)
def session() -> Session: @dataclass
class ShortcodeDB:
""" """
Create a DB session Class to represent the database
"""
url: str
Returns: def session(self) -> Session:
Session: the DB session object """
""" Create a DB session
engine = create_engine('sqlite:////tmp/test.db')
Base.metadata.create_all(engine) Returns:
new_session = sessionmaker(bind=engine) Session: the DB session object
return new_session() """
engine = create_engine(self.url)
Base.metadata.create_all(engine)
new_session = sessionmaker(bind=engine)
return new_session()

View file

@ -6,7 +6,7 @@
<h1 class="mt-5">Add a shortcode</h1> <h1 class="mt-5">Add a shortcode</h1>
</div> </div>
<br /> <br />
<form action="/add" method="post"> <form action="/_/add" method="post">
{{ form.url.label }} {{ form.url }}<br /> {{ form.url.label }} {{ form.url }}<br />
{{ form.length.label }} {{ form.length }}<br /> {{ form.length.label }} {{ form.length }}<br />
{{ form.fixed_views.label }} {{ form.fixed_views }} (0 = unlimited)<br /> {{ form.fixed_views.label }} {{ form.fixed_views }} (0 = unlimited)<br />

View file

@ -2,17 +2,22 @@
Web component Web component
""" """
import logging
from datetime import datetime from datetime import datetime
from flask import Blueprint, render_template import yaml
from flask import Blueprint, Response, redirect, render_template
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import DateTimeLocalField, IntegerField, StringField from wtforms import DateTimeLocalField, IntegerField, StringField
from wtforms.validators import DataRequired, Length from wtforms.validators import DataRequired, Length
from slinky import add_shortcode from slinky import Slinky
slinky_webapp = Blueprint('webapp', __name__, template_folder='templates') slinky_webapp = Blueprint('webapp', __name__, template_folder='templates')
with open('config.yaml', encoding='utf-8-sig') as conffile:
cfg = yaml.safe_load(conffile)
class ShortURLForm(FlaskForm): # type: ignore[misc] class ShortURLForm(FlaskForm): # type: ignore[misc]
""" """
@ -25,7 +30,7 @@ class ShortURLForm(FlaskForm): # type: ignore[misc]
render_kw={ render_kw={
'size': 64, 'size': 64,
'maxlength': 2048, 'maxlength': 2048,
'placeholder': 'e.g. www.example.com', 'placeholder': 'https://www.example.com',
}, },
) )
fixed_views = IntegerField( fixed_views = IntegerField(
@ -33,7 +38,7 @@ class ShortURLForm(FlaskForm): # type: ignore[misc]
validators=[DataRequired()], validators=[DataRequired()],
render_kw={ render_kw={
'size': 3, 'size': 3,
'value': 0, 'value': -1,
}, },
) )
length = IntegerField( length = IntegerField(
@ -54,7 +59,34 @@ class ShortURLForm(FlaskForm): # type: ignore[misc]
) )
@slinky_webapp.route('/add', methods=['GET', 'POST']) @slinky_webapp.route('/<path:path>')
def try_path_as_shortcode(path: str) -> Response:
"""
Try the initial path as a shortcode, redirect if found
Returns:
Optional[Response]: redirect if found, otherwise continue on
"""
should_redirect = True
slinky = Slinky(cfg['db'])
shortcode = slinky.get(path)
if shortcode.url:
if shortcode.fixed_views == 0:
logging.warning('Shortcode out of views')
should_redirect = False
elif shortcode.fixed_views > 0:
slinky.remove_view(shortcode.id)
if datetime.fromisoformat(shortcode.expiry) < datetime.now():
logging.warning('Shortcode expired')
should_redirect = False
if should_redirect:
return redirect(shortcode.url, 302)
return Response('Not found', 404)
@slinky_webapp.route('/_/add', methods=['GET', 'POST'])
def add() -> str: def add() -> str:
""" """
Create and add a new shorturl Create and add a new shorturl
@ -74,6 +106,12 @@ def add() -> str:
expiry = form.expiry.data or datetime.max expiry = form.expiry.data or datetime.max
if url: if url:
shortcode = add_shortcode(url, length, fixed_views, expiry) slinky = Slinky(cfg['db'])
while True:
try:
shortcode = slinky.add(url, length, fixed_views, expiry)
break
except ValueError:
logging.warning('Shortcode already exists. Retrying.')
return render_template('add.html', form=form, shortcode=shortcode) return render_template('add.html', form=form, shortcode=shortcode)

View file

@ -87,7 +87,7 @@
<div class="collapse navbar-collapse" id="navbarCollapse"> <div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav me-auto mb-2 mb-md-0"> <ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/add">Add</a> <a class="nav-link" href="/_/add">Add</a>
</li> </li>
</ul> </ul>
</div> </div>

BIN
tests/test.db Normal file

Binary file not shown.

View file

@ -1,16 +1,58 @@
from unittest import TestCase """
Test Slinky
"""
import random
from typing import Any
from unittest import TestCase, mock
import slinky from slinky import Slinky, random_string
class TestSlinky(TestCase): class TestSlinky(TestCase):
"""
Class to test Slinky code
"""
test_db = 'sqlite:///tests/test.db'
def test_random_string(self) -> None: def test_random_string(self) -> None:
""" """
Ensure the random string generates correctly Ensure the random string generates correctly
""" """
self.assertEqual(4, len(slinky.random_string())) rnd_len = random.randint(8, 128)
self.assertEqual(8, len(slinky.random_string(8)))
self.assertEqual(16, len(slinky.random_string(16))) self.assertEqual(4, len(random_string()))
self.assertEqual(64, len(slinky.random_string(64))) self.assertEqual(rnd_len, len(random_string(rnd_len)))
self.assertTrue(slinky.random_string(128).isalnum(), True)
self.assertTrue(random_string(128).isalnum(), True)
@mock.patch('sqlalchemy.orm.session.Session.add', return_value=None)
@mock.patch('slinky.random_string', return_value='abcd')
def test_add(self, *_: Any) -> None:
"""
Ensure we can add a shortcode to the DB
"""
self.assertEqual(
Slinky(self.test_db).add('https://www.example.com'),
'abcd',
)
def test_get(self) -> None:
"""
Ensure we can fetch a URL for a known shortcode
"""
self.assertEqual('https://example.com', Slinky(self.test_db).get('egie').url)
@mock.patch('sqlalchemy.orm.session.Session.add', return_value=None)
@mock.patch('slinky.random_string', return_value='egie')
def test_duplicate_shortcode(self, *_: Any) -> None:
"""
Ensure duplicate shortcodes raise a ValueError exception
"""
self.assertRaises(
ValueError,
Slinky(self.test_db).add,
'https://www.example.com',
)

35
tests/test_web.py Normal file
View file

@ -0,0 +1,35 @@
"""
Test Slinky
"""
from typing import Any
from unittest import TestCase, mock
from slinky import web
@mock.patch.dict('slinky.web.cfg', {'db': 'sqlite:///tests/test.db'})
class TestWeb(TestCase):
"""
Class to test Slinky code
"""
def test_simple_redirect(self, *_: Any) -> None:
"""
Ensure simple redirect works
"""
response = web.try_path_as_shortcode('egie')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, 'https://example.com')
def test_fixed_views(self, *_: Any) -> None:
"""
Ensure simple redirect works
"""
response = web.try_path_as_shortcode('egig')
self.assertEqual(response.status_code, 404)
def test_expiry(self, *_: Any) -> None:
"""
Ensure simple redirect works
"""
response = web.try_path_as_shortcode('egif')
self.assertEqual(response.status_code, 404)