Nu kan du spille kortspillet Krig – online!

https://wallnot.dk/krig/ har jeg netop offentliggjort årets julespil nummer 1: Krig!

Ej, jeg havde allerede prøvet at simulere Krig, men synes det kunne være spændende at få logikkerne til at hænge sammen med interaktivitet (dog højst begrænset) og logikker for rent faktisk at vise spillet.

Nu har jeg gjort et forsøg og jeg har kommenteret en masse i koden, så den forhåbentlig er nem at følge med i.

Her er views.py fra Django:

from django.shortcuts import render
import random			# Used to shuffle decks
import base64			# Used for obfuscation and deobfuscation functions
from math import ceil 	# Used to round up

# Create decks function - not a view
def new_deck(context):
	# Create card values and list of cards in each colour
	card_values = range(2,15)
	spades = [str(i) + "S" for i in card_values]
	clubs = [str(i) + "C" for i in card_values]
	diamonds = [str(i) + "D" for i in card_values]
	hearts = [str(i) + "H" for i in card_values]
	# Combine colours to deck
	deck = spades + clubs + diamonds + hearts
	# Shuffle deck		
	random.shuffle(deck)
	# Divide deck between two players and convert to commaseparated string
	player_a_deck = ",".join(deck[0:26])
	player_b_deck = ",".join(deck[26:52])
	# Obfuscate decks to make cheating marginally harder using the obfuscate function
	# production variable toggles this behavior because it's very time consuming to debug
	# if obfuscation is on
	production = True
	if production == True:
		player_a_deck = obfuscate(player_a_deck)
		player_b_deck = obfuscate(player_b_deck)
	# Add the two decks to context
	context['player_a_deck_form'] = player_a_deck
	context['player_b_deck_form'] = player_b_deck
	# Set index to 0 to only turn one card for first round of game
	context['index'] = 0
	return context

# Obfuscate by converting to base64 encoding - not a view
def obfuscate(deck):
	return base64.b64encode(deck.encode()).decode()

# Deobfuscate by converting from base64 encoding to string - not a view
def deobfuscate(deck):
	return base64.b64decode(deck.encode()).decode()

# Logic to create a list of which cards should be hidden or shown to player - not a view
def show_hide_cards(cards_on_table, index):
	counter = 0
	cards_on_table_show_hide = []
	for card in cards_on_table:
		# First card should always be shown
		if counter == 0:
			cards_on_table_show_hide.append([card, True])
		# If the card number is divisible by 4 it is the turn card in a war
		elif counter % 4 == 0:
			cards_on_table_show_hide.append([card, True])
		# If the card number equals the index value, one or both players does not
		# have enough cards for a full war so the last card should be turned
		elif counter == index:
			cards_on_table_show_hide.append([card, True])
		else:
			cards_on_table_show_hide.append([card, False])
		counter += 1
	return cards_on_table_show_hide

# Page view
def index(request):
	# Empty context variable to add to
	context = {}
	# Production variable to toggle obfuscation
	production = True
	# First visit, game has not been started
	if not request.method == 'POST':
		# Create a deck using the new_deck function
		new_deck(context)
	# Game has started
	else:
		### GAME PREPARATION AND CARD DISPLAY LOGIC ###
		# Current game status is used in template to know whether game has been
		# started or not, or has ended
		game_status = "Going on"
		
		# Get submitted decks from user submitted POST request
		player_a_deck = request.POST.get('player_a_deck')
		player_b_deck = request.POST.get('player_b_deck')
		
		# Deobfuscate submitted decks using the deobfuscate function
		if production == True:
			player_a_deck = deobfuscate(player_a_deck)
			player_b_deck = deobfuscate(player_b_deck)
		
		# Convert decks to lists
		player_a_deck = player_a_deck.split(",")
		player_b_deck = player_b_deck.split(",")

		# Get submitted index value in order to know which cards to compare
		# The index is used in case of war to determine which cards to compare
		# and what cards to show to player
		index = int(request.POST.get('index'))
		context['current_index'] = index
		
		# In order to display cards in correct order in case of war for player_b
		# a number of slices are prepared and added to context as strings in a list.
		# number_of_slices is rounded up in case index is not divisible by 4 (endgame logic)
		number_of_slices = ceil(index/4)	
		slices = []
		# Only needed if number of slices is above 0
		if number_of_slices:
			start = 1
			end = 5
			for slice in range(number_of_slices):
				slices.append(str(start)+":"+str(end))
				start +=4
				end += 4
		context['slices'] = slices
		
		# In order to display cards to player using a loop, the deck is sliced
		# by the index value plus 1. # If index is 0, 1 card should be shown.
		# If index is 4 because of war, 5 cards should be shown... and so on.
		a_cards_on_table = player_a_deck[:index+1]
		b_cards_on_table = player_b_deck[:index+1]
		
		# Cards on table is run through function to decide which cards to show face up/face down
		# to player and added to context.
		context['a_cards_on_table'] = show_hide_cards(a_cards_on_table, index)
		context['b_cards_on_table'] = show_hide_cards(b_cards_on_table, index)
		
		# Length of cards "on the table" is calculated in order to calculate remaining cards in player decks.
		# The value for player a is shown to the players and is also used for template card display logic.
		a_cards_on_table_length = len(a_cards_on_table)
		b_cards_on_table_length = len(b_cards_on_table)
				
		# Calculate number of cards in decks
		a_number_of_cards = len(player_a_deck)
		b_number_of_cards = len(player_b_deck)

		# Add remaining cards in deck to context to show to players
		a_remaining_in_deck = a_number_of_cards - a_cards_on_table_length
		b_remaining_in_deck = b_number_of_cards - b_cards_on_table_length
		context['a_remaining_in_deck'] = a_remaining_in_deck
		context['b_remaining_in_deck'] = b_remaining_in_deck
		
		### GAME LOGIC ###
		# Check if both players have decks large enough to compare
		if a_number_of_cards > index and b_number_of_cards > index:
			# Convert first card in decks to integer value in order to compare
			player_a_card = int(player_a_deck[index][:len(player_a_deck[index])-1])
			player_b_card = int(player_b_deck[index][:len(player_b_deck[index])-1])

			# Player a has the largest card
			if player_a_card > player_b_card:
				# Add cards in play to end of player a deck and delete them from beginning
				# of player a and player b decks
				player_a_deck.extend(player_a_deck[:index+1])
				player_a_deck.extend(player_b_deck[:index+1])	
				del player_a_deck[:index+1]
				del player_b_deck[:index+1]
				# If a play is decided, index is set to 0
				index = 0
				context['message'] = "Du vandt runden!"
			# Player b has the largest card
			elif player_a_card < player_b_card:
				# Cards are added to deck in different order from player a to deck in order
				# to avoid game risk of going on forever
				player_b_deck.extend(player_b_deck[:index+1])	
				player_b_deck.extend(player_a_deck[:index+1])
				del player_a_deck[:index+1]
				del player_b_deck[:index+1]
				# If a play is decided, index is set to 0
				index = 0
				context['message'] = "Du tabte runden!"
			# Cards must be equal and war is on
			else:
				# In case of war normally four cards are added to the index, but
				# In order to accomodate a case of end-game war, there are special cases
				# if either player doesn't quite have enough cards for a full 4-card-turn war
				if a_number_of_cards >= index + 4 <= b_number_of_cards:
					index += 4
				# Since the if statement two levels up already checks that number of cards is larger
				# than the index value, an else with no criteria is enough to decide how many cards
				# each player has left to turn and add the smallest number to the index
				else:
					# Calculate the difference between number of cards and index for each player.
					# The smallest of the two differences is added to index to decide how many cards to use for war.
					# One is subtracted for the card already on the table
					a_difference = a_number_of_cards - index
					b_difference = b_number_of_cards - index
					index += min(a_difference, b_difference) - 1
					# Edge case: If war on last remaining card for either player, 1 is added to index to end the game
					# by getting the index above the number of cards in the deck of the player(s) with no cards left
					if a_remaining_in_deck == 0 or b_remaining_in_deck == 0:
						index += 1
				# Messages are different for single, double, trippel wars and anything above.
				# Since the index can be upped by less than four, less than or equal is used to
				# decide which kind of war is on.
				if index <= 4:
					context['message'] = "Krig!"
				elif index <= 8:
					context['message'] = "Dobbeltkrig!"
				elif index <= 12:
					context['message'] = "Trippelkrig!"
				else:
					context['message'] = "Multikrig!"
		
		### AFTER GAME LOGIC AND DECIDE GAME LOGIC ###
		# Calculate length of decks after game logic has run
		player_a_deck_length = len(player_a_deck)
		player_b_deck_length = len(player_b_deck)
		
		# Compare lengths of decks to decide if someone has won. The number of cards on table for
		# next turn of cards is always at least one more than the index (index 0, 1 card, index 4,
		# 5 cards). There are three possible outcomes:
		# 1) Equal game: Both players are unable to turn and have equal sized decks (very, very rare!)
		# 2) Player a is unable to play and has a smaller deck than b (if both players are unable to turn, largest deck wins)
		# 3) Same as 2) for player b
		if player_a_deck_length <= index and player_b_deck_length <= index and player_a_deck_length == player_b_deck_length:
			context['message'] = "Spillet blev uafgjort. Hvor tit sker det lige?"
			game_status = "Over"
		elif player_a_deck_length <= index and player_a_deck_length < player_b_deck_length:
			context['message'] = "Du tabte spillet!"
			game_status = "Over"			
		elif player_b_deck_length <= index and player_b_deck_length < player_a_deck_length:
			context['message'] = "Du vandt spillet!"	
			game_status = "Over"			

		# Add size of decks after play to context to decide whether to show decks to player
		context['after_deck_a'] = player_a_deck
		context['after_deck_b'] = player_b_deck
		
		# Add game status to context
		context['game_status'] = game_status
		
		# Convert decks back to strings
		player_a_deck = ",".join(player_a_deck)
		player_b_deck = ",".join(player_b_deck)
		
		# Obfuscate decks using obfuscate function
		if production == True:		
			player_a_deck = obfuscate(player_a_deck)
			player_b_deck = obfuscate(player_b_deck)
		
		# Context for form
		context['player_a_deck_form'] = player_a_deck
		context['player_b_deck_form'] = player_b_deck
		context['index'] = index
		
		# If game is over, create a new deck to add to form for new game
		if game_status == "Over":
			new_deck(context)
	return render(request, 'krig/index.html', context)

Og her er skabelonen index.html:

{% load static %}
{% spaceless %}
<!doctype html>
<html lang="da">
	<head>
		<title>Krig!</title>
		<meta name="description" content="Spil det populære, vanedannende kortspil krig mod computeren - online!">
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
		<link rel="stylesheet" href="{% static "krig/style.css" %}">
		<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">
		{% comment %}Most of stylesheet is loaded externally, but logic to size images in case of war is kept in template{% endcomment %}
		{% if current_index > 0 %}
		<style>
			img {
				width: 22%;
				display: inline;
			}
		</style>
		{% endif %}
	</head>
	<body>
		<h1>Krig</h1>
		
		{% comment %}Status message of current round or game is displayed{% endcomment %}
		<p class="status">
			{{ message }}
		</p>
		
		{% comment %}Page is divided in two-column grid. Each column is aligned towards vertical center of page{% endcomment %}
		<div class="grid">
			{% comment %}Player a ("You") column{% endcomment %}
			<div class="item text-right">
				<p>Dig</p>

				{% comment %}If any cards are left to turn, show number, if no cards are left, write no cards left{% endcomment %}
				<p class="cardsleft">
					{% if a_remaining_in_deck > 0 %}
						{{ a_remaining_in_deck }} kort tilbage i bunken
					{% elif a_remaining_in_deck == 0 %}
						Ingen kort tilbage!
					{% endif %}
				</p>

				{% comment %}Back of card (deck) is shown if cards are left in deck or game has not begun{% endcomment %}
				{% if a_remaining_in_deck > 0 or not game_status %}
					<img src="{% static 'krig/back_r.svg' %}">
				{% endif %}

				{% comment %}Loop to show player's turned cards.{% endcomment %}
				{% for card in a_cards_on_table %}
					{% if card.1 == True %}
						<img src="{% static 'krig/'|add:card.0|add:'.svg' %}"><br>
					{% else %}
						<img src="{% static 'krig/back_r.svg' %}">
					{% endif %}
				{% endfor %}
			</div>

			{% comment %}Player b ("Computer") column{% endcomment %}
			<div class="item text-left">
				<p>Computeren</p>
				{% comment %}If any cards are left to turn, show number, if no cards are left, write no cards left{% endcomment %}
				<p class="cardsleft">
					{% if b_remaining_in_deck > 0 %}
						{{ b_remaining_in_deck }} kort tilbage i bunken
					{% elif b_remaining_in_deck == 0 %}
						Ingen kort tilbage!
					{% endif %}
				</p>

				{% comment %}
					The order of the deck and the first turned card is different for player b who plays on the right side.
					Therefore if there is a first card in player b's cards on table that card is shown.
				{% endcomment %}
				{% if b_cards_on_table.0 %}
					<img src="{% static 'krig/'|add:b_cards_on_table.0.0|add:'.svg' %}">
				{% endif %}

				{% comment %}If b has cards left in deck or game has not started, show back of deck{% endcomment %}
				{% if b_remaining_in_deck > 0 or not game_status %}
						<img src="{% static 'krig/back_r.svg' %}">
				{% endif %}
				<br>
				
				{% comment %}
					Due to the order of player b's shown cards being different than for player a, this loop to show cards
					in case of war is a little different from player a's.
					The slices variable contains pairs of values saved as strings that the Django template filter |slice can
					understand, e.g. "1:5". These are looped through so that only parts of b_cards_on_table corresponding to
					the slice is looped through for each single, double, etc. war. The loop through b_cards_on_table is reversed
					because the card being turned is shown left of the hidden cards in the war.
				{% endcomment %}
				{% for slice_cut in slices %}
					{% for card in b_cards_on_table|slice:slice_cut reversed %}
						{% if card.1 == True %}
							<img src="{% static 'krig/'|add:card.0|add:'.svg' %}">
						{% else %}
							<img src="{% static 'krig/back_r.svg' %}">
						{% endif %}
					{% endfor %}<br>
				{% endfor %}
			</div>
		</div>

		{% comment %}
			This form is used for user input with the text in the button depending on whether user is on:
			1) Starting page: User can start a game
			2) In an ongoing game: User can turn next card
			3) In a game that has ended: User can start a new game
		{% endcomment %}
		<form class="next" action="{% url 'krig_index' %}" method="post">
			{% csrf_token %}
			<input name="player_a_deck" type="hidden" value="{{ player_a_deck_form }}">
			<input name="player_b_deck" type="hidden" value="{{ player_b_deck_form }}">
			<input name="index" type="hidden" value="{{ index }}">
			<button type="submit">{% if not game_status %}Start spillet{% elif game_status == "Going on" %}Vend næste kort{% elif game_status == "Over" %}Start nyt spil{% endif %}</button>
		</form>
	</body>
</html>
{% endspaceless %}

God fornøjelse!

lnk.dk er i luften med dejlige forkortede links

For noget tid siden fortalte jeg om en prototype på en linkforkorter, jeg havde lavet. Den var ikke særlig brugbar, for den lå på wallnot.dk og lavede derfor ikke specielt korte links.

Efter en behagelig og ukompliceret dialog med de flinke advokater hos Kønig Advokater, der ejede brugsretten til domænenavnet lnk.dk, har jeg fået lov til at overtage lnk.dk – og nu er en opdateret udgave af min kortlinkservice i luften.

lnk.dk kan du lave automatisk generede korte links (tænk lnk.dk/ab0g) eller selv vælge, hvad dit korte link skal hedde (tænk lnk.dk/morten).

Som sædvanlig har jeg brugt Django til arbejdet.

I models.py definerer jeg datamodellen, lidt ekstra validering til brug i formularen til at lave korte links, og hvad jeg gerne vil se i admin-interfacet for siden:

from django.db import models
from django.utils import timezone
from django.contrib import admin
from django.core.exceptions import ValidationError

def validate_destination(destination):
	if "lnk.dk/" in destination.lower():
		raise ValidationError('For at undgå risiko for uendelige viderestillinger, kan du ikke tilføje korte links fra lnk.dk som destination.')
		
def validate_shortlink(shortlink):
	if shortlink == "om":
		raise ValidationError('Det korte link "om" bruger lnk.dk til at fortælle om lnk.dk. Vælg et andet selvvalgt kort link.')
	elif shortlink == "administration":
		raise ValidationError('Det korte link "administration" bruger lnk.dk til at administrere lnk.dk. Vælg et andet selvvalgt kort link.')

class Link(models.Model):
	destination = models.URLField('Destinationslink', max_length=65535, validators=[validate_destination])
	shortlink = models.SlugField('Kort link', max_length=100, unique=True, allow_unicode=True, validators=[validate_shortlink])
	LINK_TYPE_CHOICES = (
		('automatic', 'Automatisk'),
		('manual', 'Manuelt'),
	)	
	type = models.CharField('Type', max_length=10, choices=LINK_TYPE_CHOICES)
	date = models.DateTimeField(default=timezone.now, editable=False)
	
class LinkAdmin(admin.ModelAdmin):
	list_display = ('destination','shortlink','type','date')
	list_filter = ('type', )
	search_fields = ['destination']

I forms.py definerer jeg formularen, som brugeren indtaster sit lange link og evt. et selvvalgt kort link i. Jeg forsøger også at formulere nogle forståelige fejlmeddelelser:

from django.forms import ModelForm
from .models import Link

class LinkForm(ModelForm):
	def __init__(self, *args, **kwargs):
		super(LinkForm, self).__init__(*args, **kwargs)
		self.fields['destination'].widget.attrs['placeholder'] = 'https://eksempel.dk/meget/lang/url'
		self.fields['shortlink'].widget.attrs['placeholder'] = 'eksempel'
		self.fields['shortlink'].label_suffix = ""	# Remove colon after label
		self.fields['shortlink'].required = False	# Not required in form
	
	class Meta:
		model = Link
		fields = ['destination', 'shortlink']
		labels = {
			'shortlink': ('Evt. selvvalgt kort link, lnk.dk/'),
		}
		error_messages = {
			'destination': {
				'max_length': ('Din destinationsurl er for lang til denne kortlinkservice.'),
				'invalid': ('Din destinationsurl er ikke en gyldig adresse. Husk http://, https:// eller ftp:// foran dit link, hvis du har glemt det.'),
			},
			'shortlink': {
				'unique': ('Det selvvalgte link, du har valgt, er allerede i brug. Find på et andet.'),
				'max_length': ('Dit selvvalgte link må maksimalt være 100 tegn langt.'),
				'invalid': ('Du kan kun bruge unicode-bogstaver, cifre, bindestreg og understreg i din selvvalgte adresse.'),
			}
		}

Mine visninger forberedes i views.py som har:

  • En funktion til autogenerede kortlinks. Linket tilføjet en tilfældig streng hashes til en ny tilfældig streng. Der tilføjes en tilfældig streng hver gang for at sikre, at der genereres en ny streng hver gang (for at undgå en uendelig løkke, hvis et links hash-værdi skulle kollidere med et andet links hashværdi).
  • En visning til forsiden med dens formular, validering af formularen og visning af kortlink og eventuelle fejl.
  • En visning, der sørger for at viderestille fra et kort link til et destinationslink, hvis det korte link findes. Ellers vises en fejlside.
  • En visning til en “om lnk.dk”-side.
from django.shortcuts import render
from django.http import HttpResponseRedirect
from .models import Link
from .forms import LinkForm
from django.urls import reverse
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[:4]	# First 5 characters of that string 

# Front page with a form to enter destination address. Short URL returned.
def linkindex(request):
	form = LinkForm()	# Loads form
	# 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
			shortlink = form.cleaned_data['shortlink'] # Submitted slug
		
			# User has specified a unique (validated) short link
			if shortlink:
				link = form.save(commit=False)
				link.type = "manual"
				link = form.save()
				site_url = reverse('redirect', args=[link.shortlink])
				sharelink = request.build_absolute_uri(site_url)
			# User wants an automatic link
			else:
				# If a short link with same destionation of same type already exists,
				# it is fetched from database  and served. No need to use up a new
				# URL for the same destination.
				try:
					link = Link.objects.get(destination=destination, type="automatic")
					site_url = reverse('redirect', args=[link.shortlink])
					sharelink = request.build_absolute_uri(site_url)
				# If a link of same type with same destination does not exist, one is
				# created.
				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.type = "automatic"
							link.save()	# Saves destination and short link to database
							site_url = reverse('redirect', args=[link.shortlink])
							sharelink = request.build_absolute_uri(site_url)
							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, check whether a user is trying to create a duplicate manual
		# shortlink.
		else:
			# Check if there's a valid destination link
			if 'destination' in form.cleaned_data:
				# Check if there's a shortlink that's not unique
				try:
					link = Link.objects.get(shortlink=form.data['shortlink'])
					# If so, check whether the destination is the same
					if form.cleaned_data['destination'] == link.destination:
						# Show sharelink to user
						site_url = reverse('redirect', args=[link.shortlink])
						sharelink = request.build_absolute_uri(site_url)
						form.errors['shortlink'] = "" # Error replaced by empty string
					# Render form with sharelink already used error
					else:
						sharelink = ""
				# Render form with error
				except:	
					sharelink = ""
			# Render form with errors	
			else:
				sharelink = ""
		context = {'form': form, 'sharelink': sharelink}
		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 error 404 is shown
	except:
		return render(request, 'links/404.html', status=404)

# About page		
def about(request):
	context = {'request': request}
	return render(request, 'links/about.html', context)	

urls.py sørger for at forbinde den adressse, brugeren har tastet i browseren, med de rette visninger fra views.py:

from django.urls import path
from . import views

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

Endelig har jeg skabelon-filer, der sørger for selve html-koden på siden. base.html er min overordnede skabelon med det overordnede design, meta-tags, sidefod osv.:

{% load static %}{% spaceless %}<!doctype html>
<html lang="da">
	<head>
		<title>lnk.dk: Danmarks korteste links</title>
		<meta charset="utf-8"/>
		<meta name="description" content="Lav de korteste korte kortlinks gratis på lnk.dk. Fri for annoncer og overvågning.">
		<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
		<link rel="stylesheet" href="{% static "links/style.css" %}">
		<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">
		<meta name="theme-color" content="#ffffff">
	</head>
<body>
{% block content %}{% endblock %}
<div class="footer">
	<p>Lav de korteste korte links gratis på lnk.dk. Fri for annoncer og overvågning. <a href="{% url 'about' %}">Om lnk.dk</a></p>
</div>
</body>
</html>
{% endspaceless %}

Og her er skabelonen til forsiden, index.html:

{% extends "links/base.html" %}{% block content %}{% spaceless %}
<h1>Lav et kort link</h1>
<div class="content">
	{% if form %}
		<form method="post">
		{% csrf_token %}
		<div class="form_field">
			<div class="label">
				{{ form.destination.label_tag}}
			</div>
			<div>{{ form.destination }}</div>
			{{ form.destination.errors }}
		</div>
		<div class="form_field">
			<div class="label">
				{{ form.shortlink.label_tag}}
			</div>
			<div>{{ form.shortlink }}</div>
			{{ form.shortlink.errors }}
		</div>
		<p><button type="submit" value="Giv mig et kort link">Giv mig et kort link</button></p>
		</form>

		{% if request.method == "POST" and not form.destination.errors and not form.shortlink.errors  %}
			<h1>Her er dit link:</h1>
			<p class="sharelink"><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>

{% if request.method == "POST" and not form.destination.errors %}
<script>
function fallbackCopyTextToClipboard(text) {
  var textArea = document.createElement("textarea");
  textArea.value = text;
  document.body.appendChild(textArea);
  textArea.focus();
  textArea.select();
  document.execCommand("copy");
  document.body.removeChild(textArea);
}
  
function copyTextToClipboard(text) {
  if (!navigator.clipboard) {
    fallbackCopyTextToClipboard(text);
    return;
  }
  navigator.clipboard.writeText(text);
}

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

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

Er du nået hertil? Se det i praksis på lnk.dk!

Danmarks nemmeste(?) ønskeseddelservice

Måske har du fulgt med i medierne omkring ønskeseddeltjenesten Ønskeskyen og deres uheldige/ulovlige omgang med persondata? Eller synes at andre tjenester er for kommercielle, mangler basal sikkerhed i form af krypteret forbindelse, eller bare er for besværlige til dig (og især ældre familiemedlemmer) med krav om brugeroprettelse?

Tja: Nu har jeg i hvert fald lavet et bud på en gratis, ukommerciel, overvågningsfri og frem for alt: let ønskeseddelservice på wishlist.dk https://wallnot.dk/wish.

Som sædvanlig er siden udviklet med Django i Python og selve datamodellen er enkel og gemmer så få oplysninger som muligt om brugeren af siden:

class Wishlist(models.Model):
	title = models.CharField('Titel', max_length=500)
	emailconfirmationstatus = models.BooleanField('Bekræftet e-mail', default=False)
	emailconfirmationtoken = models.CharField('E-mailbekræftelseskode', max_length=32)
	editwishlisttoken = models.CharField('Redigeringskode', max_length=32)
	readwishlisttoken = models.CharField('Læsningskode', max_length=32)
	created_at = models.DateTimeField('Oprettet den', default=timezone.now, editable=False)

class Wish(models.Model):
	wishlist = models.ForeignKey(Wishlist, on_delete=models.CASCADE)
	title = models.CharField('Ønske', max_length=500)
	description = models.CharField('Beskrivelse', max_length=500, blank=True)
	shopurl = models.URLField('Link til ønske', max_length=500, blank=True)
	imageurl = models.URLField('Link til foto', max_length=500, blank=True) 
	price = models.DecimalField('Pris', max_digits=20, decimal_places=2, validators=[MinValueValidator(0, message="Må ikke være et negativt tal")], blank=True, null=True)
	reserved = models.BooleanField('Reserveret', default=False)
	reservation_note = models.CharField('Giver', max_length=500, blank=True)
	sort_order = models.PositiveIntegerField('Sortering', null=True, blank=True, default=0)

Ønskesedler kan tilgås med koder i webadressen. Der er en kode, der giver adgang til at redigere ønskesedlen, en der giver adgang til at se ønskesedlen og reservere ønsker, og en der bruges til at bekræfte, at man har modtaget en e-mailnotifikation med link til ønskesedlen. E-mailen bruges til at brugerne kan finde ønskesedlen igen og som en lille-bitte sikkerhedsforanstaltning, idet man først kan dele sin ønskeseddel med andre, når man har bekræftet sin mailadresse. Koderne genereres med Python-modulet Secrets og kan fx være g_q-3bTURYWYCwq6rH1Vu06tumI.

Sådan her ser det ud i Djangos views.py, når en bruger har indtastet en titel på en ønskeseddel og sin mailadresse og systemet bag skal oprette en ønskeseddel:

if request.method == 'POST':
	form = CreateWishlistForm(request.POST)
	if form.is_valid():
		# Create wishlist
		emailconfirmationtoken = secrets.token_urlsafe(20)
		editwishlisttoken = secrets.token_urlsafe(20)
		readwishlisttoken = secrets.token_urlsafe(20)
		wishlist = Wishlist(title = form.cleaned_data['title'], emailconfirmationtoken = emailconfirmationtoken, editwishlisttoken = editwishlisttoken, readwishlisttoken = readwishlisttoken)
		wishlist.save()

Lidt ligesom med kortlinksgeneratorer, kan man godt have flertallet af danske webudviklere mistænkt for at have udviklet deres eget bud på en ønskeseddelservice – der dukker i hvert fald en del op, når man søger. Nu har jeg lavet mit bud på danmarks nemmeste ønskeseddel.

Et lille kig på Aulas API

Det her kodeeksempel er ret gammelt. Du kan med fordel kigge på Aulas API: En opdatering i stedet

Hvis du har børn i skolealderen, kender du måske Aula. Det har jeg, og derfor har jeg lavet en lille programmeringsøvelse, hvor jeg trækker data ud fra Aula’s API.

Det kan ikke rigtigt bruges til noget i nuværende form (jeg bruger heller ikke rigtig selv Aula til noget endnu), men senere kunne det være relevant at udvide med mulighed for at tilgå visse hyppigt brugte funktioner uden at logge ind på hjemmesiden, eller til at lave sit eget personlige Aula-interface.

Ift. mine andre hente-data-fra-API’er-øvelser, har jeg her gjort to ting, som jeg synes er smarte:

  1. Jeg bruger en “session” i Python-modulet requests. Det gør, at jeg ikke behøver at rode med, hvilke cookies, de enkelte trin i loginproceduren, har brug for. De gemmes og benyttes i stedet automatisk gennem trinnene.
  2. I stedet for at gentage en masse kode i hver enkelt trin i login, bruger jeg en løkke, der, selv finder formularer og viste og skjulte input-felter på de enkelte trin, udfylder dem og sender dem af sted.

Hvis du har lyst til at prøve det af, finder du koden her. Du kan finde flere API-forespørgsler ved at bruge din browsers udviklerværktøjer på Aulas side. Koden burde også nemt kunne bruges til at logge ind og hente data fra andre hjemmesider end Aula, det kræver blot et par småjusteringer.

# aula.py
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
''' An example of how to log in to the Danish LMS Aula (https://aula.dk) and
extract data from the API. Could be further developed to also submit data and/or to
create your own web or terminal interface(s) for Aula.'''

# Imports
import requests					# Perform http/https requests
from bs4 import BeautifulSoup	# Parse HTML pages
import json						# Needed to print JSON API data

# User info
user = {
	'username': '',
	'password': ''
	}

# Start requests session
session = requests.Session()
	
# Get login page
url = 'https://login.aula.dk/auth/login.php?type=unilogin'
response = session.get(url)

# Login is handled by a loop where each page is first parsed by BeautifulSoup.
# Then the destination of the form is saved as the next url to post to and all
# inputs are collected with special cases for the username and password input.
# Once the loop reaches the Aula front page the loop is exited. The loop has a
# maximum number of iterations to avoid an infinite loop if something changes
# with the Aula login.
counter = 0
success = False
while success == False and counter < 10:
	try:
		# Parse response using BeautifulSoup
		soup = BeautifulSoup(response.text, "lxml")
		# Get destination of form element (assumes only one)
		url = soup.form['action']	
		
		# If form has a destination, inputs are collected and names and values
		# for posting to form destination are saved to a dictionary called data
		if url:
			# Get all inputs from page
			inputs = soup.find_all('input')
			# Check whether page has inputs
			if inputs:
				# Create empty dictionary 
				data = {}
				# Loop through inputs
				for input in inputs:
					# Some inputs may have no names or values so a try/except
					# construction is used.
					try:
						# Save username if input is a username field
						if input['name'] == 'username':
							data[input['name']] = user['username']
						# Save password if input is a password field
						elif input['name'] == 'password':
							data[input['name']] = user['password']
						# For all other inputs, save name and value of input
						else:
							data[input['name']] = input['value']
					# If input has no value, an error is caught but needs no handling
					# since inputs without values do not need to be posted to next
					# destination.
					except:
						pass
			# If there's data in the dictionary, it is submitted to the destination url
			if data:
				response = session.post(url, data=data)
			# If there's no data, just try to post to the destination without data
			else:
				response = session.post(url)
			# If the url of the response is the Aula front page, loop is exited
			if response.url == 'https://www.aula.dk:443/portal/':
				success = True
	# If some error occurs, try to just ignore it
	except:
		pass
	# One is added to counter each time the loop runs independent of outcome
	counter += 1

# Login succeeded without an HTTP error code and API requests can begin	
if success == True and response.status_code == 200:
	print("Login lykkedes")
	
	# All API requests go to the below url
	# Each request has a number of parameters, of which method is always included
	# Data is returned in JSON
	url = 'https://www.aula.dk/api/v11/'
	
	### First example API request ###
	params = {
		'method': 'profiles.getProfilesByLogin'
		}
	# Perform request, convert to json and print on screen
	response_profile = session.get(url, params=params).json()
	print(json.dumps(response_profile, indent=4))
	
	
	### Second example API request ###
	params = {
		'method': 'profiles.getProfileContext',
		'portalrole': 'guardian',
	}
	# Perform request, convert to json and print on screen
	response_profile_context = session.get(url, params=params).json()
	print(json.dumps(response_profile_context, indent=4))

	# Loop to get institutions and children associated with profile and save
	# them to lists
	institutions = []
	institution_profiles = []
	children = []
	for institution in response_profile_context['data']['institutions']:
		institutions.append(institution['institutionCode'])
		institution_profiles.append(institution['institutionProfileId'])
		for child in institution['children']:
			children.append(child['id'])
	
	children_and_institution_profiles = institution_profiles + children

	### Third example API request, uses data collected from second request ###
	params = {
		'method': 'notifications.getNotificationsForActiveProfile',
		'activeChildrenIds[]': children,
		'activeInstitutionCodes[]': institutions
	}
	
	# Perform request, convert to json and print on screen
	notifications_response = session.get(url, params=params).json()
	print(json.dumps(notifications_response, indent=4))
	
	### Fourth example API request, only succeeds when the third has been run before ###
	params = {
		'method': 'messaging.getThreads',
		'sortOn': 'date',
		'orderDirection': 'desc',
		'page': '0'
	}
	
	# Perform request, convert to json and print on screen
	response_threads = session.get(url, params=params).json()
	#print(json.dumps(response_threads, indent=4))
	
	### Fifth example. getAllPosts uses a combination of children and instituion profiles. ###
	params = {
		'method': 'posts.getAllPosts',
		'parent': 'profile',
		'index': "0",
		'institutionProfileIds[]': children_and_institution_profiles,
		'limit': '10'
	}

	# Perform request, convert to json and print on screen
	response_threads = session.get(url, params=params).json()
	print(json.dumps(response_threads, indent=4))
	
# Login failed for some unknown reason
else:
	print("Noget gik galt med login")

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>

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>

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!

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!

En lille Google-crawler

Til Wallnot ville jeg gerne have fat i samtlige Zetland-historier, som Google har indekseret.

Til det formål skrev jeg et lille program, der gennemgår Googles søgeresultater. Programmet holder en lille pause mellem hver side med søgeresultater, der hentes. Det skyldes at Google åbenbart ikke selv er vild med robotter, paradoksalt nok.

import requests
from bs4 import BeautifulSoup
import time
import random

linkcollection = []
def google_results(url):
	try:
		result = requests.get(url)
		soup = BeautifulSoup(result.text, "lxml")
		links = soup.find_all('a')

		for link in links:
			if "zetland.dk/historie/" in link['href']:
				full_link = link['href']
				url = full_link[full_link.find("q=")+2:full_link.find("&")]
				linkcollection.append(link['href'])
				print(link['href'])
		next_page = soup.find('a', attrs={'aria-label': 'Næste side'})
		time_to_sleep = random.randrange(3,7)
		print("Sleeping " + str(time_to_sleep) + " seconds")
		time.sleep(time_to_sleep)
		google_results('https://www.google.com'+next_page['href'])
	except TypeError:
		print("No more results it seems")

url = 'https://www.google.com/search?q=site:zetland.dk/historie'
google_results(url)

with open("./googlelist.txt", "wt", encoding="utf8") as fout:
	fout.write(str(linkcollection))