Compare commits
10 commits
471bec87fb
...
f5d9873de7
| Author | SHA1 | Date | |
|---|---|---|---|
| f5d9873de7 | |||
| 47b18c8014 | |||
| a3cbd290a7 | |||
| 8b6da2d7a2 | |||
| d465101913 | |||
| 08bf161f09 | |||
| 41e8bce03c | |||
| abc7d20fff | |||
| a4d0d9491c | |||
| d9109dab41 |
16 changed files with 173 additions and 35 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -55,6 +55,11 @@ class Question(models.Model):
|
||||||
body = models.TextField()
|
body = models.TextField()
|
||||||
member = models.ForeignKey(Member, related_name="questions", on_delete=models.CASCADE)
|
member = models.ForeignKey(Member, related_name="questions", on_delete=models.CASCADE)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
# asker metadata
|
||||||
|
asker_first_name = models.CharField(max_length=150, blank=True)
|
||||||
|
asker_last_name = models.CharField(max_length=150, blank=True)
|
||||||
|
asker_city = models.CharField(max_length=150, blank=True)
|
||||||
|
asker_email = models.EmailField(blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Frage"
|
verbose_name = "Frage"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ urlpatterns = [
|
||||||
path("gemeinden/<slug:slug>/", views.public_body_detail, name="public_body_detail"),
|
path("gemeinden/<slug:slug>/", views.public_body_detail, name="public_body_detail"),
|
||||||
path("mitglieder/", views.members, name="members"),
|
path("mitglieder/", views.members, name="members"),
|
||||||
path("mitglieder/<int:pk>/", views.member_detail, name="member_detail"),
|
path("mitglieder/<int:pk>/", views.member_detail, name="member_detail"),
|
||||||
|
path("mitglieder/<int:pk>/frage/stellen", views.ask_question, name="ask_question"),
|
||||||
path("parteien/", views.parties, name="parties"),
|
path("parteien/", views.parties, name="parties"),
|
||||||
path("parteien/<int:pk>/", views.party_detail, name="party_detail"),
|
path("parteien/<int:pk>/", views.party_detail, name="party_detail"),
|
||||||
path("fragen/", views.questions, name="questions"),
|
path("fragen/", views.questions, name="questions"),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.contrib import messages
|
||||||
from .models import PublicBody, Party, Member, Question, Vote
|
from .models import PublicBody, Party, Member, Question, Vote
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,6 +40,34 @@ def member_detail(request, pk: int):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ask_question(request, pk: int):
|
||||||
|
member = get_object_or_404(Member, pk=pk)
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("member_detail", pk=member.pk)
|
||||||
|
|
||||||
|
title = request.POST.get("title", "").strip()
|
||||||
|
asker_first_name = request.POST.get("asker_first_name", "").strip()
|
||||||
|
asker_last_name = request.POST.get("asker_last_name", "").strip()
|
||||||
|
asker_city = request.POST.get("asker_city", "").strip()
|
||||||
|
asker_email = request.POST.get("asker_email", "").strip()
|
||||||
|
|
||||||
|
if not title or not asker_first_name or not asker_last_name or not asker_city or not asker_email:
|
||||||
|
messages.error(request, "Bitte alle Pflichtfelder ausfüllen.")
|
||||||
|
return redirect("member_detail", pk=member.pk)
|
||||||
|
|
||||||
|
Question.objects.create(
|
||||||
|
title=title,
|
||||||
|
body=title,
|
||||||
|
member=member,
|
||||||
|
asker_first_name=asker_first_name,
|
||||||
|
asker_last_name=asker_last_name,
|
||||||
|
asker_city=asker_city,
|
||||||
|
asker_email=asker_email,
|
||||||
|
)
|
||||||
|
messages.success(request, "Frage wurde eingereicht.")
|
||||||
|
return redirect("member_detail", pk=member.pk)
|
||||||
|
|
||||||
|
|
||||||
def parties(request):
|
def parties(request):
|
||||||
items = Party.objects.all()
|
items = Party.objects.all()
|
||||||
return render(request, "council/parties.html", {"items": items})
|
return render(request, "council/parties.html", {"items": items})
|
||||||
|
|
|
||||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
10
flake.nix
10
flake.nix
|
|
@ -28,18 +28,16 @@
|
||||||
dependencies = with python3Packages; [ django requests jinja2 ];
|
dependencies = with python3Packages; [ django requests jinja2 ];
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out/share
|
mkdir -p $out/share/fragdenrat
|
||||||
# Project code
|
# Project code
|
||||||
cp -r ${./fragdenrat} $out/share/fragdenrat
|
cp -r "${./fragdenrat}/." $out/share/fragdenrat/
|
||||||
|
# Django app: council
|
||||||
|
cp -r ${./council} $out/share/fragdenrat/council
|
||||||
# Django manage helper
|
# Django manage helper
|
||||||
install -Dm755 ${./manage.py} $out/bin/fragdenrat-manage
|
install -Dm755 ${./manage.py} $out/bin/fragdenrat-manage
|
||||||
# Templates and static assets
|
# Templates and static assets
|
||||||
if [ -d ./templates ]; then
|
|
||||||
cp -r ./templates $out/share/fragdenrat/
|
cp -r ./templates $out/share/fragdenrat/
|
||||||
fi
|
|
||||||
if [ -d ./assets ]; then
|
|
||||||
cp -r ./assets $out/share/fragdenrat/
|
cp -r ./assets $out/share/fragdenrat/
|
||||||
fi
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
passthru.pythonPath = python3Packages.makePythonPath dependencies;
|
passthru.pythonPath = python3Packages.makePythonPath dependencies;
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -2,6 +2,7 @@ from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
PROJECT_DIR = Path(__file__).resolve().parent
|
||||||
DATA_DIR = Path(os.environ.get("FRAGDENRAT_DATA_DIR", str(BASE_DIR)))
|
DATA_DIR = Path(os.environ.get("FRAGDENRAT_DATA_DIR", str(BASE_DIR)))
|
||||||
|
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-change-me")
|
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-change-me")
|
||||||
|
|
@ -33,7 +34,10 @@ ROOT_URLCONF = "fragdenrat.urls"
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
"DIRS": [BASE_DIR / "templates"],
|
"DIRS": [
|
||||||
|
BASE_DIR / "templates",
|
||||||
|
PROJECT_DIR / "templates",
|
||||||
|
],
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": True,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
|
|
@ -61,6 +65,9 @@ USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATICFILES_DIRS = [BASE_DIR / "assets"]
|
STATICFILES_DIRS = [
|
||||||
|
BASE_DIR / "assets",
|
||||||
|
PROJECT_DIR / "assets",
|
||||||
|
]
|
||||||
STATIC_ROOT = DATA_DIR / "staticfiles"
|
STATIC_ROOT = DATA_DIR / "staticfiles"
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
path("impressum", TemplateView.as_view(template_name="impressum.html"), name="impressum"),
|
||||||
|
path("datenschutz", TemplateView.as_view(template_name="datenschutz.html"), name="datenschutz"),
|
||||||
path("", include("council.urls")),
|
path("", include("council.urls")),
|
||||||
]
|
]
|
||||||
59
module.nix
59
module.nix
|
|
@ -5,35 +5,64 @@
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
|
|
||||||
cfg = config.services.fragdenrat;
|
cfg = config.services.fragdenrat;
|
||||||
|
dataDir = "/var/lib/fragdenrat";
|
||||||
|
manageCmd = "${pkgs.fragdenrat}/bin/fragdenrat-manage";
|
||||||
|
pythonPath = "${pkgs.fragdenrat}/share:${pkgs.fragdenrat.pythonPath}";
|
||||||
|
envVars = [
|
||||||
|
"PYTHONPATH=${pythonPath}"
|
||||||
|
"DJANGO_SETTINGS_MODULE=fragdenrat.settings"
|
||||||
|
"FRAGDENRAT_DATA_DIR=${dataDir}"
|
||||||
|
];
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|
||||||
options = {
|
options = {
|
||||||
services.fragdenrat = {
|
services.fragdenrat = {
|
||||||
|
|
||||||
enable = lib.mkEnableOption "FragDenRat web app";
|
enable = lib.mkEnableOption "FragDenRat web app";
|
||||||
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
|
|
||||||
|
# Ensure data dir exists with proper perms
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${dataDir} 0750 fragdenrat fragdenrat -"
|
||||||
|
"d ${dataDir}/staticfiles 0750 fragdenrat fragdenrat -"
|
||||||
|
];
|
||||||
|
|
||||||
|
# One-shot setup: migrate DB and collect static into dataDir
|
||||||
|
systemd.services.fragdenrat-setup = {
|
||||||
|
description = "Initialize FragDenRat database and static files";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
User = "fragdenrat";
|
||||||
|
Group = "fragdenrat";
|
||||||
|
WorkingDirectory = dataDir;
|
||||||
|
Environment = envVars;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
${manageCmd} migrate --noinput
|
||||||
|
${manageCmd} collectstatic --noinput
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# uWSGI app
|
||||||
services.uwsgi = {
|
services.uwsgi = {
|
||||||
enable = true;
|
enable = true;
|
||||||
plugins = [ "python3" ];
|
plugins = [ "python3" ];
|
||||||
|
|
||||||
instance = {
|
instance = {
|
||||||
type = "emperor";
|
type = "emperor";
|
||||||
vassals = {
|
vassals = {
|
||||||
fragdenrat = {
|
fragdenrat = {
|
||||||
type = "normal";
|
type = "normal";
|
||||||
chdir = "${pkgs.fragdenrat}/share/fragdenrat";
|
# Use data dir as working directory
|
||||||
|
chdir = dataDir;
|
||||||
|
|
||||||
# Use absolute wsgi entrypoint to avoid module resolution issues
|
# Absolute WSGI entrypoint in Nix store
|
||||||
wsgi-file = "${pkgs.fragdenrat}/share/fragdenrat/fragdenrat/wsgi.py";
|
wsgi-file = "${pkgs.fragdenrat}/share/fragdenrat/wsgi.py";
|
||||||
callable = "application";
|
callable = "application";
|
||||||
|
|
||||||
socket = "${config.services.uwsgi.runDir}/fragdenrat.sock";
|
socket = "${config.services.uwsgi.runDir}/fragdenrat.sock";
|
||||||
|
|
@ -49,15 +78,13 @@ in
|
||||||
need-app = true;
|
need-app = true;
|
||||||
"no-orphans" = true;
|
"no-orphans" = true;
|
||||||
|
|
||||||
env = [
|
env = envVars;
|
||||||
# Ensure python sees the app and dependencies
|
|
||||||
"PYTHONPATH=${pkgs.fragdenrat}/share/fragdenrat:${pkgs.fragdenrat.pythonPath}"
|
|
||||||
"DJANGO_SETTINGS_MODULE=fragdenrat.settings"
|
|
||||||
];
|
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
"static-map" = "/static=${pkgs.fragdenrat}/share/fragdenrat/assets";
|
# Serve collected static files from dataDir
|
||||||
pythonpath = "${pkgs.fragdenrat}/share/fragdenrat";
|
"static-map" = "/static=${dataDir}/staticfiles";
|
||||||
|
# Python import path for the app and its deps
|
||||||
|
pythonpath = "${pkgs.fragdenrat}/share";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
2
result
2
result
|
|
@ -1 +1 @@
|
||||||
/nix/store/wqsr5qqw8lj49hrags3pzairq70fwwb6-fragdenrat-0.1.0
|
/nix/store/jsjajfw1bbm7h28fh8q64zignm6z09yn-fragdenrat-0.1.0
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
.navbar-brand img { height: 36px; }
|
.navbar-brand img { height: 36px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="d-flex flex-column min-vh-100">
|
||||||
<nav class="navbar navbar-expand-lg bg-white border-bottom">
|
<nav class="navbar navbar-expand-lg bg-white border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||||
|
|
@ -35,10 +35,20 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="container py-4">
|
<main class="container py-4 flex-grow-1">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="border-top bg-white py-3 mt-auto">
|
||||||
|
<div class="container d-flex justify-content-between">
|
||||||
|
<span class="text-muted">© {{ now|date:'Y' }} FragDenRat</span>
|
||||||
|
<nav>
|
||||||
|
<a href="{% url 'impressum' %}" class="text-muted me-3">Impressum</a>
|
||||||
|
<a href="{% url 'datenschutz' %}" class="text-muted">Datenschutz</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
|
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,26 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ member }} – Stadträt:in{% endblock %}
|
{% block title %}{{ member }} – Stadträt:in{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
<h2 class="mb-1">{{ member.first_name }} {{ member.last_name }}</h2>
|
<h2 class="mb-1">{{ member.first_name }} {{ member.last_name }}</h2>
|
||||||
<p class="text-muted mb-3">
|
<p class="text-muted mb-3">
|
||||||
{% if member.public_body %}in <a href="{% url 'public_body_detail' slug=member.public_body.slug %}">{{ member.public_body.name }}</a>{% endif %}
|
{% if member.public_body %}in <a href="{% url 'public_body_detail' slug=member.public_body.slug %}">{{ member.public_body.name }}</a>{% endif %}
|
||||||
{% if member.party %} • {{ member.party }} (<a href="{% url 'party_detail' pk=member.party.id %}">Profil</a>){% endif %}
|
{% if member.party %} • {{ member.party }} (<a href="{% url 'party_detail' pk=member.party.id %}">Profil</a>){% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#askQuestionModal">Frage stellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="mt-2">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags|default:'info' }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h3 class="mt-4">Fragen an {{ member.first_name }}</h3>
|
<h3 class="mt-4">Fragen an {{ member.first_name }}</h3>
|
||||||
<ul class="list-group mb-4">
|
<ul class="list-group mb-4">
|
||||||
|
|
@ -33,4 +48,47 @@
|
||||||
<li class="list-group-item">Keine Abstimmungen.</li>
|
<li class="list-group-item">Keine Abstimmungen.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- Ask Question Modal -->
|
||||||
|
<div class="modal fade" id="askQuestionModal" tabindex="-1" aria-labelledby="askQuestionModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="askQuestionModalLabel">Frage an {{ member.first_name }} {{ member.last_name }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{% url 'ask_question' pk=member.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="title" class="form-label">Frage</label>
|
||||||
|
<input type="text" class="form-control" id="title" name="title" required>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="asker_first_name" class="form-label">Vorname</label>
|
||||||
|
<input type="text" class="form-control" id="asker_first_name" name="asker_first_name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="asker_last_name" class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" id="asker_last_name" name="asker_last_name" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 mt-2">
|
||||||
|
<label for="asker_city" class="form-label">Wohnort</label>
|
||||||
|
<input type="text" class="form-control" id="asker_city" name="asker_city" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="asker_email" class="form-label">E-Mail</label>
|
||||||
|
<input type="email" class="form-control" id="asker_email" name="asker_email" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Senden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue