Add initial project setup with Django configuration

Introduce basic Django project structure, including ASGI application settings, forms setup for Playthrough model, command utilities, and initial migration files. Includes proper `.gitignore` definitions for Python and Django development standards. Initialize Bootstrap with custom theming and necessary static assets for frontend styling. Dedicate management command for setting outbound redirect destinations, enhancing administrative capabilities.
This commit is contained in:
Kumi 2024-02-03 22:07:36 +01:00
commit e4ae60d336
Signed by: kumi
GPG key ID: ECBCC9082395383F
23 changed files with 601 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
db.sqlite3
*.pyc
__pycache__/
venv/

0
exp360test/__init__.py Normal file
View file

16
exp360test/asgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
ASGI config for exp360test project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'exp360test.settings')
application = get_asgi_application()

9
exp360test/forms.py Normal file
View file

@ -0,0 +1,9 @@
from django.forms import ModelForm
from .models import Playthrough
class PlaythroughForm(ModelForm):
class Meta:
model = Playthrough
fields = ["email"]

View file

@ -0,0 +1,25 @@
from django.core.management.base import BaseCommand, CommandError
from dbsettings.models import Setting
class Command(BaseCommand):
help = "Set the destination of the outward redirect"
def add_arguments(self, parser):
parser.add_argument("destination", type=str)
parser.add_argument("password", type=str)
def handle(self, *args, **options):
Setting.objects.update_or_create(
key="destination",
defaults={"value": options["destination"]},
)
Setting.objects.update_or_create(
key="password",
defaults={"value": options["password"]},
)
self.stdout.write(
self.style.SUCCESS(f"Successfully set the destination to {options['destination']}")
)

View file

@ -0,0 +1,23 @@
# Generated by Django 5.0.1 on 2024-02-02 19:37
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Playthrough',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254, unique=True)),
('start', models.DateTimeField(auto_now_add=True)),
('end', models.DateTimeField(blank=True, null=True)),
],
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 5.0.1 on 2024-02-02 19:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exp360test', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='playthrough',
name='session',
field=models.CharField(default=None, max_length=100, unique=True),
preserve_default=False,
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-02-02 20:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exp360test', '0002_playthrough_session'),
]
operations = [
migrations.AlterField(
model_name='playthrough',
name='session',
field=models.UUIDField(unique=True),
),
]

View file

24
exp360test/models.py Normal file
View file

@ -0,0 +1,24 @@
from django.db import models
class PlaythroughManager(models.Manager):
def get_queryset(self):
return (
super()
.get_queryset()
.annotate(
duration=models.ExpressionWrapper(
models.F("end") - models.F("start"),
output_field=models.DurationField(),
)
)
)
class Playthrough(models.Model):
session = models.UUIDField(unique=True)
email = models.EmailField(unique=True)
start = models.DateTimeField(auto_now_add=True)
end = models.DateTimeField(null=True, blank=True)
objects = PlaythroughManager()

129
exp360test/settings.py Normal file
View file

@ -0,0 +1,129 @@
"""
Django settings for exp360test project.
Generated by 'django-admin startproject' using Django 5.0.1.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-bh1y%s73i)w(h=0l+h)%8nuj8pxe$5hatd#gu2bn%q@2y0#t^w'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Hosts for CSRF
CSRF_TRUSTED_ORIGINS = ["https://exp360test.kumi.live", "https://exp360test.dev.kumi"]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'exp360test',
'dbsettings',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'exp360test.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'exp360test.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,32 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EXP360 Test</title>
<link rel="stylesheet" href="{% static 'dist/css/bootstrap.min.css' %}">
</head>
<body>
<nav class="navbar navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="/">EXP360 Test</a>
</div>
</nav>
<main style="margin-top: 30px;" role="main" class="container">
{% block content %}{% endblock %}
</main>
<footer class="text-muted py-5">
<div class="container">
<p style="visibility: hidden;" class="float-right">
<a href="#">Back to top</a>
</p>
<p>Kumi Systems e.U. &copy; 2024</p>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block content %}
<div class="row">
<h1>End</h1>
<p>Thank you for participating. You took {{ duration.total_seconds }} seconds to complete the tour.</p>
<p>You may now close this window.</p>
<h2>Leaderboard</h2>
<table>
<tr>
<th>Rank</th>
<th>Email</th>
<th>Duration</th>
</tr>
{% for entry in leaderboard %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ entry.email }}</td>
<td>{{ entry.duration }}</td>
</tr>
{% endfor %}
</table>
<form action="/restart" method="get">
<input class="btn btn-secondary" type="submit" value="Restart">
</form>
</div>
{% if redirect %}
<script>
window.history.replaceState({}, "", null);
window.location.replace("{{ redirect }}");
</script>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block content %}
<div class="row">
<h1>Leaderboard</h1>
<table>
<tr>
<th>Rank</th>
<th>Email</th>
<th>Duration</th>
</tr>
{% for entry in leaderboard %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ entry.email }}</td>
<td>{{ entry.duration }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,16 @@
<html>
<head>
<title>Redirect</title>
</head>
<body>
<h1>Redirect</h1>
<p>Please wait while we redirect you to the content, or click the button below.</p>
<form action="{{ destination }}" method="post">
<input type="hidden" name="password" value="{{ password }}">
<input type="submit" value="Redirect">
</form>
<script>
window.history.replaceState({}, "", null);
document.querySelector('form').submit();
</script>
</html>

View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block content %}
<div class="row">
<h1>Start</h1>
<p>Please enter your email address to continue.</p>
<form action="" method="post">
{% csrf_token %}
{{ form }}
<input class="btn btn-primary" type="submit" value="Start">
</form>
<hr style="margin-top: 10px;">
<form action="/leaderboard" method="get">
<input class="btn btn-secondary" type="submit" value="Leaderboard">
</form>
</div>
{% endblock %}

38
exp360test/urls.py Normal file
View file

@ -0,0 +1,38 @@
"""
URL configuration for exp360test project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from .views import (
StartView,
EndView,
RedirectInView,
RedirectOutView,
RestartView,
LeaderboardView,
)
urlpatterns = [
path("", StartView.as_view(), name="start"),
path("end/", EndView.as_view(), name="end"),
path("redirect_in/", RedirectInView.as_view(), name="redirect_in"),
path("redirect_out/", RedirectOutView.as_view(), name="redirect_out"),
path("restart/", RestartView.as_view(), name="restart"),
path("leaderboard/", LeaderboardView.as_view(), name="leaderboard"),
path("admin/", admin.site.urls),
]

128
exp360test/views.py Normal file
View file

@ -0,0 +1,128 @@
from django.views.generic import TemplateView, View, FormView
from django.shortcuts import resolve_url, redirect
from django.db.models import F, ExpressionWrapper, fields
from django.contrib import messages
from django.forms import ValidationError
from django.http import Http404
from datetime import datetime
import uuid
from .models import Playthrough
from .forms import PlaythroughForm
from dbsettings.models import Setting
class StartView(FormView):
template_name = "start.html"
form_class = PlaythroughForm
def form_valid(self, form):
session_id = self.request.session.get("session_id")
if session_id is None:
session_id = str(uuid.uuid4())
self.request.session["session_id"] = session_id
form.instance.session = session_id
form.save()
return redirect("redirect_out")
def form_invalid(self, form):
for field, errors in form.errors.items():
for error in errors:
messages.error(self.request, f"{field}: {error}")
return super().form_invalid(form)
def get(self, request, *args, **kwargs):
session_id = request.session.get("session_id")
if session_id is not None:
try:
playthrough = Playthrough.objects.get(session=session_id)
except Playthrough.DoesNotExist:
request.session["session_id"] = None
else:
if playthrough.end is None:
return redirect("redirect_out")
else:
return redirect("end")
return super().get(request, *args, **kwargs)
class EndView(TemplateView):
template_name = "end.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
try:
playthrough = Playthrough.objects.get(
session=self.request.session.get("session_id")
)
except Playthrough.DoesNotExist:
context["redirect"] = resolve_url("start")
return context
if playthrough.end is None:
context["redirect"] = resolve_url("redirect_out")
return context
context["duration"] = playthrough.duration
context["leaderboard"] = Playthrough.objects.filter(
duration__isnull=False
).order_by("duration")[:10]
return context
class RedirectOutView(TemplateView):
template_name = "redirect_out.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["destination"] = Setting.objects.get(key="destination").value
context["password"] = Setting.objects.get(key="password").value
return context
class RedirectInView(View):
template_name = "redirect_in.html"
def get(self, request, *args, **kwargs):
session_id = request.session.get("session_id")
if session_id is None:
return redirect("start")
try:
playthrough = Playthrough.objects.get(session=session_id)
except Playthrough.DoesNotExist:
return redirect("start")
if playthrough.end is None:
playthrough.end = datetime.now()
playthrough.save()
return redirect("end")
class RestartView(View):
def get(self, request, *args, **kwargs):
request.session["session_id"] = None
return redirect("start")
class LeaderboardView(TemplateView):
template_name = "leaderboard.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["leaderboard"] = Playthrough.objects.filter(
duration__isnull=False
).order_by("duration")
return context

16
exp360test/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for exp360test project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'exp360test.settings')
application = get_wsgi_application()

22
manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'exp360test.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
Django
dbsettings