diff --git a/.gitignore b/.gitignore
index 058ad98..d37e6f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,9 +11,7 @@ db.sqlite3
# Environments
.python-version
-.env
.venv
-env/
venv/
# Distribution / packaging
@@ -24,3 +22,10 @@ dist/
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
+
+# IDE stuff
+.idea/
+.vscode/
+
+# Other stuff
+_tmp
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..c2bda28
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,18 @@
+# General settings
+APP_NAME := tofu
+PYTHON ?= python3
+
+# Development server
+SERVER_LISTEN ?= 0.0.0.0:8037
+
+.PHONY: run
+
+# Default target: none
+all:
+
+
+### Local development
+
+# Run django development server
+run:
+ $(PYTHON) manage.py runserver $(SERVER_LISTEN)
diff --git a/issues/__init__.py b/issues/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/issues/admin.py b/issues/admin.py
new file mode 100644
index 0000000..dfbf979
--- /dev/null
+++ b/issues/admin.py
@@ -0,0 +1,5 @@
+from django.contrib import admin
+
+from .models import Issue
+
+admin.site.register(Issue)
diff --git a/issues/apps.py b/issues/apps.py
new file mode 100644
index 0000000..73a36a0
--- /dev/null
+++ b/issues/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class IssuesConfig(AppConfig):
+ name = 'issues'
diff --git a/issues/forms.py b/issues/forms.py
new file mode 100644
index 0000000..c7c5dc9
--- /dev/null
+++ b/issues/forms.py
@@ -0,0 +1,9 @@
+from django.forms import ModelForm
+
+from .models import Issue
+
+
+class IssueForm(ModelForm):
+ class Meta:
+ model = Issue
+ fields = ['project', 'title', 'text']
diff --git a/issues/migrations/0001_initial.py b/issues/migrations/0001_initial.py
new file mode 100644
index 0000000..82fb950
--- /dev/null
+++ b/issues/migrations/0001_initial.py
@@ -0,0 +1,27 @@
+# Generated by Django 2.2.1 on 2019-05-27 12:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('projects', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Issue',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=200)),
+ ('text', models.TextField(blank=True)),
+ ('create_date', models.DateTimeField(default=django.utils.timezone.now)),
+ ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.Project')),
+ ],
+ ),
+ ]
diff --git a/issues/migrations/__init__.py b/issues/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/issues/models.py b/issues/models.py
new file mode 100644
index 0000000..180923f
--- /dev/null
+++ b/issues/models.py
@@ -0,0 +1,14 @@
+from django.db import models
+from django.utils import timezone
+
+from projects.models import Project
+
+
+class Issue(models.Model):
+ project = models.ForeignKey(Project, on_delete=models.CASCADE)
+ title = models.CharField(max_length=200)
+ text = models.TextField(blank=True)
+ create_date = models.DateTimeField(default=timezone.now)
+
+ def __str__(self):
+ return self.title
diff --git a/issues/templates/issues/detail.html b/issues/templates/issues/detail.html
new file mode 100644
index 0000000..e25b70d
--- /dev/null
+++ b/issues/templates/issues/detail.html
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+
+{% block title %}Issue: {{ issue.title }}{% endblock %}
+
+{% block content %}
+
#{{ issue.id }}: {{ issue.title }}
+
+ Project: {{ issue.project }}
+ Created: {{ issue.create_date }}
+
+ {{ issue.text }}
+{% endblock %}
diff --git a/issues/templates/issues/index.html b/issues/templates/issues/index.html
new file mode 100644
index 0000000..9eb3e07
--- /dev/null
+++ b/issues/templates/issues/index.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+
+{% block title %}Issues{% endblock %}
+
+{% block content %}
+ {% if issue_list %}
+
+ {% else %}
+ No issues.
+ {% endif %}
+
+ Create new issue
+{% endblock %}
diff --git a/issues/templates/issues/new.html b/issues/templates/issues/new.html
new file mode 100644
index 0000000..0e6d87c
--- /dev/null
+++ b/issues/templates/issues/new.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+
+{% block title %}Create issue{% endblock %}
+
+{% block content %}
+ Create new issue
+
+ {% if error_message %}{{ error_message }}
{% endif %}
+
+
+{% endblock %}
diff --git a/issues/tests.py b/issues/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/issues/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/issues/urls.py b/issues/urls.py
new file mode 100644
index 0000000..8402305
--- /dev/null
+++ b/issues/urls.py
@@ -0,0 +1,10 @@
+from django.urls import path
+
+from . import views
+
+app_name = 'issues'
+urlpatterns = [
+ path('', views.IndexView.as_view(), name='index'),
+ path('new', views.new, name='new'),
+ path('/', views.DetailView.as_view(), name='detail'),
+]
diff --git a/issues/views.py b/issues/views.py
new file mode 100644
index 0000000..a41005d
--- /dev/null
+++ b/issues/views.py
@@ -0,0 +1,42 @@
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import HttpResponseRedirect
+from django.shortcuts import render
+from django.views import generic
+
+from .forms import IssueForm
+from .models import Issue
+
+
+class IndexView(LoginRequiredMixin, generic.ListView):
+ template_name = 'issues/index.html'
+ context_object_name = 'issue_list'
+
+ def get_queryset(self):
+ return Issue.objects.order_by('create_date')
+
+
+class DetailView(LoginRequiredMixin, generic.DetailView):
+ model = Issue
+ template_name = 'issues/detail.html'
+ pk_url_kwarg = 'issue_id'
+
+
+# class NewIssueForm(LoginRequiredMixin, generic.FormView):
+# template_name = 'issues/new.html'
+# form_class = IssueForm
+# success_url =
+
+@login_required
+def new(request):
+ if request.method == 'POST':
+ form = IssueForm(request.POST)
+ if form.is_valid():
+ new_issue = form.save()
+ return HttpResponseRedirect('/issues/{}'.format(new_issue.id))
+ else:
+ form = IssueForm()
+
+ return render(request, 'issues/new.html', {
+ 'form': form,
+ })
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000..dd7d3a3
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+import os
+import sys
+
+if __name__ == '__main__':
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tofu.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)
diff --git a/projects/__init__.py b/projects/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/projects/admin.py b/projects/admin.py
new file mode 100644
index 0000000..badc1bb
--- /dev/null
+++ b/projects/admin.py
@@ -0,0 +1,5 @@
+from django.contrib import admin
+
+from .models import Project
+
+admin.site.register(Project)
diff --git a/projects/apps.py b/projects/apps.py
new file mode 100644
index 0000000..3ef44de
--- /dev/null
+++ b/projects/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ProjectsConfig(AppConfig):
+ name = 'projects'
diff --git a/projects/migrations/0001_initial.py b/projects/migrations/0001_initial.py
new file mode 100644
index 0000000..55089c4
--- /dev/null
+++ b/projects/migrations/0001_initial.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.1 on 2019-05-27 12:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Project',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('project_key', models.CharField(max_length=16, unique=True)),
+ ('name', models.CharField(max_length=200)),
+ ('description', models.TextField(blank=True)),
+ ],
+ ),
+ ]
diff --git a/projects/migrations/__init__.py b/projects/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/projects/models.py b/projects/models.py
new file mode 100644
index 0000000..9345b06
--- /dev/null
+++ b/projects/models.py
@@ -0,0 +1,10 @@
+from django.db import models
+
+
+class Project(models.Model):
+ project_key = models.CharField(max_length=16, unique=True)
+ name = models.CharField(max_length=200)
+ description = models.TextField(blank=True)
+
+ def __str__(self):
+ return '{} - {}'.format(self.project_key, self.name)
diff --git a/projects/templates/projects/index.html b/projects/templates/projects/index.html
new file mode 100644
index 0000000..ee9bb64
--- /dev/null
+++ b/projects/templates/projects/index.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block title %}Projects{% endblock %}
+
+{% block content %}
+ {% if project_list %}
+
+ {% else %}
+ No projects.
+ {% endif %}
+{% endblock %}
diff --git a/projects/templates/projects/view.html b/projects/templates/projects/view.html
new file mode 100644
index 0000000..afae756
--- /dev/null
+++ b/projects/templates/projects/view.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+
+{% block title %}Project: {{ project.name }}{% endblock %}
+
+{% block content %}
+ {{ project.project_key }} - {{ project.name }}
+ {{ project.description }}
+
+ Issues
+ {% if issue_list %}
+
+ {% else %}
+ This project has no issues yet.
+ {% endif %}
+{% endblock %}
diff --git a/projects/tests.py b/projects/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/projects/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/projects/urls.py b/projects/urls.py
new file mode 100644
index 0000000..3b0b3b9
--- /dev/null
+++ b/projects/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+
+from . import views
+
+app_name = 'projects'
+urlpatterns = [
+ path('', views.IndexView.as_view(), name='index'),
+ path('/', views.ProjectView.as_view(), name='view'),
+]
diff --git a/projects/views.py b/projects/views.py
new file mode 100644
index 0000000..3886a1e
--- /dev/null
+++ b/projects/views.py
@@ -0,0 +1,22 @@
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.views import generic
+
+from .models import Project
+
+class IndexView(LoginRequiredMixin, generic.ListView):
+ template_name = 'projects/index.html'
+ context_object_name = 'project_list'
+
+ def get_queryset(self):
+ return Project.objects.order_by('project_key')
+
+
+class ProjectView(LoginRequiredMixin, generic.DetailView):
+ model = Project
+ template_name = 'projects/view.html'
+ slug_field = slug_url_kwarg = 'project_key'
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['issue_list'] = self.get_object().issue_set.order_by('create_date')
+ return context
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..d3e4ba5
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+django
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..c54107f
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,12 @@
+
+
+
+ {% block title %}Tofu{% endblock %} - Tofu
+
+
+
+ Tofu
+
+ {% block content %}No content.{% endblock %}
+
+
diff --git a/templates/registration/login.html b/templates/registration/login.html
new file mode 100644
index 0000000..f3d53be
--- /dev/null
+++ b/templates/registration/login.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+
+{% block title %}Login{% endblock %}
+
+{% block content %}
+ Login
+
+
+{% endblock %}
diff --git a/tofu/__init__.py b/tofu/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tofu/settings.py b/tofu/settings.py
new file mode 100644
index 0000000..cc78a18
--- /dev/null
+++ b/tofu/settings.py
@@ -0,0 +1,131 @@
+"""
+Django settings for tofu project.
+
+Generated by 'django-admin startproject' using Django 2.1.7.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/2.1/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/2.1/ref/settings/
+"""
+
+import os
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'p53=*^ls87h6@*b^-d&t!0suv4r5slw=o(%ji!v+ap_%_!lmm2'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'issues.apps.IssuesConfig',
+ 'projects.apps.ProjectsConfig',
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+]
+
+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 = 'tofu.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [
+ os.path.join(BASE_DIR, 'templates'),
+ ],
+ '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 = 'tofu.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/2.1/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/2.1/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'Europe/Berlin'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/2.1/howto/static-files/
+
+STATIC_URL = '/static/'
+
+
+# Other settings
+
+LOGIN_URL = '/account/login/'
+LOGIN_REDIRECT_URL = '/projects/'
+LOGOUT_REDIRECT_URL = '/account/login/'
diff --git a/tofu/urls.py b/tofu/urls.py
new file mode 100644
index 0000000..596418a
--- /dev/null
+++ b/tofu/urls.py
@@ -0,0 +1,29 @@
+"""tofu URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/2.1/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.shortcuts import redirect
+from django.urls import include, path
+
+urlpatterns = [
+ path('account/', include('django.contrib.auth.urls')),
+ path('issues/', include('issues.urls')),
+ path('projects/', include('projects.urls')),
+
+ path('admin/', admin.site.urls),
+
+ # TODO temporarily: just redirect / to /projects/
+ path('', lambda request: redirect('projects/', permanent=False)),
+]
diff --git a/tofu/wsgi.py b/tofu/wsgi.py
new file mode 100644
index 0000000..9c30b16
--- /dev/null
+++ b/tofu/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for tofu 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/2.1/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tofu.settings')
+
+application = get_wsgi_application()