Kategorier
blandet

Prisovervågning på Skyr med Python og Django

Skyr er dyrt, men altid på tilbud. På https://etilbudsavis.dk/search/skyr kan man finde de aktuelle tilbud fra tilbudsaviserne.

Jeg har udviklet en lille hjemmeside, der monitorerer priserne på skyr i Storkøbenhavn. Du kan finde den på https://wallnot.dk/skyr.

Sådan ser hjemmesiden med historiske og (et lille bitte stykke) fremtidige priser på skyr ud.

Sådan virker det…

Skyrpriser består af:

  • En database med en model defineret i Django’s models.py
  • Et python-script, cron_skyrpriser.py, der køres som job en gang i døgnet og skraber tilbud på skyr fra https://etilbudsavis.dk og gemmer dem i databasen
  • Et view i Django’s views.py, der gør data fra databasen klar i en struktur, der er brugbar i sidens skabelon
  • En skabelon (index.html), som indeholder sidens HTML-kode, stylesheet og det javascript der, ved hjælp af biblioteket Chart.js, genererer grafen over Skyrpriser

Jeg starter med datamodellen i models.py. Hovedtabellen hedder “Offer” og gemmer typen af skyr, hvilken butik, der er tale om, hvilken dato tilbuddet gælder og kiloprisen for tilbuddet:

from django.db import models
from django.utils import timezone
from django.contrib import admin

class Offer(models.Model):
	skyr_type = models.CharField('Skyrtype', max_length=100)
	store = models.CharField('Butik', max_length=100)
	date = models.DateField('Dato')
	price_per_kilo = models.FloatField('Kilopris')
	added_at = models.DateTimeField('Tilføjelsesdato', default=timezone.now, editable=False)
	
class OfferAdmin(admin.ModelAdmin):
	list_display = ('store','skyr_type','date')
	list_filter = ('store', 'skyr_type')
	search_fields = ['store', 'skyr_type']

Så kommer jeg til cron_skyrpriser.py. Jeg har brugt min browsers udviklerværktøjer til at finde ud af, hvordan jeg taler med API’et for etilbudsavis.dk og får data tilbage i JSON-format. Jeg henter de felter, jeg har brug for og gemmer dem i databasen, hvis de ikke allerede findes i databasen:

import requests
from datetime import datetime, date, timedelta
from bs4 import BeautifulSoup
import psycopg2
from psycopg2 import Error
import pytz

now = datetime.now()
cph = pytz.timezone('Europe/Copenhagen')

# Connect to database
try:
	connection = psycopg2.connect(user = "[slettet]",
									password = "[slettet]",
									host = "[slettet]",
									port = "",
									database = "[slettet]")
	cursor = connection.cursor()
except (Exception, psycopg2.Error) as error:
	print ("Error while connecting to PostgreSQL", error)

### INSERT SKYR IN DATABASE FUNCTION ###

def insert_in_database(connection, offer):
	with connection:
		with connection.cursor() as cur:
			try:
				sql = ''' SELECT * from skyrpriser_offer WHERE skyr_type = %s AND store = %s AND date = %s'''
				cur.execute(sql, (offer[0], offer[1], offer[2]))
				results = cur.fetchall()
				if not results:
					sql = ''' INSERT INTO skyrpriser_offer(skyr_type,store,date,price_per_kilo,added_at)
					VALUES(%s,%s,%s,%s,%s)'''
					cur.execute(sql, offer)	
			except Error as e:
				print(e, offer)

# Scrape prices of skyr and save to database
def main():
	url = "https://etilbudsavis.dk/api/squid/v2/sessions"
	session = requests.Session()
	headers = {
		'authority': 'etilbudsavis.dk',
		'accept': 'application/json',
		'dnt': '1',
		'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36',
		'x-api-key': '[slettet]',
		'sec-fetch-site': 'same-origin',
		'sec-fetch-mode': 'cors',
		'sec-fetch-dest': 'empty',
		'referer': 'https://etilbudsavis.dk/search/skyr',
		'accept-language': 'en-US,en;q=0.9',
		'cookie': 'sgn-flags=^{^%^22flags^%^22:^{^}^}; sgn-consents=^[^]',
	}
	session.headers.update(headers)
	response = session.get(url)
	url = "https://etilbudsavis.dk/api/squid/v2/offers/search?query=skyr&r_lat=55.695497&r_lng=12.550145&r_radius=20000&r_locale=da_DK&limit=24&offset=0"
	response = session.get(url)
	response_json = response.json()

	for item in response_json:
		skyr_type = item['heading']
		store = item['branding']['name']
		valid_from = item['run_from']
		valid_from = datetime.strptime(valid_from, '%Y-%m-%dT%H:%M:%S%z').astimezone(cph).date()
		valid_to = item['run_till']
		valid_to = datetime.strptime(valid_to, '%Y-%m-%dT%H:%M:%S%z').astimezone(cph).date()
		price = item['pricing']['price']
		amount = item['quantity']['size']['from']
		measure = item['quantity']['unit']['symbol']
		if measure == "g":
			price_per_kilo = price/amount*1000
		elif measure == "kg":
			price_per_kilo = price/amount
		number_of_days = int((valid_to - valid_from).days)
		for day in range(number_of_days+1):
			date = valid_from + timedelta(day)
			offer = (skyr_type, store, date, price_per_kilo, now)
			insert_in_database(connection, offer)

main()
print("Opdaterede skyrpriser")

I Django’s views.py henter jeg databasetabellens indhold og gør dem klar vha. nogle løkker, som formentlig er ret ineffektive, men virker OK:

from django.shortcuts import render
from .models import Offer
from django.db.models import Max, Min
from datetime import timedelta

# Main page
def skyrindex(request):
	offers = Offer.objects.all().order_by('date')
	context = {}
	if offers:
		date_min = Offer.objects.aggregate(Min('date'))['date__min']
		date_max = Offer.objects.aggregate(Max('date'))['date__max']
		number_of_days = (date_max - date_min).days
		dates = []
		for i in range(number_of_days + 1):
			dates.append(date_min + timedelta(i))
		
		structure = {}
		for offer in offers:
			if not offer.store in structure:
				structure[offer.store] = {}
			if not offer.skyr_type in structure[offer.store]:
				structure[offer.store][offer.skyr_type] = []
			structure[offer.store][offer.skyr_type].append({offer.date: round(offer.price_per_kilo, 1)})

		new_structure = {}
		for store, offer in structure.items():
			new_structure[store] = {}
			for skyr_type, prices in offer.items():	
				new_structure[store][skyr_type] = []
				for date in dates:
					have_price = False
					for price in prices:
						if date in price:
							new_structure[store][skyr_type].append({date: price[date]})
							have_price = True
					if not have_price:
						new_structure[store][skyr_type].append({date: ","})

		context = {'dates': dates, 'structure': structure, 'new_structure': new_structure}
	return render(request, 'skyrpriser/index.html', context)

Og til sidst har jeg så index.html, som også har nogle (for mig) ret komplicerede løkker for at strukturere data i et format som Javascript-bibliteket Chart.js kan forstå.

Jeg benytter mig af nogle, synes jeg, smarte features i Django’s løkke-funktioner:

  • cycle: Gør det muligt at løbe igennem en prædefineret række værdier hver gang løkken køres, her brugt til at få en ny farve per linje i diagrammet.
  • forloop.last: Den sidste gang en løkke kører, sættes variablen forloop.last. Det gør at jeg fx nemt kan sætte komma efter hver dato i diagrammets x-akse, undtagen efter den sidste dato på listen.

Her er index.html:

<h1>Skyrpriser</h1>
		
<canvas id="myChart"></canvas>
<script>
var ctx = document.getElementById('myChart');
var myChart = new Chart(ctx, {
    type: 'line',
    data: {
        labels: [{% for date in dates %}'{{ date|date:"d. b" }}'{% if not forloop.last %}, {% endif %}{% endfor %}],
        datasets: [{% for store, offer in new_structure.items %}
						{% for skyr_type, prices in offer.items %}
							{
							fill: false,
							backgroundColor: {% cycle "'#e9ecef'," "'#ffc9c9'," "'#fcc2d7'," "'#eebefa'," "'#d0bfff'," "'#bac8ff'," "'#a5d8ff'," "'#99e9f2'," "'#96f2d7'," "'#b2f2bb'," "'#d8f5a2'," "'#ffec99'," "'#ffd8a8'," %}
							borderColor: {% cycle "'#e9ecef'," "'#ffc9c9'," "'#fcc2d7'," "'#eebefa'," "'#d0bfff'," "'#bac8ff'," "'#a5d8ff'," "'#99e9f2'," "'#96f2d7'," "'#b2f2bb'," "'#d8f5a2'," "'#ffec99'," "'#ffd8a8'," %}
							label: '{{ store|safe }}, {{ skyr_type|safe }}',
							data: 	[
									{% for price in prices %}
										{% for date, cost in price.items %}
											{% if not cost == "," %}
											{{ cost|unlocalize }}
											{% endif %}
										{% endfor %}
										{% if not forloop.last %}
										,
										{% endif %}
									{% endfor %}
									]
							}{% if not forloop.last %},{% endif %}
						{% endfor %}
						{% if not forloop.last %},{% endif %}
					{% endfor %}]
    },
    options: {
		responsive: true,
		spanGaps: false,
		title: {
			display: true,
			text: 'Tilbud på Skyr over tid'
		},
		tooltips: {
			mode: 'index',
			intersect: false,
		},
		hover: {
			mode: 'nearest',
			intersect: true
		},		
		scales: {
			xAxes: [{
				display: true,
				scaleLabel: {
					display: true,
					labelString: 'Dato'
				}
			}],
			yAxes: [{
				display: true,
				scaleLabel: {
					display: true,
					labelString: 'Pris pr. kilo Skyr i kroner'
				}
			}]
		}


    }
});
</script>
Kategorier
blandet

Kalender med ugenumre og helligdage

Jeg har lavet endnu et Django-eksperiment med https://ugenr.dk som det store forbillede.

En digital kalender

https://wallnot.dk/kalender kan du altid finde en Mayland-style-kalender med helligdage og ugenumre. Kalenderen understøtter år 1 til 9999.

Den er lavet med tre side-funktioner: En der altid viser nuværende halvår (kalindex), en der viser et hvilket som helst halvår (kalperiod) og en der kan vise et hvilket som helst år (kalyear). Alle tre funktioner kalder en fjerde funktion get-dates der returnerer datoer i kalenderen for det år og/eller halvår, der efterspørges.

Her er views.py:

from django.shortcuts import render
import datetime
from workalendar.europe import Denmark	# Module containing most Danish holidays
from django.http import Http404

# Function to return all calendar dates and other context data
def get_dates(year, period, now):
	now_isocalendar = now.isocalendar()
	
	### HOLIDAY LIST FOR YEAR IS GENERATED ###
		
	# Create dictionary with all holidays of the year
	holidays = Denmark().holidays(year)
	
	all_holidays = {}
	all_holidays[datetime.date(year,5,1)] = ["Første maj", "Særlig dag"]
	all_holidays[datetime.date(year,6,5)] = ["Grundlovsdag", "Særlig dag"]
	all_holidays[datetime.date(year,12,31)] = ["Nytårsaften", "Særlig dag"]

	holiday_lookup = {
						"New year": ["Nytårsdag", "Helligdag"],
						"Holy Thursday": ["Skærtorsdag", "Helligdag"],
						"Good Friday": ["Langfredag", "Helligdag"],
						"Easter Sunday": ["Påskedag", "Helligdag"],
						"Easter Monday": ["2. påskedag", "Helligdag"],
						"Store Bededag": ["Store bededag", "Helligdag"],
						"Ascension Thursday": ["Kr. himmelfart", "Helligdag"],
						"Pentecost Sunday": ["Pinsedag", "Helligdag"],
						"Pentecost Monday": ["2. pinsedag", "Helligdag"],
						"Christmas Eve": ["Juleaften", "Særlig dag"],
						"Christmas Day": ["1. juledag", "Helligdag"],
						"Second Day of Christmas": ["2. juledag", "Helligdag"],
					}
	
	for holiday in holidays:
		# Check for two holidays on same day
		if holiday[0] not in all_holidays:
			all_holidays[holiday[0]] = (holiday_lookup[holiday[1]][0], holiday_lookup[holiday[1]][1])
		# If two on the same day, names are concenated
		else:
			all_holidays[holiday[0]] = (holiday_lookup[holiday[1]][0] + "/" + all_holidays[holiday[0]][0] , holiday_lookup[holiday[1]][1])
	
	### DATES FOR YEAR ARE GENERATED IN A DAY AND MONTH DIMENSION ###
	
	# First dimension is maximum number of days in a month
	dates_in_year = {}
	for day in range(1,32):
		dates_in_year[day] = []
	
	# Second dimension is that date for each month
	for day in range(1,32):
		for month in period:
			# If the generated day actually is a valid date, day is added to dates_in_year dictionary
			try:
				date_to_add = datetime.date(year,month,day)
				date_isocalendar = date_to_add.isocalendar()
								
				# HOLIDAY LOGIC #
				# If day is special, get type of day and name of day
				if date_to_add in all_holidays:
					type_of_day = all_holidays[date_to_add][1]
					name_of_day = all_holidays[date_to_add][0]
				# If not, type of day is normal and no name
				else:
					type_of_day = "Normal dag"
					name_of_day = "Intet navn"
				
				# HTML BORDER CLASS LOGIC #
				html_class = ""
				
				# Year of date must be the same as year of current date
				if date_isocalendar[0] == now_isocalendar[0]:
					# Week number is the same as current week number
					if date_isocalendar[1] == now_isocalendar[1]:
						# All days get a red right and red left class
						html_class = "redleft redright"
						# Sunday also gets a red bottom class
						if date_isocalendar[2] == 7:
							html_class += " redbottom"
					# Date is Sunday in the week before current
					elif date_isocalendar[1] == now_isocalendar[1] - 1 and date_isocalendar[2] == 7:
						html_class += " redbottom"
					# Same date next month is in current week
					try:
						date_next_month = datetime.date(year,month + 1,day)
						date_next_month_isocalendar = date_next_month.isocalendar()
						# Week number is the same as current week number
						if date_next_month_isocalendar[1] == now_isocalendar[1]:
							html_class = "redright"
					except ValueError:
						pass
				date_data = (date_to_add, type_of_day, name_of_day, html_class)
				dates_in_year[day].append(date_data)
			# Except when that dates does not exist, e.g. february 30
			except ValueError:
				dates_in_year[day].append("NON-EXISTING DATE")
	
	context = {'year': str(year), 'next': year+1, 'previous': year-1, 'dates_in_year': dates_in_year, 'period': period, 'now': now}
	return context
	
# Main page
def kalindex(request):
	now = datetime.datetime.now()
	year = now.year
	month = now.month
	if month < 7:
		period = range(1,7)
	else:
		period = range(7,13)
	# Run function to get calendar dates
	context = get_dates(year, period, now)
	return render(request, 'kalender/index.html', context)

# Earlier or future year page
def kalyear(request, year):
	# If year is not an integer, a 404 error is thrown
	try:
		year = int(year)
	except ValueError:
		raise Http404
	# If year is between 1 and 10000, a calendar is rendered
	if year > 0 and year < 10000:
		now = datetime.datetime.now()
		period = range(1,13)
		# Run function to get calendar dates
		context = get_dates(year, period, now)
		return render(request, 'kalender/index.html', context)
	# If not, a 404 error is thrown
	else:
		raise Http404
	
# Earlier or future year page
def kalperiod(request, year, period):
	# If year is not an integer, a 404 error is thrown
	try:
		year = int(year)
	except ValueError:
		raise Http404
	# If year is between 1 and 10000, a calendar is rendered
	if year > 0 and year < 10000 and (period == "1" or period == "2"):
		if period == "1":
			period = range(1,7)
		elif period == "2":
			period = range(7,13)
		now = datetime.datetime.now()
		# Run function to get calendar dates
		context = get_dates(year, period, now)
		return render(request, 'kalender/index.html', context)
	# If not, a 404 error is thrown
	else:
		raise Http404	

Sidens skabelon index.html ser en lille smule rodet ud (af hensyn til at minimere sidens størrelse). Skabelonen genererer en tabel ved at gennemgå alle dagene i kalenderen og tilføje særlige layout-regler for lørdage, søndage, helligdage, den nuværende uge, dag og ugenummer osv.

Her er den del af den, der benytter sig af Djangos skabelon-funktioner. (Resten af koden kan du finde ved at bruge “view source” på https://wallnot.dk/kalender):

{% if period|length == 12 %}
{% if not year == "1" %}<a href="{% url 'kal_year' previous %}" title="Se kalender for året før">« forrige</a>{% endif %}<h1> Kalender for år {{ year }} </h1>{% if not year == "9999" %}<a href="{% url 'kal_year' next %}" title="Se kalender for året efter">næste »</a>{% endif %} <a class="calendartype" href="{% if now|date:"n" == "7" or now|date:"n" == "8" or now|date:"n" == "9" or now|date:"n" == "9" or now|date:"n" == "10" or now|date:"n" == "11" or now|date:"n" == "12" and now|date:"Y" == year %}{% url 'kal_period' year 2 %}{% else %}{% url 'kal_period' year 1 %}{% endif %}" title="Gå til halvårskalender">Til halvårskalender</a>
{% elif period|last == 6 %}
{% if not year == "1" %}<a href="{% url 'kal_period' previous 2 %}" title="Se kalender for halvåret før">« forrige </a>{% endif %}<h1> Kalender for år {{ year }}, første halvår </h1><a href="{% url 'kal_period' year 2 %}" title="Se kalender for halvåret efter">næste »</a> <a class="calendartype" href="{% url 'kal_year' year %}" title="Gå til helårskalender">Til helårskalender</a>
{% else %}
<a href="{% url 'kal_period' year 1 %}" title="Se kalender for halvåret før">« forrige</a><h1> Kalender for år {{ year }}, andet halvår </h1>{% if not year == "9999" %}<a href="{% url 'kal_period' next 1 %}" title="Se kalender for halvåret efter">næste »</a>{% endif %} <a class="calendartype" href="{% url 'kal_year' year %}" title="Gå til helårskalender">Til helårskalender</a>
{% endif %}

<p>I dag er det {{ now|date:"l" }} den {{ now|date }} i uge {{ now|date:"W" }}</p>

<table>
	<thead>
		<tr>
		{% if period|length == 12 %}
			<th>Januar</th>
			<th>Februar</th>
			<th>Marts</th>
			<th>April</th>
			<th>Maj</th>
			<th>Juni</th>
			<th>Juli</th>
			<th>August</th>
			<th>September</th>
			<th>Oktober</th>
			<th>November</th>
			<th>December</th>
		{% elif period|last == 6 %}	
			<th>Januar</th>
			<th>Februar</th>
			<th>Marts</th>
			<th>April</th>
			<th>Maj</th>
			<th>Juni</th>
		{% else %}
			<th>Juli</th>
			<th>August</th>
			<th>September</th>
			<th>Oktober</th>
			<th>November</th>
			<th>December</th>		
		{% endif %}	
		</tr>
	</thead>
	<tbody>
	{% for month, monthdays in dates_in_year.items %}
		<tr>
		{% for day in monthdays %}
			<td{% if day.1 == "Helligdag" or day.0|date:"w" == "0" %} class="holy{% if day.3 %} {{ day.3 }}{% endif %}"{% elif day == "NON-EXISTING DATE" %} class="noborder"{% elif year == now|date:"Y" and day.3 %} class="{{ day.3 }}"{% endif %}>
			
				<div title="{{ day.0|date:"l"|capfirst }}" class="weekday{% if day.0|date:"w" == "6" %} saturday{% endif %}{% if day.0|date == now|date %} red{% endif %}">{{ day.0|date:"D"|slice:":1"|upper }}</div>
	
				<div class="datenum{% if day.0|date:"w" == "6" %} saturday{% endif %}{% if day.0|date == now|date %} red{% endif %}">{{ day.0|date:"j" }}</div>

				{% if day.0|date:"w" == "1" %}<div title="Uge {{ day.0|date:"W" }}" class="weeknum{% if day.0|date:"Y W" == now|date:"Y W" %} red{% endif %}">{{ day.0|date:"W" }}</div>{% endif %}

				{% if day.1 == "Helligdag" or day.1 == "Særlig dag" %}<div title="{{ day.2 }}" class="named{% if "/" in day.2 and period|length == 12 %} named-small{% endif %}{% if day.0|date == now|date %} red{% endif %}">{{ day.2 }}</div>{% endif %}
				
			</td>
		{% endfor %}	
	</tr>
	{% endfor %}	
	</tbody>
</table>

Kategorier
blandet

En enkel besøgstæller

https://wallnot.dk/count har jeg oprettet en besøgstæller.

Den tæller besøg på siden, når:

  • Den nyeste besøgende ikke er den samme som den sidste besøgende

Datamodellen i models.py definerer en tæller, ip-adressen på sidste besøgende og tidspunkt for sidste opdatering af tælleren:

from django.db import models
from django.utils import timezone

class Counter(models.Model):
    count = models.PositiveIntegerField('Besøgende nummer')
    last_ip = models.GenericIPAddressField('Sidste besøgendes IP-adresse')
    date = models.DateTimeField(default=timezone.now, editable=False)

I views.py definerer jeg logikken bag, hvornår der skal opdateres. Jeg bruger et modul til Django for at finde brugerens IP:

from django.shortcuts import render
from .models import Counter
from ipware import get_client_ip

def countindex(request):
	# Get current count
	try:
		counter = Counter.objects.get(pk=1)
	# If a count does not exist (first visit to site), one is created
	except:
		firstcount = Counter(count=0, last_ip='0.0.0.0'
		)
		firstcount.save()
		counter = Counter.objects.get(pk=1)

	# Get user IP
	client_ip, is_routable = get_client_ip(request)

	# If user IP exists, check whether user is identical to last user
	# (If no user IP, nothing happens)
	if client_ip is not None:
		# Check whether user is identical to last user
		try:
			Counter.objects.get(last_ip=client_ip)
		# If not, one is added to visitor count and IP is saved
		except:
			counter.count += 1
			counter.last_ip = client_ip
			counter.save()
	context = {'ip': client_ip, 'counter': counter}
	return render(request, 'vcounter/index.html', context)

Endelig har jeg min skabelon index.html som viser brugeren hvad nummer besøgende, hun er, og hendes IP-adresse:

<h1>Du er besøgende nummer<br>
<strong>{{ counter.count }}</strong></h1>
(Dit ip-nummer er: {{ ip }})

Voila!

Kategorier
blandet

Wallnot i version 2.0

En af Wallnots få (men trofaste) brugere, bad om arkiv- og søgefunktionalitet på Wallnot.

Det krævede en større omlægning af Wallnot fra:

  • En side, der viser links til et øjebliksbillede af gratisartikler fra forsiden af danske netaviser.

Til:

  • En side der løbende arkiverer links til gratisartikler fra danske netaviser

Det kræver:

  • En bagvedliggende database
  • Løbende vedligeholdelse så links, der ændrer status fra gratis- til betalingsartikler, fjernes fra siden

Den nye Wallnot har:

  • Søgefunktion på artikeloverskrifter
  • Arkiv, der hele tiden bliver større
  • Zetland- og delte Politiken-artikler fra de sidste par år. Zetlandarkivet er nærmest komplet.
  • En robot, der løbende tjekker links fra de sidste par dage for ændret betalingsmursstatus
  • Mulighed for at filtrere Ritzau-telegrammer og dubletartikler fra
  • Bevaret hurtig- og enkeltheden fra version 1.

Arkitekturen bag Wallnot version 2

Version 2 af Wallnot er udviklet i Django, mens robotterne der indsamler og vedligeholder links er skrevet i Python.

Selve omlægningen til Django er faktisk enkel.

I models.py beskrives datamodellen, altså felterne i den bagvedliggende database:

from django.db import models
from django.utils import timezone
from django.contrib import admin

# Create your models here.
class Article(models.Model):
	title = models.CharField('Overskrift', max_length=500)
	unique_id = models.CharField('Avisens artikel-id', max_length=20, unique=True, null=True, blank=True)
	date = models.DateTimeField('Publiceringstidspunkt')
	MEDIUM_CHOICES = (
		('politiken', 'Politiken'),
		('berlingske', 'Berlingske'),
		('jyllandsposten', 'Jyllandsposten'),
		('information', 'Information'),
		('kristeligtdagblad', 'Kristeligt Dagblad'),
		('weekendavisen', 'Weekendavisen'),
		('zetland', 'Zetland'),
		('finansdk', 'Finans.dk'),
		('borsen', 'Børsen'),
		('arbejderen', 'Arbejderen'),
	)
	medium = models.CharField('Medie', max_length=30, choices=MEDIUM_CHOICES)
	url = models.URLField('Adresse', max_length=400, unique=True)
	ritzau = models.BooleanField('Ritzautelegram', default=False, null=True, blank=True)
	excerpt = models.CharField('Første sætning', max_length=1000, null=True, blank=True)
	duplicate = models.BooleanField('Dublet', default=False, null=True, blank=True)
	user_reports_paywall = models.BooleanField('Brugerrapporteret paywall', default=False, null=True)
	created_at = models.DateTimeField('Tilføjet den', default=timezone.now, editable=False)

class ArticleAdmin(admin.ModelAdmin):
	list_display = ('title','unique_id','ritzau','duplicate','excerpt','date')
	list_filter = ('medium', 'user_reports_paywall', 'ritzau','duplicate')
	search_fields = ['title', 'unique_id', 'excerpt']

Derudover skal der bygges et view, der beskriver forespørgslen til databasen. Her i en forkortet udgave uden logikken bag brugerrapportering af links bag paywall:

from django.shortcuts import render
from django.core.paginator import Paginator
import requests
import json
from .models import Article

def index(request):
	articles = Article.objects.order_by('-date')
	searchterm = request.GET.get('q')
	medium = request.GET.get('m')
	ritzau = request.GET.get('r')
	duplicates = request.GET.get('d')
	newwindow = request.GET.get('w')
	if searchterm:
		firstsearchcharacter = searchterm[:1]
		# Exclude queries by adding ! to searchterm
		if firstsearchcharacter == "!":
			searchterm = searchterm[1:]
			articles = articles.exclude(title__iregex=searchterm)
			searchterm = "!" + searchterm
		# Perform normal regex-enabled search
		else:
			articles = articles.filter(title__iregex=searchterm)
	if medium:
		articles = articles.filter(medium=medium)
	if ritzau:
		articles = articles.exclude(ritzau=True)
	if not duplicates and not medium:
		articles = articles.exclude(duplicate=True)
	paginator = Paginator(articles, 80)
	page_number = request.GET.get('page')
	page_obj = paginator.get_page(page_number)
	context = {'request': request, 'page_obj': page_obj, 'medium': medium, 'searchterm': searchterm, 'ritzau': ritzau, 'newwindow': newwindow, 'duplicates': duplicates}
	return render(request, 'wall/index.html', context)

Til sidst skrives en skabelon (template) der omsætter data til HTML. Her er fx den ganske korte bid kode, der spytter artikellinks ud på siden:

{% for article in page_obj %}
	{% ifchanged article.date|date %}<h3>{{ article.date|date }}</h3>{% endifchanged %}
	<p>{{ article.date|date:"H:i" }}: <a href="{{ article.url }}"{% if newwindow %} target="_blank"{% endif %}>{{ article.title }}</a> {% if article.ritzau %}<small><sup> ritzau </sup></small> {% endif %}{% if article.duplicate and not medium %}<small><sup> dublet </sup></small> {% endif %}<img title="Giv besked hvis artiklen er bag en paywall" id="{{ article.id }}" class="myBtnt" src="{% static "wall/alert.svg" %}"/></p>
{% endfor %}

God fornøjelse med den nye Wallnot!

Kategorier
blandet

Britta Nielsen-generator

Du ser et stort pengebeløb og tænker: Hvor mange gange Britta Nielsen svarer det egentlig til?

Nu kan du få svaret med regnemaskinen på https://wallnot.dk/britta

Kategorier
blandet

Kortlinkværktøj med Django/Python

Der er nok ikke mange mennesker efterhånden, der ikke har deres egen kortlinkservice. En af de mest kendte er https://bitly.com/.

Som en øvelse har jeg lavet kortlinkservicen https://wallnot.dk/link. Linkene bliver godt nok ikke specielt korte, men indtil videre sparer jeg udgiften til et selvstændigt domænenavn. Det er ikke fordi, der mangler muligheder andre steder.

At lave et kortlink-værktøj i Django er overraskende nemt.

Her er en lille opskrift.

Opskrift på kortlinkværktøj

Efter at have oprettet mit projekt (se evt. guide på https://www.djangoproject.com/start/) går jeg i gang.

Jeg starter med min datamodel i models.py. Hvert link har en destination (det lange link), et kort link og et tidsstempel. Destinationen er en URL, det korte link er et antal tegn og tidsstemplet er – et tidsstempel:

from django.db import models
from django.utils import timezone

class Link(models.Model):
    destination = models.URLField(max_length=500)
    shortlink = models.CharField(max_length=6, unique=True)
    date = models.DateTimeField(default=timezone.now, editable=False)

Jeg ved, at jeg skal bruge en formular. Den opretter jeg i forms.py. Her bruger jeg en type formular, der kaldes ModelForm. Django sørger for, at valideringsreglerne følger samme type data, som jeg har i min bagvedliggende datamodel:

from django.forms import ModelForm, URLInput
from .models import Link

class LinkForm(ModelForm):
    class Meta:
        model = Link
        fields = ['destination']
        widgets = {
            'destination': URLInput(attrs={'placeholder': 'Indsæt link'}),
        }

Logikkerne bag de enkelte visninger i Django laves i views.py. Jeg har to forskellige visninger. Én visning som jeg bruger til at vise min forside, hvor jeg både viser min formular til indtastning af links og det korte link (index). Én visning, som aktiveres når brugeren besøger et kort link (redirect).

Endelig har jeg en funktion, som jeg bruger til at generere selve de korte links.

Jeg har kommenteret koden en masse, så jeg håber den er til at følge med i:

from django.shortcuts import render
from django.http import HttpRequest, HttpResponseRedirect
from .models import Link
from .forms import LinkForm
import hashlib
import bcrypt

# Function to create a random hash to use as short link address
def create_shortlink(destination):
	salt = bcrypt.gensalt().decode()	# Random salt
	destination = destination+salt		# Salt added to destination URL
	hash = hashlib.md5(destination.encode()).hexdigest() # Hashed to alphanumeric string
	return hash[:6]	# First 6 characters of that string 

# Front page with a form to enter destination address. Short URL returned.
def index(request):
	form = LinkForm()	# Loads form
	url = 'https://wallnot.dk/link/'	# site url
	# If a destination is submitted, a short link is returned
	if request.method == 'POST':
		form = LinkForm(request.POST) # Form instance with submitted data
		# Check whether submitted data is valid
		if form.is_valid():
			destination = form.cleaned_data['destination'] # Submitted destination
			# If destination is already in database, return short link for destination from database
			try:
				link = Link.objects.get(destination=destination)
				sharelink = url + link.shortlink # Creates full URL using page URL and hash
			# If destination is not in database, create a new short link
			except:
				# Loop to create a unique hash value for short link
				unique_link = False
				while unique_link == False:
					hash = create_shortlink(destination)	# Return hash
					# First we check whether the hash is a duplicate
					try:
						Link.objects.get(shortlink=hash)	# Check whether hash is used
					# If not a duplicate, an error is thrown, and we can save the hash
					except:
						link = form.save(commit=False)	# Prepare to save form destination data and hash
						link.shortlink = hash	# Sets short link to hash value
						link.save()	# Saves destination and short link to database
						sharelink = url + link.shortlink # Creates full URL using page URL and hash
						unique_link = True	# If check causes error, hash is unused, exit loop
			context = {'sharelink': sharelink, 'form': form}	# Dictionary with variables used in template
			return render(request, 'links/index.html', context)
		# If form is invalid, just renders page.
		else:
			context = {'form': form}
			return render(request, 'links/index.html', context)
	# Render page with form before user has submitted
	context = {'form': form}
	return render(request, 'links/index.html', context)

# Short link redirect to destination URL
def redirect(request, shortlink):
	# Query the database for short link, if there is a hit, redirect to destination URL
	try:
		link = Link.objects.get(shortlink=shortlink)
		return HttpResponseRedirect(link.destination)
	# An error means the short link doesn't exist, so the front page template is shown with an error variable
	except:
		error = True
		context = {'error': error}
		return render(request, 'links/index.html', context)

For at kunne servere siderne, har jeg urls.py, der fortæller Django hvordan en indtastet URL af brugeren skal pege på funktioner i views.py:

from django.urls import path
from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('<shortlink>', views.redirect, name='redirect'),
]

Og endelig har jeg index.html, som er den skabelon, som min side genereres på baggrund af. Hvis du ikke har prøvet Django før, så læg mærke til alt det, der står i tuborgklammer ({}). De bruges dels til simple funktioner (fx if-funktioner, dels til at indsætte variable fra views.py i den side, der genereres.

Hvis du lægger mærke til funktionerne, bruger jeg if-funktionerne til at nøjes med en skabelon, uanset hvilken situation brugeren er havnet i, sådan at indholdet fx er anderledes, når brugeren har lavet en fejl i udfyldelsen af formularen, end når brugeren ikke har udfyldt formularen endnu.

Der er også et lille javascript i filen, der sørger for at brugeren kan kopiere det korte link til sin udklipsholder.

<!doctype html>
<html lang="da">
  <head>
    <!-- Required meta tags -->
	<title>Korte links</title>
	<meta name="description" content="Skønne korte links">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
	<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
	<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
	<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
	<link rel="manifest" href="/site.webmanifest">
	<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
	<meta name="msapplication-TileColor" content="#ffc40d">
	<meta name="theme-color" content="#ffffff">

	<style>
	body { 
		font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
		text-align: center;
		box-sizing: border-box;	
	}

	h1 {
		margin-top: 0;
		font-size: 4.0rem;
		font-weight: 300;
		line-height: 1.2;
		margin-bottom: 1.5rem;
	}

	h2 {
		margin-top: 1.5rem;
		font-size: 2.5rem;
		font-weight: 300;
		line-height: 1.2;
		margin-bottom: 1.5rem;
	}

	input {
		width: 60%;
		line-height: 1.2;
		font-size: 1.0rem;
		height: 1.5rem;
		padding: 10px;
	}

	button {
		width: 50%;
		border: 1px solid transparent;
		padding: .375rem .75rem;
		font-size: 1rem;
		line-height: 1.8;
		height: 2.5rem;
		border-radius: .25rem;
		color: #fff;
		background-color: #28a745;
		border-color: #28a745;
	}

	button:focus {
		box-shadow: 0 0 0 0.2rem rgba(72,180,97,.5)
	}

	button:hover {
		background-color: #218838;
		border-color: #1e7e34;
	}
	
	.footer {
		position: fixed;
		left: 0;
		bottom: 0;
		width: 100%;
		background-color: #f1f1f1;
		color: black;
	}	
	</style>
  </head>
  <body>

<h1>Lav et kort link</h1>

{% if form %}
	<form method="post">
	{% csrf_token %}
	<p>{{ form.destination }}</p>
	<p><button type="submit" value="Lav et kort link">Lav et kort link</button></p>
	</form>

	{% if form.destination.errors %}
		<h2>Tast et gyldigt link!</h2>
		<p><em>Du har tastet et ugyldigt link. Prøv igen med et gyldigt link med http://, https://, ftp:// eller ftps:// foran.</em></p>
	{% endif %}

	{% if request.method == "POST" and not form.destination.errors %}
		<h2>Her er dit link:</h2>
		<p><a href="{{ sharelink }}">{{ sharelink }}</a></p>
		<button class="copy">Kopier link</button>
	{% endif %} 

{% endif %}

{% if error %}
<h2>Har du tastet forkert?</h2>
<p><em>Du har prøvet at bruge et kort link. Desværre er det link, du har tastet, ikke registreret. Måske er du kommet til at taste forkert?</em></p>
<p><a href="{% url 'index' %}">Til forsiden</a>
{% endif %} 

<div class="footer">
  <p>Lav relativt korte links på wallnot.dk. Gratis og fri for annoncer og overvågning.</p>
</div>


<script>
function fallbackCopyTextToClipboard(text) {
  var textArea = document.createElement("textarea");
  textArea.value = text;
  document.body.appendChild(textArea);
  textArea.focus();
  textArea.select();

  try {
    var successful = document.execCommand("copy");
    var msg = successful ? "successful" : "unsuccessful";
    console.log("Fallback: Kopiering gik fint " + msg);
  } catch (err) {
    console.error("Fallback: Kunne ikke kopiere", err);
  }
  document.body.removeChild(textArea);
}

function copyTextToClipboard(text) {
  if (!navigator.clipboard) {
    fallbackCopyTextToClipboard(text);
    return;
  }
  navigator.clipboard.writeText(text).then(function() {
    console.log('Kopiering gik fint');
  }, function(err) {
    console.error('Kunne ikke kopiere', err);
  });
}

var copy = document.querySelector('.copy');

copy.addEventListener('click', function(event) {
  copyTextToClipboard('{{ sharelink }}');
});
</script>

</body>
</html>
Kategorier
blandet

Pakkesporing fra flere forskellige transportører

For tiden øver jeg mig i at bruge Django – et værktøj til at lave webapplikationer i Python. Det er vildt smart.

Det tog et par timer at få https://wallnot.dk/pak/ i luften, men så er der heller ikke gjort noget ud af brugerfladen og det bagvedliggende kunne helt sikkert også gøres smartere. Siden kan bruges til at spore pakker til levering fra flere forskellige transportører (PostNord, GLS, DAO).

Hvis du har pakker på vej fra andre transportører og vil dele pakkenumrene med mig, er jeg interesseret.