├── vote ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0009_remove_voter_remind_me.py │ ├── 0031_voter_qr.py │ ├── 0011_voter_logged_in.py │ ├── 0016_voter_name.py │ ├── 0014_election_voters_self_apply.py │ ├── 0017_election_send_emails_on_start.py │ ├── 0020_election_remind_text_sent.py │ ├── 0026_voter_invalid_email.py │ ├── 0006_auto_20200530_1836.py │ ├── 0013_auto_20201007_1830.py │ ├── 0021_auto_20201103_1923.py │ ├── 0023_election_disable_abstention.py │ ├── 0025_session_spectator_token.py │ ├── 0030_auto_20220508_1226.py │ ├── 0010_auto_20200601_0020.py │ ├── 0012_auto_20200601_0047.py │ ├── 0024_auto_20201116_2128.py │ ├── 0004_auto_20200529_2250.py │ ├── 0005_auto_20200530_0019.py │ ├── 0008_election_result_published.py │ ├── 0007_auto_20200530_2113.py │ ├── 0003_auto_20200529_2118.py │ ├── 0018_auto_20201025_2159.py │ ├── 0019_auto_20201030_1913.py │ ├── 0022_auto_20201104_1438.py │ ├── 0015_auto_20201011_1826.py │ ├── 0028_auto_20210804_2335.py │ ├── 0029_rename_unpublished_and_disable_abstention.py │ ├── 0027_change_field_type_results_published.py │ ├── 0002_auto_20200529_2104.py │ └── 0001_initial.py ├── static │ ├── img │ │ ├── example.png │ │ ├── favicon16.png │ │ ├── favicon32.png │ │ ├── favicon64.png │ │ ├── logo_inv.png │ │ ├── blank_avatar.png │ │ ├── favicon128.png │ │ ├── apple-touch-icon.png │ │ ├── help_page │ │ │ ├── apply_page.png │ │ │ ├── overview_app.png │ │ │ ├── vote_waiting.png │ │ │ ├── overview_page.png │ │ │ ├── overview_results.png │ │ │ ├── vote_page_bernd.png │ │ │ └── apply_page_numbered.png │ │ └── question-circle.svg │ ├── js │ │ ├── application.js │ │ └── reload.js │ ├── vote │ │ ├── js │ │ │ └── vote.js │ │ └── css │ │ │ └── style.css │ └── bootstrap-4.5.3-dist │ │ └── css │ │ ├── bootstrap-reboot.min.css │ │ └── bootstrap-reboot.css ├── apps.py ├── routing.py ├── templates │ └── vote │ │ ├── ratelimited.html │ │ ├── image_input.html │ │ ├── mails │ │ ├── start.j2 │ │ └── invitation.j2 │ │ ├── login.html │ │ ├── tex │ │ └── invitation.tex │ │ ├── spectator_election_item.html │ │ ├── index.html │ │ ├── spectator.html │ │ ├── application.html │ │ ├── base.html │ │ ├── index_election_item.html │ │ ├── help.md │ │ └── vote.html ├── templatetags │ └── vote_extras.py ├── urls.py ├── management │ └── commands │ │ ├── revoke_code.py │ │ ├── reset_voter.py │ │ ├── create_voter.py │ │ ├── create_session.py │ │ └── create_election.py ├── consumers.py ├── selectors.py ├── admin.py ├── authentication.py ├── tests.py └── forms.py ├── management ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0001_initial.py │ └── 0002_auto_20201007_1636.py ├── apps.py ├── static │ └── management │ │ ├── js │ │ ├── datetime.js │ │ └── session.js │ │ └── css │ │ ├── style.css │ │ └── DateTimePicker.css ├── utils.py ├── routing.py ├── templates │ └── management │ │ ├── image_input.html │ │ ├── results.html │ │ ├── add_voters.html │ │ ├── add_tokens.html │ │ ├── import_csv.html │ │ ├── spectator_settings.html │ │ ├── add_mobile_voter_name.html │ │ ├── login.html │ │ ├── add_mobile_voter_qr.html │ │ ├── session_election_item.html │ │ ├── index.html │ │ ├── application.html │ │ ├── add_session.html │ │ ├── base.html │ │ ├── add_election.html │ │ └── session_settings.html ├── management │ └── commands │ │ ├── process_reminders.py │ │ └── create_admin.py ├── admin.py ├── authentication.py ├── consumers.py ├── urls.py └── models.py ├── wahlfang ├── settings │ ├── __init__.py │ ├── wahlfang.py │ ├── development.py │ └── base.py ├── __init__.py ├── routing.py ├── wsgi.py ├── asgi.py ├── urls.py └── manage.py ├── .bandit ├── .gitignore ├── requirements_dev.txt ├── requirements.txt ├── MANIFEST.in ├── Makefile ├── LICENSE ├── setup.cfg ├── pyproject.toml ├── README.md ├── docs ├── settings.py └── deploying.md └── .gitlab-ci.yml /vote/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vote/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /management/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wahlfang/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wahlfang/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0.7' 2 | -------------------------------------------------------------------------------- /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude: /.venv/,*_test.py,/tests/,/venv/ 3 | skips: B108 4 | -------------------------------------------------------------------------------- /vote/static/img/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/example.png -------------------------------------------------------------------------------- /vote/static/img/favicon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/favicon16.png -------------------------------------------------------------------------------- /vote/static/img/favicon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/favicon32.png -------------------------------------------------------------------------------- /vote/static/img/favicon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/favicon64.png -------------------------------------------------------------------------------- /vote/static/img/logo_inv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/logo_inv.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /venv/ 2 | *.pyc 3 | .idea 4 | *.sqlite3 5 | *.log 6 | /media 7 | /build 8 | /dist 9 | wahlfang.egg-info -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | bandit==1.6.2 2 | pylint==2.8 3 | pylint-django~=2.4.4 4 | django-stubs 5 | build 6 | freezegun -------------------------------------------------------------------------------- /vote/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class VoteConfig(AppConfig): 5 | name = 'vote' 6 | -------------------------------------------------------------------------------- /vote/static/img/blank_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/blank_avatar.png -------------------------------------------------------------------------------- /vote/static/img/favicon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/favicon128.png -------------------------------------------------------------------------------- /vote/static/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/apple-touch-icon.png -------------------------------------------------------------------------------- /management/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ManagementConfig(AppConfig): 5 | name = 'management' 6 | -------------------------------------------------------------------------------- /vote/static/img/help_page/apply_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/help_page/apply_page.png -------------------------------------------------------------------------------- /vote/static/img/help_page/overview_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/help_page/overview_app.png -------------------------------------------------------------------------------- /vote/static/img/help_page/vote_waiting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/help_page/vote_waiting.png -------------------------------------------------------------------------------- /vote/static/img/help_page/overview_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/help_page/overview_page.png -------------------------------------------------------------------------------- /vote/static/img/help_page/overview_results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/help_page/overview_results.png -------------------------------------------------------------------------------- /vote/static/img/help_page/vote_page_bernd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/help_page/vote_page_bernd.png -------------------------------------------------------------------------------- /vote/static/img/help_page/apply_page_numbered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stustanet/wahlfang/HEAD/vote/static/img/help_page/apply_page_numbered.png -------------------------------------------------------------------------------- /management/static/management/js/datetime.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | 3 | $("#dtBox").DateTimePicker({dateTimeFormat: "yyyy-MM-dd HH:mm"}); 4 | 5 | }); -------------------------------------------------------------------------------- /management/static/management/js/session.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $('#downloadlink').click(function () { 3 | $('#downloadToken').modal('hide'); 4 | }); 5 | }); 6 | 7 | $(function () { 8 | $('[data-toggle="tooltip"]').tooltip() 9 | }) 10 | -------------------------------------------------------------------------------- /management/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def is_valid_sender_email(email: str) -> bool: 5 | if not isinstance(email, str) or '@' not in email: 6 | return False 7 | 8 | return email.split('@')[-1] in settings.VALID_MANAGER_EMAIL_DOMAINS 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==3.1.* 2 | argon2-cffi==20.1.* 3 | pillow==7.2.* 4 | django-crispy-forms==1.9.* 5 | django-csp==3.7.* 6 | django-ratelimit==3.0.* 7 | django-auth-ldap==2.2.* 8 | qrcode==6.1 9 | latex==0.7.* 10 | django_prometheus==2.1.* 11 | channels==3.0.* 12 | channels-redis==3.2.* -------------------------------------------------------------------------------- /vote/routing.py: -------------------------------------------------------------------------------- 1 | from channels.routing import URLRouter 2 | from django.urls import path, re_path 3 | from . import consumers 4 | 5 | websocket_urlpatterns = URLRouter([ 6 | path('', consumers.VoteConsumer.as_asgi()), 7 | re_path(r'spectator/(?P.+)$', consumers.VoteConsumer.as_asgi()), 8 | ]) 9 | -------------------------------------------------------------------------------- /wahlfang/settings/wahlfang.py: -------------------------------------------------------------------------------- 1 | 2 | SEND_FROM_MANAGER_EMAIL = True 3 | VALID_MANAGER_EMAIL_DOMAINS = [ 4 | 'stusta.de', 'stustanet.de', 'stusta.mhn.de', 'stusta.net', 'stusta.sexy', 'stusta.party', 'stusta.io' 5 | ] 6 | 7 | # Base URL for template links (without 'https://') 8 | URL = 'vote.stustanet.de' 9 | -------------------------------------------------------------------------------- /wahlfang/routing.py: -------------------------------------------------------------------------------- 1 | from channels.routing import URLRouter 2 | from django.urls import path 3 | 4 | import management.routing 5 | import vote.routing 6 | 7 | websocket_urlpatterns = URLRouter([ 8 | path('', vote.routing.websocket_urlpatterns), 9 | path('management/', management.routing.websocket_urlpatterns), 10 | ]) 11 | -------------------------------------------------------------------------------- /vote/templates/vote/ratelimited.html: -------------------------------------------------------------------------------- 1 | {% extends 'vote/base.html' %} 2 | 3 | {% block content %} 4 |
5 |
6 | 429 7 |
Too many attempts! Try again in 1 hour.
8 |
9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /vote/templatetags/vote_extras.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from django import template 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.simple_tag 9 | def applicant_name(application): 10 | return application.get_display_name() 11 | 12 | 13 | @register.filter 14 | def shuffle(items): 15 | items = list(items)[:] 16 | random.shuffle(items) 17 | return items 18 | -------------------------------------------------------------------------------- /vote/static/js/application.js: -------------------------------------------------------------------------------- 1 | document.querySelector('input#id_avatar').onchange = function() { 2 | var input = this; 3 | if (input.files && input.files[0]) { 4 | var reader = new FileReader(); 5 | reader.onload = function (e) { 6 | input.parentElement.querySelector('.img-wrapper img').src = e.target.result; 7 | } 8 | reader.readAsDataURL(input.files[0]); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /management/routing.py: -------------------------------------------------------------------------------- 1 | from channels.routing import URLRouter 2 | from django.urls import re_path 3 | from . import consumers 4 | 5 | websocket_urlpatterns = URLRouter([ 6 | re_path(r'election/(?P\d+)$', consumers.ElectionConsumer.as_asgi()), 7 | re_path(r'meeting/(?P\d+)$', consumers.SessionConsumer.as_asgi()), 8 | re_path(r'meeting/(?P\d+)/add_mobile_voter$', consumers.AddMobileConsumer.as_asgi()), 9 | ]) 10 | -------------------------------------------------------------------------------- /wahlfang/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for wahlfang project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | from django.core.wsgi import get_wsgi_application 11 | 12 | from wahlfang.manage import setup 13 | 14 | setup() 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /vote/migrations/0009_remove_voter_remind_me.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-05-31 21:31 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0008_election_result_published'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='voter', 15 | name='remind_me', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | recursive-include vote *.py *.html *.js *.css *.png *.svg *.po *.txt *.tex 3 | graft vote/static 4 | graft vote/templates 5 | graft vote/static 6 | graft vote/templatetags 7 | graft vote/management 8 | recursive-include management *.py *.html *.js *.css *.png *.svg *.po *.txt *.tex 9 | graft management/static 10 | graft management/templates 11 | graft management/static 12 | graft management/templatetags 13 | graft management/management 14 | -------------------------------------------------------------------------------- /vote/migrations/0031_voter_qr.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-05-20 21:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0030_auto_20220508_1226'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='voter', 15 | name='qr', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vote/migrations/0011_voter_logged_in.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-05-31 22:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0010_auto_20200601_0020'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='voter', 15 | name='logged_in', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vote/migrations/0016_voter_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-12 19:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('vote', '0015_auto_20201011_1826'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='voter', 14 | name='name', 15 | field=models.CharField(blank=True, max_length=256, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /vote/migrations/0014_election_voters_self_apply.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-11 13:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('vote', '0013_auto_20201007_1830'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='election', 14 | name='voters_self_apply', 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /vote/migrations/0017_election_send_emails_on_start.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-21 20:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('vote', '0016_voter_name'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='election', 14 | name='send_emails_on_start', 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /vote/migrations/0020_election_remind_text_sent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-11-02 17:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('vote', '0019_auto_20201030_1913'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='election', 14 | name='remind_text_sent', 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /vote/migrations/0026_voter_invalid_email.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-01 16:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0025_session_spectator_token'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='voter', 15 | name='invalid_email', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint 2 | lint: pylint mypy bandit 3 | 4 | .PHONY: pylint 5 | pylint: 6 | pylint ./**/*.py 7 | 8 | .PHONY: mypy 9 | mypy: 10 | mypy --ignore-missing-imports . 11 | 12 | .PHONY: bandit 13 | bandit: 14 | bandit -r . 15 | 16 | .PHONY: test 17 | test: 18 | # a bit hacky due to the manage.py script now living in the main module but it werks, meh ... 19 | PYTHONPATH="${PYTHONPATH}:$(pwd)" WAHLFANG_DEBUG=True python3 wahlfang/manage.py test 20 | 21 | .PHONY: package 22 | package: 23 | python3 -m build -------------------------------------------------------------------------------- /vote/migrations/0006_auto_20200530_1836.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-30 16:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0005_auto_20200530_0019'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='election', 15 | name='max_votes_yes', 16 | field=models.IntegerField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vote/migrations/0013_auto_20201007_1830.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-10-07 16:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0012_auto_20200601_0047'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='voter', 15 | name='email', 16 | field=models.EmailField(blank=True, max_length=254, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vote/migrations/0021_auto_20201103_1923.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-11-03 18:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0020_election_remind_text_sent'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='session', 15 | name='start_date', 16 | field=models.DateTimeField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vote/migrations/0023_election_disable_abstention.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-11-11 13:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0022_auto_20201104_1438'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='election', 15 | name='disable_abstention', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vote/migrations/0025_session_spectator_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-08 21:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('vote', '0024_auto_20201116_2128'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='session', 14 | name='spectator_token', 15 | field=models.UUIDField(blank=True, null=True, unique=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /vote/migrations/0030_auto_20220508_1226.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-05-08 10:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0029_rename_unpublished_and_disable_abstention'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='election', 15 | name='enable_abstention', 16 | field=models.BooleanField(default=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vote/migrations/0010_auto_20200601_0020.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-05-31 22:20 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0009_remove_voter_remind_me'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='voter', 15 | name='first_name', 16 | ), 17 | migrations.RemoveField( 18 | model_name='voter', 19 | name='last_name', 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /vote/migrations/0012_auto_20200601_0047.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-05-31 22:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0011_voter_logged_in'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='election', 15 | name='result_published', 16 | field=models.CharField(choices=[('0', 'unpublished'), ('1', 'fully published')], default='0', max_length=1), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vote/migrations/0024_auto_20201116_2128.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-11-16 20:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('vote', '0023_election_disable_abstention'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='election', 14 | name='result_published', 15 | field=models.CharField(choices=[('0', 'unpublished'), ('1', 'published')], default='0', max_length=1), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /vote/migrations/0004_auto_20200529_2250.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-05-29 20:50 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('vote', '0003_auto_20200529_2118'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='election', 16 | name='session', 17 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='elections', to='vote.Session'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /vote/migrations/0005_auto_20200530_0019.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-29 22:19 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('vote', '0004_auto_20200529_2250'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='openvote', 16 | name='voter', 17 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='open_votes', to='vote.Voter'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /vote/migrations/0008_election_result_published.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-31 13:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0007_auto_20200530_2113'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='election', 15 | name='result_published', 16 | field=models.CharField(choices=[('0', 'unpublished'), ('1', 'only winners published'), ('2', 'fully published')], default='0', max_length=1), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vote/migrations/0007_auto_20200530_2113.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-30 19:13 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0006_auto_20200530_1836'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='application', 15 | old_name='first_name', 16 | new_name='display_name', 17 | ), 18 | migrations.RemoveField( 19 | model_name='application', 20 | name='last_name', 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /vote/migrations/0003_auto_20200529_2118.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-05-29 19:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0002_auto_20200529_2104'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='application', 15 | name='room', 16 | ), 17 | migrations.AlterField( 18 | model_name='application', 19 | name='email', 20 | field=models.EmailField(blank=True, max_length=254, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /vote/migrations/0018_auto_20201025_2159.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-25 20:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('vote', '0017_election_send_emails_on_start'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='election', 14 | name='remind_text', 15 | field=models.TextField(blank=True, max_length=1000), 16 | ), 17 | migrations.AddField( 18 | model_name='session', 19 | name='invite_text', 20 | field=models.TextField(blank=True, max_length=1000), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /vote/migrations/0019_auto_20201030_1913.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-30 18:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('vote', '0018_auto_20201025_2159'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='election', 14 | name='remind_text', 15 | field=models.TextField(blank=True, max_length=1000, null=True), 16 | ), 17 | migrations.AlterField( 18 | model_name='session', 19 | name='invite_text', 20 | field=models.TextField(blank=True, max_length=1000, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /vote/migrations/0022_auto_20201104_1438.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-11-04 13:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vote', '0021_auto_20201103_1923'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='election', 15 | name='remind_text', 16 | field=models.TextField(blank=True, max_length=8000, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='session', 20 | name='invite_text', 21 | field=models.TextField(blank=True, max_length=8000, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /vote/migrations/0015_auto_20201011_1826.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-11 16:26 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('vote', '0014_election_voters_self_apply'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='application', 15 | name='voter', 16 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, 17 | related_name='application', to='vote.voter'), 18 | ), 19 | migrations.AlterUniqueTogether( 20 | name='application', 21 | unique_together={('voter', 'election')}, 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /wahlfang/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for wahlfang project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | from django.core.asgi import get_asgi_application 11 | 12 | from wahlfang.manage import setup 13 | 14 | setup() 15 | django_asgi_application = get_asgi_application() 16 | 17 | from channels.auth import AuthMiddlewareStack # pylint: disable=wrong-import-order 18 | from channels.routing import ProtocolTypeRouter # pylint: disable=wrong-import-order 19 | import wahlfang.routing # pylint: disable=wrong-import-order 20 | 21 | application = ProtocolTypeRouter({ 22 | "https": django_asgi_application, 23 | "websocket": AuthMiddlewareStack(wahlfang.routing.websocket_urlpatterns), 24 | }) 25 | -------------------------------------------------------------------------------- /vote/templates/vote/image_input.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
3 | {% if widget.is_initial %} 4 |
5 | applicant-picture 6 |
7 | {% if not widget.required %} 8 | 9 | 10 | {% endif %}
11 | {% else %} 12 |
13 | applicant-picture 14 |
15 | {% endif %} 16 |
17 | (about 100x100 px) 18 |
19 | -------------------------------------------------------------------------------- /management/templates/management/image_input.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
3 | {% if widget.is_initial %} 4 |
5 | applicant-picture 6 |
7 | {% if not widget.required %} 8 | 9 | 10 | {% endif %}
11 | {% else %} 12 |
13 | applicant-picture 14 |
15 | {% endif %} 16 |
17 | (about 100x100 px) 18 |
19 | -------------------------------------------------------------------------------- /vote/templates/vote/mails/start.j2: -------------------------------------------------------------------------------- 1 | --- 2 | English version below. 3 | --- 4 | 5 | --- ERINNERUNG --- 6 | 7 | Hallo{% if voter.name %} {{ voter.name }} {% endif %}, 8 | 9 | die Abstimmungsphase von "{{ election.title }}" hat nun begonnen und ihr könnt ab sofort abstimmen. 10 | Die Abstimmung ist möglich bis zum {{ election.end_date|date:"d.m.Y" }} um {{ election.end_date|date:"H:i" }} unter: 11 | {{ url }} (Persönlicher Zugangscode befindet sich in der ersten E-Mail). 12 | 13 | --- 14 | English version 15 | --- 16 | 17 | --- REMINDER --- 18 | 19 | Hi, 20 | 21 | The election phase of "{{ election.title }}" has begun and you can now vote. 22 | The election is possible until {{ election.end_date|date:"Y/m/d" }} at 23 | {{ election.end_date|date:"h:i A" }} via the following link: 24 | {{ url }} (personal access code can be found in the first email). 25 | -------------------------------------------------------------------------------- /vote/migrations/0028_auto_20210804_2335.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-08-04 21:35 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('vote', '0027_change_field_type_results_published'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='application', 16 | name='election', 17 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='vote.election'), 18 | ), 19 | migrations.AlterField( 20 | model_name='application', 21 | name='voter', 22 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='vote.voter'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /management/templates/management/results.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% if not election.disable_abstention %} 10 | 11 | {% endif %} 12 | 13 | 14 | 15 | {% for application in election.election_summary %} 16 | 17 | 18 | 19 | 20 | 21 | {% if not election.disable_abstention %} 22 | 23 | {% endif %} 24 | 25 | {% endfor %} 26 | 27 |
#{% if election.voters_self_apply %}Applicant{% else %}Option{% endif %}YesNoAbstention
{{ forloop.counter }}{{ application.get_display_name }}{{ application.votes_accept }}{{ application.votes_reject }}{{ application.votes_abstention }}
28 | -------------------------------------------------------------------------------- /management/management/commands/process_reminders.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.utils import timezone 3 | 4 | from vote.models import Election 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Process all elections and send remind emails to the voters when the elction has started' 9 | 10 | def add_arguments(self, parser): 11 | pass 12 | 13 | def handle(self, *args, **options): 14 | for election in Election.objects.all(): 15 | if election.start_date is None or election.remind_text_sent or not election.send_emails_on_start or \ 16 | timezone.now() < election.start_date: 17 | continue 18 | election.remind_text_sent = True 19 | election.save() 20 | for voter in election.session.participants.all(): 21 | voter.send_reminder(election.session.managers.all().first().sender_email, election) 22 | -------------------------------------------------------------------------------- /vote/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import views as auth_views 2 | from django.urls import path 3 | from django.views.generic.base import RedirectView 4 | 5 | from vote import views 6 | 7 | app_name = 'vote' 8 | 9 | urlpatterns = [ 10 | path('', views.index, name='index'), 11 | path('vote/', views.vote, name='vote'), 12 | 13 | # code login 14 | path('code', views.LoginView.as_view(), name='code_login'), 15 | path('code/', RedirectView.as_view(pattern_name='vote:code_login')), 16 | path('code/', views.code_login, name='link_login'), 17 | path('logout', auth_views.LogoutView.as_view( 18 | next_page='vote:index', 19 | ), name='logout'), 20 | path('vote//apply', views.apply, name='apply'), 21 | path('vote//delete-own-application', views.delete_own_application, name='delete_own_application'), 22 | path('help', views.help_page, name='help'), 23 | path('spectator/', views.spectator, name='spectator') 24 | ] 25 | -------------------------------------------------------------------------------- /management/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-29 12:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ('vote', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='ElectionManager', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('password', models.CharField(max_length=128, verbose_name='password')), 20 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 21 | ('email', models.EmailField(max_length=254, unique=True)), 22 | ('sessions', models.ManyToManyField(blank=True, related_name='managers', to='vote.Session')), 23 | ], 24 | options={ 25 | 'abstract': False, 26 | }, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /vote/migrations/0029_rename_unpublished_and_disable_abstention.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-08-06 08:40 2 | 3 | from django.db import migrations 4 | from django.db.models import Q 5 | 6 | 7 | def update_values(apps, schema_editor): 8 | election = apps.get_model('vote', 'election') 9 | election.objects.update(result_published=Q(result_published=False)) 10 | election.objects.update(enable_abstention=Q(enable_abstention=False)) 11 | 12 | 13 | class Migration(migrations.Migration): 14 | dependencies = [ 15 | ('vote', '0028_auto_20210804_2335'), 16 | ] 17 | 18 | operations = [ 19 | migrations.RenameField( 20 | model_name='election', 21 | old_name='result_unpublished', 22 | new_name='result_published', 23 | ), 24 | migrations.RenameField( 25 | model_name='election', 26 | old_name='disable_abstention', 27 | new_name='enable_abstention', 28 | ), 29 | migrations.RunPython(update_values, reverse_code=update_values), 30 | ] 31 | -------------------------------------------------------------------------------- /management/templates/management/add_voters.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | {% load crispy_forms_filters %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Add Voters

11 | Enter one email address per line in the field below.
12 | These emails will receive an access code for this session after clicking "Submit". 13 |
14 |
15 |
16 | {% csrf_token %} 17 | {{ form|crispy }} 18 | 19 | Cancel 21 |
22 |
23 |
24 |
25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /vote/management/commands/revoke_code.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from vote.models import Voter 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Revoke the given access code' 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument('--access_code', type=str, required=True) 11 | 12 | def handle(self, *args, **options): 13 | access_code = options['access_code'] 14 | voter_id, password = Voter.split_access_code(access_code) 15 | if not voter_id: 16 | raise CommandError("voter not found") 17 | 18 | voter = Voter.objects.get(pk=voter_id) 19 | 20 | if not voter.check_password(password): 21 | raise CommandError("incorrect access_code") 22 | 23 | voter.set_unusable_password() 24 | voter.save() 25 | 26 | if voter.has_usable_password(): 27 | raise CommandError("unsetting password failed") 28 | 29 | self.stdout.write( 30 | self.style.SUCCESS('Successfully revoked access for "%s"\nAccess Code: %s' % (voter, access_code))) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 StuStaNet e. V. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /vote/consumers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from channels.generic.websocket import AsyncWebsocketConsumer 4 | from channels.db import database_sync_to_async 5 | 6 | from vote.models import Session 7 | 8 | 9 | class VoteConsumer(AsyncWebsocketConsumer): 10 | 11 | async def connect(self): 12 | self.group = await database_sync_to_async(self.get_session_key)() # pylint: disable=W0201 13 | await self.channel_layer.group_add(self.group, self.channel_name) 14 | await self.accept() 15 | 16 | async def disconnect(self, code): 17 | await self.channel_layer.group_discard(self.group, self.channel_name) 18 | 19 | async def send_reload(self, event): 20 | await self.send(text_data=json.dumps({ 21 | 'reload': event['id'], 22 | })) 23 | 24 | def get_session_key(self): 25 | if 'uuid' in self.scope['url_route']['kwargs']: 26 | uuid = self.scope['url_route']['kwargs']['uuid'] 27 | session = Session.objects.get(spectator_token=uuid) 28 | else: 29 | session = self.scope['user'].session 30 | return "Session-" + str(session.pk) 31 | -------------------------------------------------------------------------------- /vote/management/commands/reset_voter.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from vote.models import Voter 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Reset Voter and resend invitation' 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument('--voter_id', type=int, required=True) 11 | parser.add_argument('--email', type=str) 12 | parser.add_argument('--send-invitation', type=bool, default=True) 13 | 14 | def handle(self, *args, **options): 15 | voter_id = options['voter_id'] 16 | voter = Voter.objects.get(pk=voter_id) 17 | password = voter.set_password() 18 | 19 | email = options['email'] 20 | if email: 21 | voter.email = email 22 | self.stdout.write(self.style.SUCCESS('New email: "%s"' % email)) 23 | 24 | voter.save() 25 | 26 | access_code = Voter.get_access_code(voter_id, password) 27 | if options['send_invitation']: 28 | voter.send_invitation(access_code, voter.session.managers.all().first().sender_email) 29 | self.stdout.write(self.style.SUCCESS('Successfully reset voter "%s"\nAccess Code: %s' % (voter, access_code))) 30 | -------------------------------------------------------------------------------- /vote/selectors.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.utils import timezone 3 | 4 | from vote.models import Election, Session 5 | 6 | 7 | def upcoming_elections(session: Session): 8 | return Election.objects.filter(session=session).filter( 9 | Q(start_date__gt=timezone.now()) | Q(start_date__isnull=True) 10 | ).order_by('start_date') 11 | 12 | 13 | def open_elections(session: Session): 14 | return Election.objects.filter(session=session).filter( 15 | Q(start_date__isnull=False, end_date__isnull=False, start_date__lte=timezone.now(), end_date__gt=timezone.now()) 16 | | Q(start_date__isnull=False, end_date__isnull=True, start_date__lte=timezone.now()) 17 | ).order_by('-start_date') 18 | 19 | 20 | def _closed_elections(session: Session): 21 | return Election.objects.filter(session=session).filter( 22 | Q(end_date__lte=timezone.now(), end_date__isnull=False) 23 | ).order_by('-start_date') 24 | 25 | 26 | def published_elections(session: Session): 27 | return _closed_elections(session).filter(result_published=True) 28 | 29 | 30 | def closed_elections(session: Session): 31 | return _closed_elections(session).filter(result_published=False) 32 | -------------------------------------------------------------------------------- /management/migrations/0002_auto_20201007_1636.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-07 14:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def copy_house_id(apps, schema_editor): 7 | ElectionManager = apps.get_model('management', 'ElectionManager') 8 | for e in ElectionManager.objects.all(): 9 | e.username = e.email 10 | e.save() 11 | 12 | 13 | class Migration(migrations.Migration): 14 | 15 | dependencies = [ 16 | ('management', '0001_initial'), 17 | ] 18 | 19 | operations = [ 20 | migrations.AddField( 21 | model_name='electionmanager', 22 | name='username', 23 | field=models.CharField(null=True, max_length=255), 24 | ), 25 | migrations.RunPython(code=copy_house_id, reverse_code=migrations.RunPython.noop), 26 | migrations.AlterField( 27 | model_name='electionmanager', 28 | name='username', 29 | field=models.CharField(max_length=255, unique=True), 30 | ), 31 | migrations.AlterField( 32 | model_name='electionmanager', 33 | name='email', 34 | field=models.EmailField(blank=True, max_length=254, null=True), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /wahlfang/settings/development.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from wahlfang.settings.base import * 5 | from wahlfang.settings.wahlfang import * 6 | 7 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 8 | 9 | # SECURITY WARNING: keep the secret key used in production secret! 10 | SECRET_KEY = '$rl7hy0b_$*7py@t0-!^%gdlqdv0f%1+h2s%rza@=2h#1$y1vw' 11 | 12 | DEBUG = True 13 | # will output to your console 14 | logging.basicConfig( 15 | level=logging.DEBUG, 16 | format='%(asctime)s %(levelname)s %(message)s', 17 | ) 18 | 19 | DATABASES = { 20 | 'default': { 21 | 'ENGINE': 'django.db.backends.sqlite3', 22 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 23 | } 24 | } 25 | 26 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 27 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media') 28 | 29 | # Mail 30 | EMAIL_HOST = 'mail.stusta.de' 31 | EMAIL_SENDER = 'no-reply@stusta.de' 32 | EMAIL_PORT = 25 33 | 34 | # LDAP 35 | AUTH_LDAP_SERVER_URI = "ldap://ldap.stusta.de" 36 | AUTH_LDAP_USER_DN_TEMPLATE = "cn=%(user)s,ou=account,ou=pnyx,dc=stusta,dc=mhn,dc=de" 37 | AUTH_LDAP_START_TLS = True 38 | AUTH_LDAP_USER_ATTR_MAP = {'email': 'mail'} 39 | AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = True 40 | -------------------------------------------------------------------------------- /vote/management/commands/create_voter.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import BaseCommand 3 | 4 | from vote.models import Session, Voter 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Creates a new Voter' 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument('--session-id', type=int, required=True) 12 | parser.add_argument('--no-invitation', default=False, action='store_true') 13 | 14 | # Make things a little bit easier for dev and debugging convenience 15 | if settings.DEBUG: 16 | parser.add_argument('--email', type=str, default="spam@spam.spam") 17 | else: 18 | parser.add_argument('--email', type=str, required=True) 19 | 20 | def handle(self, *args, **options): 21 | session = Session.objects.get(pk=options['session_id']) 22 | 23 | voter, access_code = Voter.from_data( 24 | email=options['email'], 25 | session=session, 26 | ) 27 | if not options['no_invitation']: 28 | voter.send_invitation(access_code, session.managers.all().first().sender_email) 29 | self.stdout.write(self.style.SUCCESS('Successfully created voter "%s"\nAccess Code: %s' % (voter, access_code))) 30 | -------------------------------------------------------------------------------- /vote/migrations/0027_change_field_type_results_published.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2021-06-18 13:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def char2bool(apps, schema_editor): 7 | election = apps.get_model('vote', 'election') 8 | for row in election.objects.all(): 9 | row.result_unpublished = not bool(int(row.result_published)) 10 | row.save(update_fields=['result_unpublished']) 11 | 12 | 13 | def bool2char(apps, schema_editor): 14 | election = apps.get_model('vote', 'election') 15 | for row in election.objects.all(): 16 | row.result_published = str(int(not row.result_unpublished)) 17 | row.save(update_fields=['result_published']) 18 | 19 | 20 | class Migration(migrations.Migration): 21 | dependencies = [ 22 | ('vote', '0026_voter_invalid_email'), 23 | ] 24 | 25 | operations = [ 26 | migrations.AddField( 27 | model_name='election', 28 | name='result_unpublished', 29 | field=models.BooleanField(default=True), 30 | ), 31 | migrations.RunPython(char2bool, reverse_code=bool2char), 32 | migrations.RemoveField( 33 | model_name='election', 34 | name='result_published' 35 | ) 36 | ] 37 | -------------------------------------------------------------------------------- /management/templates/management/add_tokens.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | {% load crispy_forms_filters %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Add Voters

11 | Enter the amount of tokens that you want to generate and click "Submit". Tokens can then be 12 | downloaded in form of a PDF file in the session view.
Caution: The voters generated purely by 13 | token are anonymous. Also note that new tokens will be generated every time you download the PDF file, 14 | invalidating any previously generated PDF. 15 |
16 |
17 |
18 | {% csrf_token %} 19 | {{ form|crispy }} 20 | 21 | Cancel 23 |
24 |
25 |
26 |
27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = wahlfang 3 | author = StuStaNet e. V. 4 | version = attr: wahlfang.__version__ 5 | author_email = admins@stustanet.de 6 | description = Wahlfang - a simple, feature complete online voting platform. 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | license = MIT 10 | url = https://gitlab.stusta.de/stustanet/wahlfang 11 | classifiers = 12 | Operating System :: OS Independent 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: MIT License 15 | Topic :: Internet :: WWW/HTTP :: WSGI :: Application 16 | Development Status :: 4 - Beta 17 | Framework :: Django 18 | 19 | [options] 20 | python_requires = >=3.6 21 | setup_requires = 22 | setuptools 23 | install_requires = 24 | Django~=3.2 25 | django-crispy-forms~=1.11 26 | django-csp~=3.7 27 | django-ratelimit~=3.0 28 | pillow~=8.2 29 | argon2-cffi~=20.1 30 | django-auth-ldap~=2.4 31 | qrcode~=6.1 32 | latex~=0.7 33 | django_prometheus~=2.1 34 | channels~=3.0 35 | channels-redis~=3.2 36 | djangorestframework~=3.12 37 | djangorestframework-simplejwt~=4.7 38 | packages = find: 39 | include_package_data = True 40 | zip_safe = False 41 | 42 | [options.entry_points] 43 | console_scripts = 44 | wahlfang = wahlfang.manage:main 45 | -------------------------------------------------------------------------------- /management/templates/management/import_csv.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | {% load crispy_forms_filters %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Import Voters from CSV File

11 | This page can be used to add voters via a CSV file. The file needs to have two columns email and name. 12 | The content of an exemplary CSV file is shown below:

13 |

email,name
erika.musterfrau@stusta.de,Erika Musterfrau
14 | max.mustermann@stusta.de,Max Mustermann

15 |
16 |
17 |
19 | {% csrf_token %} 20 | {{ form|crispy }} 21 | 22 | Cancel 24 |
25 |
26 |
27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /management/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | from django.contrib.auth.forms import UserCreationForm, UserChangeForm 4 | 5 | from management.models import ElectionManager 6 | 7 | 8 | class ElectionManagerCreateForm(UserCreationForm): 9 | class Meta: 10 | model = ElectionManager 11 | fields = ('username', 'email', 'password') 12 | field_classes = {} 13 | 14 | 15 | class ElectionManagerChangeForm(UserChangeForm): 16 | class Meta: 17 | model = ElectionManager 18 | fields = ('username', 'email',) 19 | field_classes = {} 20 | 21 | 22 | class ElectionManagerAdmin(UserAdmin): 23 | add_form = ElectionManagerCreateForm 24 | form = ElectionManagerChangeForm 25 | model = ElectionManager 26 | fieldsets = ( 27 | (None, {'fields': ('username', 'email', 'password')}), 28 | ) 29 | add_fieldsets = ( 30 | (None, { 31 | 'classes': ('wide',), 32 | 'fields': ('username', 'email', 'password1', 'password2')} 33 | ), 34 | ) 35 | list_display = ('username', 'email',) 36 | list_filter = ('username', 'email',) 37 | ordering = ('username', 'email',) 38 | filter_horizontal = tuple() 39 | 40 | 41 | admin.site.register(ElectionManager, ElectionManagerAdmin) 42 | -------------------------------------------------------------------------------- /vote/migrations/0002_auto_20200529_2104.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-05-29 19:04 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('vote', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='voter', 16 | name='first_name', 17 | field=models.CharField(blank=True, max_length=128, null=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='voter', 21 | name='last_name', 22 | field=models.CharField(blank=True, max_length=128, null=True), 23 | ), 24 | migrations.AlterField( 25 | model_name='voter', 26 | name='session', 27 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='vote.Session'), 28 | ), 29 | migrations.AlterField( 30 | model_name='voter', 31 | name='voter_id', 32 | field=models.AutoField(primary_key=True, serialize=False), 33 | ), 34 | migrations.AlterUniqueTogether( 35 | name='voter', 36 | unique_together={('session', 'email')}, 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /vote/management/commands/create_session.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import BaseCommand 3 | from django.utils import timezone 4 | 5 | from vote.models import Session 6 | 7 | 8 | class Command(BaseCommand): 9 | help = 'Creates a new election' 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument('-t', '--title', type=str, required=True) 13 | 14 | # Make things a little bit easier for dev and debugging convenience 15 | if settings.DEBUG: 16 | parser.add_argument('-s', '--start-date', type=str, default=timezone.now()) 17 | parser.add_argument('-l', '--meeting-link', type=str, default="http://meeting.link") 18 | parser.add_argument('-id', type=int, default=0) 19 | else: 20 | parser.add_argument('-s', '--start-date', type=str, required=True) 21 | parser.add_argument('-l', '--meeting-link', type=str, required=True) 22 | 23 | def handle(self, *args, **options): 24 | session = Session.objects.create( 25 | id=options['id'], 26 | title=options['title'], 27 | start_date=options['start_date'], 28 | meeting_link=options['meeting_link'], 29 | ) 30 | self.stdout.write(self.style.SUCCESS('Successfully created session "%s" with ID %i' % (session, session.id))) 31 | -------------------------------------------------------------------------------- /wahlfang/urls.py: -------------------------------------------------------------------------------- 1 | """wahlfang URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.urls import path, include 19 | 20 | urlpatterns = [ 21 | path('', include('vote.urls', namespace='vote')), 22 | path('management/', include('management.urls', namespace='management')), 23 | 24 | # prometheus metrics 25 | path('', include('django_prometheus.urls')), 26 | ] 27 | 28 | if settings.DEBUG: 29 | from django.contrib import admin 30 | 31 | urlpatterns += [path('admin/', admin.site.urls)] 32 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 33 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 34 | -------------------------------------------------------------------------------- /vote/templates/vote/mails/invitation.j2: -------------------------------------------------------------------------------- 1 | --- 2 | English version below. 3 | --- 4 | 5 | Hallo{% if voter.name %} {{ voter.name }}{% endif %}, 6 | 7 | du wurdest zur Versammlung "{{ session.title }}" eingeladen. Mit dem folgenden Link kannst du an den Abstimmungen teilnehmen: 8 | {{ login_url }} 9 | 10 | Alternativ kannst du dich auch mit dem Zugangscode "{{ access_code }}" auf {{ base_url }} einloggen. 11 | 12 | {% if session.meeting_link %}Am Meeting kannst du unter 13 | {{ session.meeting_link }} teilnehmen.{% endif %} 14 | {% if session.start_date %}Es beginnt am {{ session.start_date|date:"d.m.Y" }} um {{ session.start_date|date:"H:i" }} 15 | Uhr.{% endif %} 16 | 17 | --- 18 | English version 19 | --- 20 | 21 | Hello{% if voter.name %} {{ voter.name }}{% endif %}, 22 | 23 | You were invited to the meeting "{{ session.title }}". To participate in the elections, click the following link: 24 | {{ login_url }} 25 | 26 | Alternatively, you can also login at {{ base_url }} with the access code "{{ access_code }}". 27 | 28 | {% if session.meeting_link %}You can take part in the meeting via 29 | {{ session.meeting_link }}.{% endif %} 30 | {% if session.start_date %}It starts on {{ session.start_date|date:"Y/m/d" }} at {{ session.start_date|date:"h:i A" }} 31 | .{% endif %} 32 | -------------------------------------------------------------------------------- /vote/static/vote/js/vote.js: -------------------------------------------------------------------------------- 1 | document.querySelectorAll(".vote-list input[type='radio']").forEach(function(input) { 2 | input.onchange = function() { 3 | var voteList = document.querySelector('.vote-list'); 4 | var max = voteList.dataset.maxVotesYes; 5 | var count = voteList.querySelectorAll("input[type='radio'][value='accept']:checked").length; 6 | if(count >= max) { 7 | voteList.querySelectorAll("input[type='radio'][value='accept']:not(:checked)").forEach(function(input) { 8 | input.disabled = true; 9 | }); 10 | if(count > max){ 11 | this.checked = false; 12 | } 13 | } else { 14 | voteList.querySelectorAll("input[type='radio'][value='accept']").forEach(function(input) { 15 | input.disabled = (count >= max); 16 | }); 17 | } 18 | voteList.querySelector("tfoot .yes").innerText = (max-count) + " remaining"; 19 | } 20 | }); 21 | 22 | document.querySelectorAll("#all-yes").forEach(function(btn) { 23 | btn.parentNode.classList.remove('d-none'); 24 | btn.onclick = function() { 25 | var voteList = document.querySelector('.vote-list'); 26 | voteList.querySelectorAll("input[type='radio'][value='accept']").forEach(function(input) { 27 | input.checked = true; 28 | input.onchange(); 29 | }); 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /management/templates/management/spectator_settings.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Spectator Settings

11 | Back 12 |
13 |
14 | {% if token_url %} 15 |

Public spectator URL:

16 |

{{ token_url }}

17 |
18 | {% csrf_token %} 19 | 20 | 21 |
22 | {% else %} 23 |

No public spectator access has been created yet.

24 |
25 | {% csrf_token %} 26 | 27 | 28 |
29 | {% endif %} 30 |
31 |
32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | 'setuptools>=42', 4 | 'wheel' 5 | ] 6 | build-backend = 'setuptools.build_meta' 7 | 8 | [tool.pylint] 9 | [tool.pylint.master] 10 | ignore = ['migrations', 'settings.py'] 11 | jobs = 1 12 | load-plugins = 'pylint_django' 13 | django-settings-module = 'wahlfang.settings.development' 14 | 15 | [tool.pylint.'MESSAGES CONTROL'] 16 | disable = [ 17 | 'print-statement', 18 | 'missing-function-docstring', 19 | 'missing-module-docstring', 20 | 'missing-class-docstring', 21 | 'line-too-long', 22 | 'invalid-name', 23 | 'unused-argument', 24 | 'too-many-locals', 25 | 'too-many-statements', 26 | 'too-many-instance-arguments', 27 | 'too-few-public-methods', 28 | 'too-many-arguments', 29 | 'too-many-instance-attributes', 30 | 'too-many-branches', 31 | 'too-many-lines', 32 | 'too-many-public-methods', 33 | 'bad-indentation', 34 | 'bad-continuation', 35 | 'import-error', 36 | 'wildcard-import', 37 | 'no-self-use', 38 | 'duplicate-code', 39 | 'wrong-import-position', 40 | 'no-member', 41 | 'unused-import' 42 | ] 43 | 44 | [tool.mypy] 45 | plugins = [ 46 | 'mypy_django_plugin.main' 47 | ] 48 | ignore_missing_imports = true 49 | pretty = true 50 | 51 | #[tool.mypy.plugins.'django-stubs'] # FIXME: this does not work with toml apparently 52 | #django_settings_module = 'wahlfang.settings.development' 53 | 54 | [tool.'mypy-*'.'migrations.*'] 55 | ignore_errors = true 56 | -------------------------------------------------------------------------------- /vote/templates/vote/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'vote/base.html' %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |
8 |
9 |
10 | {% csrf_token %} 11 |
12 | 13 |
14 |
15 | 24 | {% if form.access_code.errors %} 25 |
{{ form.access_code.errors }}
26 | {% endif %} 27 |
28 | 29 | 30 |
31 |
32 |
33 |
34 |
35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /management/templates/management/add_mobile_voter_name.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | {% load crispy_forms_filters %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Add Voters via QR Code

11 | What's the voters name? Hint: You can also leave the name empty. 12 | 13 |
14 |
15 | {% csrf_token %} 16 | 17 |
18 | 19 |
20 | Cancel 21 |
22 |
23 |
24 |
25 | {% endblock %} 26 | {% block footer_scripts %} 27 | 29 | 30 | 31 | 33 | 34 | {% endblock %} -------------------------------------------------------------------------------- /wahlfang/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | 8 | def setup(): 9 | """Setup environment for wahlfang""" 10 | if os.getenv('DJANGO_SETTINGS_MODULE') is not None: 11 | return 12 | 13 | if os.getenv('WAHLFANG_DEBUG'): 14 | os.environ['DJANGO_SETTINGS_MODULE'] = 'wahlfang.settings.development' 15 | return 16 | 17 | wahlfang_config = os.getenv('WAHLFANG_CONFIG', '/etc/wahlfang/settings.py') 18 | if not os.path.exists(wahlfang_config): 19 | print(f'Wahlfang configuration file at {wahlfang_config} does not exist', file=sys.stderr) 20 | print('Modify "WAHLFANG_CONFIG" environment variable to point at settings.py', file=sys.stderr) 21 | sys.exit(1) 22 | 23 | config_path = Path(wahlfang_config).resolve() 24 | sys.path.append(str(config_path.parent)) 25 | 26 | os.environ['DJANGO_SETTINGS_MODULE'] = config_path.stem 27 | 28 | 29 | def main(): 30 | setup() 31 | 32 | try: 33 | from django.core.management import execute_from_command_line # pylint: disable=import-outside-toplevel 34 | except ImportError as exc: 35 | raise ImportError( 36 | "Couldn't import Django. Are you sure it's installed and " 37 | "available on your PYTHONPATH environment variable? Did you " 38 | "forget to activate a virtual environment?" 39 | ) from exc 40 | execute_from_command_line(sys.argv) 41 | 42 | 43 | if __name__ == '__main__': 44 | main() 45 | -------------------------------------------------------------------------------- /vote/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | from django.contrib.auth.forms import UserCreationForm, UserChangeForm 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from .models import Application 7 | from .models import Election 8 | from .models import Voter 9 | 10 | 11 | class VoterCreationForm(UserCreationForm): 12 | class Meta: 13 | model = Voter 14 | fields = ('voter_id',) 15 | field_classes = {} 16 | 17 | 18 | class VoterChangeForm(UserChangeForm): 19 | class Meta: 20 | model = Voter 21 | fields = ('voter_id',) 22 | field_classes = {} 23 | 24 | 25 | class VoterAdmin(UserAdmin): 26 | add_form = VoterCreationForm 27 | form = VoterChangeForm 28 | model = Voter 29 | fieldsets = ( 30 | (None, {'fields': ('voter_id', 'password', 'session',)}), 31 | (_('Personal info'), {'fields': ('email',)}), 32 | (_('Status'), {'fields': ('voted',)}), 33 | ) 34 | add_fieldsets = ( 35 | (None, { 36 | 'classes': ('wide',), 37 | 'fields': ('voter_id', 'password1', 'password2', 'session')} 38 | ), 39 | (_('Personal info'), {'fields': ('email',)}), 40 | ) 41 | list_display = ('voter_id', 'session',) 42 | list_filter = () 43 | search_fields = ('voter_id', 'session', 'email',) 44 | ordering = ('voter_id',) 45 | filter_horizontal = tuple() 46 | 47 | 48 | admin.site.register(Election) 49 | admin.site.register(Application) 50 | admin.site.register(Voter, VoterAdmin) 51 | -------------------------------------------------------------------------------- /vote/authentication.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import BaseBackend 2 | from django.contrib.auth.decorators import user_passes_test 3 | 4 | from vote.models import Voter 5 | 6 | 7 | def voter_login_required(function=None, redirect_field_name=None): 8 | """ 9 | Decorator for views that checks that the voter is logged in, redirecting 10 | to the log-in page if necessary. 11 | """ 12 | actual_decorator = user_passes_test( 13 | lambda u: u.is_authenticated and isinstance(u, Voter), 14 | redirect_field_name=redirect_field_name 15 | ) 16 | if function: 17 | return actual_decorator(function) 18 | return actual_decorator 19 | 20 | 21 | class AccessCodeBackend(BaseBackend): 22 | def authenticate(self, request, **kwargs): 23 | access_code = kwargs.pop('access_code', None) 24 | if access_code is None: 25 | return None 26 | 27 | voter_id, password = Voter.split_access_code(access_code) 28 | if not voter_id: 29 | return None 30 | 31 | try: 32 | voter = Voter.objects.get(voter_id=voter_id) 33 | except Voter.DoesNotExist: 34 | # Run the default password hasher once to reduce the timing 35 | # difference between an existing and a nonexistent user (#20760). 36 | Voter().set_password(password) 37 | else: 38 | if voter.check_password(password): 39 | if not voter.logged_in: 40 | voter.logged_in = True 41 | voter.save() 42 | voter.backend = 'vote.authentication.AccessCodeBackend' 43 | return voter 44 | 45 | return None 46 | 47 | def get_user(self, user_id): 48 | return Voter.objects.filter(pk=user_id).first() 49 | -------------------------------------------------------------------------------- /vote/static/img/question-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /management/templates/management/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |
10 |
11 | {% csrf_token %} 12 |
13 |

Management Login

14 |
15 |
16 | 22 | {% if form.username.errors %} 23 |
{{ form.username.errors }}
24 | {% endif %} 25 |
26 |
27 | 33 | {% if form.password.errors %} 34 |
{{ form.username.errors }}
35 | {% endif %} 36 |
37 | 38 | 39 |
40 |
41 |
42 |
43 |
44 |
45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /management/authentication.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import REDIRECT_FIELD_NAME 2 | from django.contrib.auth.backends import BaseBackend 3 | from django.contrib.auth.decorators import user_passes_test 4 | from django_auth_ldap.backend import LDAPBackend 5 | 6 | from management.models import ElectionManager 7 | 8 | 9 | def management_login_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url='management:login'): 10 | """ 11 | Decorator for views that checks that the voter is logged in, redirecting 12 | to the log-in page if necessary. 13 | """ 14 | actual_decorator = user_passes_test( 15 | lambda u: u.is_authenticated and isinstance(u, ElectionManager), 16 | login_url=login_url, 17 | redirect_field_name=redirect_field_name 18 | ) 19 | if function: 20 | return actual_decorator(function) 21 | return actual_decorator 22 | 23 | 24 | class ManagementBackend(BaseBackend): 25 | def authenticate(self, request, **kwargs): 26 | username = kwargs.pop('username', None) 27 | password = kwargs.pop('password', None) 28 | 29 | if username is None or password is None: 30 | return None 31 | 32 | try: 33 | user = ElectionManager.objects.get(username=username) 34 | except ElectionManager.DoesNotExist: 35 | # Run the default password hasher once to reduce the timing 36 | # difference between an existing and a nonexistent user (#20760). 37 | ElectionManager().set_password(password) 38 | else: 39 | if user.check_password(password): 40 | user.backend = 'management.authentication.ManagementBackend' 41 | return user 42 | 43 | return None 44 | 45 | def get_user(self, user_id): 46 | return ElectionManager.objects.filter(pk=user_id).first() 47 | 48 | 49 | class ManagementBackendLDAP(LDAPBackend): 50 | def get_user_model(self): 51 | return ElectionManager 52 | -------------------------------------------------------------------------------- /management/templates/management/add_mobile_voter_qr.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | {% load crispy_forms_filters %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |
11 |

Add Voters via QR Code

12 | Show the following QR code to the people you want to add to your election session. 13 | 14 |
15 |
{{ name }}
16 | QR Code 17 |
18 | The page refreshes automatically if it is scanned. However, you can also request a 19 | new one manually if somebody has a bad Internet connection. 20 | 21 |

22 | Add New 23 |
24 |
25 | {% csrf_token %} 26 | 27 | 28 |
29 |
30 |
31 |
32 |
33 |
34 | {% endblock %} 35 | {% block footer_scripts %} 36 | {# If the QR code was scanned and the user logged in successfully the page will automatically #} 37 | {# change to add_mobile_voter_name again #} 38 | 40 | 41 | 42 | 44 | 45 | {% endblock %} -------------------------------------------------------------------------------- /vote/templates/vote/tex/invitation.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | \usepackage[utf8]{inputenc} 3 | \usepackage[ 4 | colorlinks=false, 5 | ]{hyperref} 6 | 7 | \title{\vspace{-3.5cm}Invitation to Participate in an Online Election} 8 | \author{} 9 | \date{} 10 | 11 | \usepackage{natbib} 12 | \usepackage{graphicx} 13 | \usepackage{pifont} 14 | \usepackage[a4paper, left=3cm, right=3cm, bottom=2.5cm, top=3cm]{geometry} 15 | \usepackage{subfig} 16 | 17 | \pagenumbering{gobble} 18 | \newcommand{\cuthere}{% 19 | \noindent 20 | \raisebox{-2.8pt}[0pt][0.75\baselineskip]{\small\ding{34}} 21 | \unskip{\tiny\dotfill} 22 | } 23 | 24 | \begin{document} 25 | 26 | {% for token in tokens %} 27 | {% if forloop.counter0|divisibleby:2 == 0 %} 28 | \cuthere 29 | \vspace{1cm} 30 | {% endif %} 31 | 32 | \section*{Invitation to Participate in an Online Election} 33 | \noindent You have been in invited to attend the following online election session:\\\\ 34 | Title: {{session.title}}\\ 35 | {% if session.meeting_link %}% 36 | Meeting room: {{ session.meeting_link}}\\ 37 | {% endif %}% 38 | {% if session.start_date %}% 39 | Start date: {{session.start_date}}\\ 40 | {% endif %}% 41 | Access token: {{token.token}}\\ 42 | 43 | \noindent Access to the election session can be gained by either entering the access token 44 | in \url{https://vote.stustanet.de} or by scanning the following QR-Code: 45 | 46 | \begin{figure}[h]% 47 | \centering 48 | {% if meeting_link_qr %}% 49 | \subfloat[Meeting Link]{{'{'}}\includegraphics[width=3.4cm]{{'{'}}{{meeting_link_qr}}{{'}'}} {{'}'}}% 50 | \qquad% 51 | {% endif %}% 52 | \subfloat[Voting Link]{{'{'}}\includegraphics[width=3.4cm]{{'{'}}{{token.path}}{{'}'}} {{'}'}}% 53 | \end{figure}% 54 | 55 | \noindent Wahlfang is an open source online voting system developed and hosted by StuStaNet. If you have any questions 56 | or want to help expanding the feature set feel free to contact us: \texttt{admins@stusta.de}\\ 57 | 58 | {% if forloop.counter0|divisibleby:2 == 0 %}% 59 | \pagebreak 60 | {% else %}% 61 | \vspace{0.5cm} 62 | {% endif %}% 63 | {% endfor %}% 64 | 65 | 66 | \end{document} -------------------------------------------------------------------------------- /vote/management/commands/create_election.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.conf import settings 4 | from django.core.management.base import BaseCommand 5 | from django.utils import timezone 6 | 7 | from vote.models import Election, Session 8 | 9 | 10 | class Command(BaseCommand): 11 | help = 'Creates a new election' 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument('-t', '--title', type=str, required=True) 15 | parser.add_argument('-m', '--max-votes-yes', type=int, required=True) 16 | parser.add_argument('-i', '--session-id', type=int, required=True) 17 | 18 | # Make things a little bit easier for dev and debugging convenience 19 | if settings.DEBUG: 20 | parser.add_argument('-s', '--start-date', type=str, default=timezone.now()) 21 | parser.add_argument('-a', '--application-due-date', type=str, 22 | default=timezone.now() + datetime.timedelta(days=1)) 23 | parser.add_argument('-e', '--end-date', type=str, default=timezone.now() + datetime.timedelta(days=2)) 24 | parser.add_argument('-l', '--meeting-link', type=str, default="http://meeting.link") 25 | parser.add_argument('-d', '--meeting-time', type=str, default=timezone.now() + datetime.timedelta(days=3)) 26 | else: 27 | parser.add_argument('-s', '--start-date', type=str, required=True) 28 | parser.add_argument('-a', '--application-due-date', type=str, required=True) 29 | parser.add_argument('-e', '--end-date', type=str, required=True) 30 | parser.add_argument('-l', '--meeting-link', type=str, required=True) 31 | parser.add_argument('-d', '--meeting-time', type=str, required=True) 32 | 33 | def handle(self, *args, **options): 34 | session = Session.objects.get(id=options['session_id']) 35 | election = Election.objects.create( 36 | title=options['title'], 37 | start_date=options['start_date'], 38 | end_date=options['end_date'], 39 | max_votes_yes=options['max_votes_yes'], 40 | session=session, 41 | ) 42 | self.stdout.write(self.style.SUCCESS('Successfully created election "%s" with ID %i' % (election, election.id))) 43 | -------------------------------------------------------------------------------- /management/templates/management/session_election_item.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | {{ election.title }} 4 | 9 | 10 | 11 | {% if not election.started and election.start_date %} 12 | Starts at {{ election.start_date|date:"Y-m-d H:i:s" }} 13 | {# Time for automatic reload #} 14 |
{{ election.start_date|date:"U" }}|
15 | {% elif not election.started %} 16 | Needs to be started manually 17 | {% elif election.is_open and election.end_date %} 18 | Open until {{ election.end_date|date:"Y-m-d H:i:s" }} 19 | {# Time for automatic reload #} 20 |
{{ election.end_date|date:"U" }}|
21 | {% elif election.closed %} 22 | Closed 23 | {% else %} 24 | Open, needs to be closed manually 25 | {% endif %} 26 |
27 |
28 | 29 | 49 | -------------------------------------------------------------------------------- /management/static/management/css/style.css: -------------------------------------------------------------------------------- 1 | /*@import url("https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css");*/ 2 | 3 | /* 4 | expands the clickable area of the main link 5 | to fill the parent container, because it's the nearest 6 | ancestor with "position:relative" 7 | */ 8 | .list-group-item-action .main-link::before { 9 | content: ''; 10 | position: absolute; 11 | top: 0; 12 | right: 0; 13 | bottom: 0; 14 | left: 0; 15 | margin-right: 30mm; 16 | } 17 | 18 | .right-margin { 19 | margin-right: 5mm; 20 | } 21 | 22 | .left-margin { 23 | margin-left: 5mm; 24 | } 25 | 26 | 27 | #content { 28 | height: 100% 29 | } 30 | 31 | .width100 { 32 | width: 100% 33 | } 34 | 35 | .hide{ 36 | display: none 37 | } 38 | 39 | .myclose { 40 | padding: 0; 41 | background-color: transparent; 42 | border: 0; 43 | -webkit-appearance: none; 44 | -moz-appearance: none; 45 | appearance: none; 46 | } 47 | 48 | p.monospace { 49 | font-family: "Courier New", monospace; 50 | } 51 | 52 | th.monospace { 53 | font-family: "Courier New", monospace; 54 | } 55 | 56 | .mycard-header:after { 57 | font-family: "Courier New", monospace; 58 | content: "-"; 59 | float: right; 60 | } 61 | 62 | .mycard-header.collapsed:after { 63 | /* symbol for "collapsed" panels */ 64 | font-family: "Courier New", monospace; 65 | content: "+"; 66 | } 67 | 68 | .cursor-pointer { 69 | cursor: pointer; 70 | } 71 | 72 | .voter-table { 73 | max-height: 400px; 74 | overflow-y: auto; 75 | } 76 | 77 | .voter-table > .list-group-item { 78 | border-width: 0 0 1px; 79 | } 80 | 81 | .hamburger { 82 | width: 20px; 83 | height: 2px; 84 | box-shadow: inset 0 0 0 32px, 0 -6px, 0 6px; 85 | margin: 10px 3px; 86 | box-sizing: border-box; 87 | display: inline-block; 88 | vertical-align: middle; 89 | position: relative; 90 | font-style: normal; 91 | color: currentColor; 92 | text-align: left; 93 | text-indent: -9999px; 94 | direction: ltr; 95 | } 96 | 97 | .hamburger:before { 98 | content: ""; 99 | pointer-events: none; 100 | } 101 | 102 | .hamburger:after { 103 | content: ""; 104 | pointer-events: none; 105 | } 106 | 107 | .centerimg { 108 | display: block; 109 | margin-left: auto; 110 | margin-right: auto; 111 | width: 100%; 112 | } -------------------------------------------------------------------------------- /vote/static/js/reload.js: -------------------------------------------------------------------------------- 1 | $(document).ready(() => { 2 | let timeout; 3 | 4 | function reload_callback() { 5 | setup_date_reload(); 6 | } 7 | 8 | function open(link){ 9 | window.open(link,"_self") 10 | } 11 | 12 | function reload(reload_id="#content") { 13 | console.log("Reloading " + reload_id) 14 | $(reload_id).load(location.pathname + " " + reload_id, reload_callback) 15 | } 16 | 17 | function setup_date_reload() { 18 | //setup a timer to reload the page if a start or end date of a election passed 19 | clearTimeout(timeout); 20 | const now_ms = new Date().getTime(); 21 | const times = $(".time").text().split('|').map(u_time => parseInt(u_time)); 22 | const wait_ms = times.map(time => (time + 1) * 1000 - now_ms).filter(t => t > 1000); 23 | const min_ms = Math.min(...wait_ms); 24 | if (min_ms < 24 * 60 * 60 * 1000) { 25 | console.log("Reloading in " + (min_ms / 1000) + "s"); 26 | timeout = setTimeout(reload.bind(this, '#electionCard'), min_ms); 27 | } 28 | } 29 | 30 | function setup_websocket() { 31 | const ws = new WebSocket(location.href.replace("http", "ws")); 32 | ws.onmessage = function (e) { 33 | const message = JSON.parse(e.data) 34 | if (message.reload) { 35 | reload(message.reload); 36 | }else if (message.alert){ 37 | if (message.alert.reload) 38 | // we want to reload the voters because the list might be outdated due to the deletion of 39 | // voters (optional idea: mark the invalid voters red) 40 | reload(message.alert.reload); 41 | $('#alertModalBody').find('p').html(message.alert.msg); 42 | $('#alertModalTitle').html(message.alert.title); 43 | $('#alertModal').modal('show'); 44 | }else if (message.succ){ 45 | let succ_div = $('#message-success'); 46 | succ_div.find('div').html(message.succ); 47 | succ_div.toggleClass('hide'); 48 | }else if(message.open){ 49 | open(message.open); 50 | } 51 | } 52 | ws.onopen = function (e) { 53 | console.log("Websocket connected"); 54 | } 55 | ws.onerror = function (e) { 56 | console.error("Websocket ERROR. Site will not reload automatically"); 57 | } 58 | ws.onclose = function (e) { 59 | console.error("Websocket Closed. Site will not reload automatically"); 60 | } 61 | } 62 | //$('#alertModal').on('hidden.bs.modal', function (e) { 63 | // reload(); 64 | //}); 65 | setup_date_reload(); 66 | setup_websocket(); 67 | }) 68 | -------------------------------------------------------------------------------- /management/consumers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from channels.generic.websocket import AsyncWebsocketConsumer 4 | 5 | 6 | class ElectionConsumer(AsyncWebsocketConsumer): 7 | 8 | async def connect(self): 9 | self.group = "Election-" + \ 10 | self.scope['url_route']['kwargs']['pk'] # pylint: disable=W0201 11 | await self.channel_layer.group_add(self.group, self.channel_name) 12 | await self.accept() 13 | 14 | async def disconnect(self, code): 15 | await self.channel_layer.group_discard(self.group, self.channel_name) 16 | 17 | async def send_reload(self, event): 18 | await self.send(text_data=json.dumps({ 19 | 'reload': event['id'], 20 | })) 21 | 22 | 23 | class SessionConsumer(AsyncWebsocketConsumer): 24 | 25 | async def connect(self): 26 | session = self.scope['url_route']['kwargs']['pk'] 27 | # reload if a new voter logged in, or if a election of the session was changed (like added) 28 | self.groups = ["Login-Session-" + session, "Session-" + session, 29 | "SessionAlert-" + session] # pylint: disable=W0201 30 | for group in self.groups: 31 | await self.channel_layer.group_add(group, self.channel_name) 32 | await self.accept() 33 | 34 | async def disconnect(self, code): 35 | for group in self.groups: 36 | await self.channel_layer.group_discard(group, self.channel_name) 37 | 38 | async def send_reload(self, event): 39 | await self.send(text_data=json.dumps({ 40 | 'reload': event['id'], 41 | })) 42 | 43 | async def send_alert(self, event): 44 | await self.send(text_data=json.dumps({ 45 | 'alert': {'title': event.get('title', 'Alert'), 'msg': event['msg'], 'reload': event.get('reload')}, 46 | })) 47 | 48 | async def send_succ(self, event): 49 | await self.send(text_data=json.dumps({ 50 | 'succ': event['msg'], 51 | })) 52 | 53 | 54 | class AddMobileConsumer(AsyncWebsocketConsumer): 55 | 56 | async def connect(self): 57 | self.group = "QR-Reload-" + \ 58 | self.scope['url_route']['kwargs']['pk'] # pylint: disable=W0201 59 | await self.channel_layer.group_add(self.group, self.channel_name) 60 | await self.accept() 61 | 62 | async def disconnect(self, code): 63 | await self.channel_layer.group_discard(self.group, self.channel_name) 64 | 65 | async def send_reload(self, event): 66 | await self.send(text_data=json.dumps({ 67 | 'open': event['link'], 68 | })) 69 | -------------------------------------------------------------------------------- /vote/templates/vote/spectator_election_item.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ election.title }}

4 | {% if election.end_date %} 5 | Voting Period: {{ election.start_date|date:"D Y-m-d H:i:s" }} 6 | - {{ election.end_date|date:"D Y-m-d H:i:s" }} (UTC{{ election.end_date|date:"O" }}) 7 | 8 |
{{ election.start_date|date:"U" }}|
9 |
{{ election.end_date|date:"U" }}|
10 | {% endif %} 11 |
12 |
13 | {% if election.closed and election.result_published %} 14 | 42 | {% elif not election.is_open and not election.closed %} 43 | 44 | Election has not started yet. 45 | 46 | 47 | {% elif election.is_open and not election.closed %} 48 | 49 | Election is currently ongoing. 50 | 51 | {% else %} 52 | 53 | Election is over but results haven't been published yet. Please ask your election manager to do so 54 | and refresh the page. 55 | 56 | {% endif %} 57 |
58 |
59 |
-------------------------------------------------------------------------------- /management/management/commands/create_admin.py: -------------------------------------------------------------------------------- 1 | from getpass import getpass 2 | from secrets import token_hex 3 | 4 | from django.conf import settings 5 | from django.core.mail import send_mail 6 | from django.core.management.base import BaseCommand 7 | from django.core.validators import validate_email 8 | 9 | from management.models import ElectionManager 10 | from management.utils import is_valid_sender_email 11 | 12 | 13 | class Command(BaseCommand): 14 | help = 'Create a new management login' 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument('-u', '--username', type=str, required=False) 18 | parser.add_argument('-e', '--email', type=str, required=False) 19 | parser.add_argument('--send-login-infos', action='store_true', default=False) 20 | 21 | def handle(self, *args, **options): 22 | username = options['username'] or input('Username: ') # nosec 23 | email = options['email'] or input('E-Mail: ') # nosec 24 | 25 | validate_email(email) 26 | if not is_valid_sender_email(email): 27 | self.stdout.write( 28 | self.style.ERROR( 29 | f'Email must end with either of the following: {", ".join([f"@{i}" for i in settings.VALID_MANAGER_EMAIL_DOMAINS])}')) 30 | return 31 | 32 | if ElectionManager.objects.filter(email=email).exists(): 33 | self.stdout.write(self.style.ERROR('An election manager with this email already exists')) 34 | return 35 | 36 | if options['send_login_infos']: 37 | password = token_hex(12) 38 | else: 39 | password = getpass('Password: ') # nosec 40 | repeat_password = getpass('Repeat Password: ') # nosec 41 | if password != repeat_password: 42 | self.stdout.write(self.style.ERROR('Passwords do not match')) 43 | return 44 | 45 | manager = ElectionManager(username=username, email=email) 46 | manager.set_password(password) 47 | 48 | if options['send_login_infos']: 49 | send_mail( 50 | f'Management login for {settings.URL}', 51 | f'A new management account on {settings.URL} has been created.\n' 52 | f'You can login under https://{settings.URL}/management ' 53 | f'with the following data:\n\n' 54 | f'Username: {username}\n' 55 | f'Password: {password}', 56 | settings.EMAIL_SENDER, 57 | [email], 58 | fail_silently=False, 59 | ) 60 | 61 | manager.save() 62 | self.stdout.write(self.style.SUCCESS( 63 | f'Successfully created management login with username: {username}, email: {email}')) 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wahlfang 2 | > A self-hostable online voting tool developed to include all the 3 | > features you would need to hold any online election you can dream of 4 | 5 | Developed by [StuStaNet](https://stustanet.de) Wahlfang is a small-ish Django project 6 | which aims at being an easy to use solution for online elections. From simple one-time 7 | votes about where to grab a coffee to large and long meetings with multiple different 8 | votes and elections - Wahlfang does it all. 9 | 10 | If you would like a new feature or have a bug to report please open an [issue](https://github.com/stustanet/wahlfang/issues). 11 | 12 | ## Getting Started 13 | To setup your own wahlfang instance for productive use see [deploying](docs/deploying.md). 14 | 15 | ### Metrics 16 | 17 | In the default configuration wahlfang exports some internal application statistics as [Prometheus](https://prometheus.io/) 18 | metrics at the endpoint `/metrics`. This behaviour can be turned off by settings `EXPORT_PROMETHEUS_METRICS = False` 19 | in the application settings. 20 | 21 | We use the [django-prometheus](https://github.com/korfuri/django-prometheus) project to export our exports. 22 | 23 | ## Contributing 24 | To just get the current version up and running simply 25 | ```bash 26 | $ git clone https://gitlab.stusta.de/stustanet/wahlfang.git 27 | $ cd wahlfang 28 | $ pip3 install -r requirements.txt 29 | $ pip3 install -r requirements_dev.txt 30 | $ export WAHLFANG_DEBUG=True 31 | $ export PYTHONPATH="$PYTHONPATH:." 32 | $ python3 wahlfang/manage.py migrate 33 | $ python3 wahlfang/manage.py runserver localhost:8000 34 | ``` 35 | 36 | Creating a local election management user: 37 | ```bash 38 | $ python3 wahlfang/manage.py create_admin 39 | ``` 40 | 41 | Login to the management interface running at [http://127.0.0.1:8000/management/](http://127.0.0.1:8000/management/). 42 | 43 | Run the linting and test suite 44 | ```bash 45 | $ make lint 46 | $ make test 47 | ``` 48 | 49 | If some model changed, you might have to make and/or apply migrations again: 50 | ```bash 51 | $ python3 wahlfang/manage.py makemigrations 52 | $ python3 wahlfang/manage.py migrate 53 | ``` 54 | Don't forget to add the new migration file to git. If the CI pipeline fails this is most likely the reason for it. 55 | 56 | ## Releasing 57 | The release process is automated in the gitlab ci. 58 | 59 | To make a new release bump the package version number in `wahlfang.__init__.py` and tag the commit with the same version 60 | number. The CI will then build the package, publish it as a gitlab release and push it to pypi. 61 | 62 | The pypi credentials must be set in the gitlab CI settings via the env variables `TWINE_USERNAME` and `TWINE_PASSWORD`. 63 | 64 | ## Development References 65 | 66 | - Django 3: https://docs.djangoproject.com/en/3.2/ 67 | -------------------------------------------------------------------------------- /management/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import views as auth_views 2 | from django.urls import path 3 | from prometheus_client import Gauge 4 | 5 | import vote.views 6 | from management import views 7 | from management.models import ElectionManager 8 | from vote.models import Election, Session 9 | 10 | app_name = 'management' 11 | 12 | election_gauge = Gauge('wahlfang_election_count', 'Wahlfang Number of Elections') 13 | election_gauge.set_function(lambda: Election.objects.all().count()) 14 | 15 | election_manager_gauge = Gauge('wahlfang_election_manager_count', 'Wahlfang Number of Election Managers') 16 | election_manager_gauge.set_function(lambda: ElectionManager.objects.all().count()) 17 | 18 | session_gauge = Gauge('wahlfang_session_count', 'Wahlfang Number of Sessions') 19 | session_gauge.set_function(lambda: Session.objects.all().count()) 20 | 21 | urlpatterns = [ 22 | path('', views.index, name='index'), 23 | path('help', views.help_page, name='help'), 24 | 25 | # Session 26 | path('meeting/', views.session_detail, name='session'), 27 | path('meeting//settings', views.session_settings, name='session_settings'), 28 | path('meeting//delete_session', views.delete_session, name='delete_session'), 29 | path('meeting//add_voters', views.add_voters, name='add_voters'), 30 | path('meeting//add_tokens', views.add_tokens, name='add_tokens'), 31 | path('meeting//add_mobile_voter', views.add_mobile_voter_get, name='add_mobile_voter'), 32 | path('meeting//add_election', views.add_election, name='add_election'), 33 | path('meeting//print_token', views.print_token, name='print_token'), 34 | path('meeting//import_csv', views.import_csv, name='import_csv'), 35 | path('meeting//spectator', views.spectator, name='spectator'), 36 | 37 | # Election 38 | path('election//add_application', views.election_upload_application, name='add_application'), 39 | path('election//edit/', views.election_upload_application, name='edit_application'), 40 | path('election//edit//delete_application', views.election_delete_application, 41 | name='delete_application'), 42 | path('election/', views.election_detail, name='election'), 43 | path('election//delete_voter', views.delete_voter, name='delete_voter'), 44 | path('election//delete_election', views.delete_election, name='delete_election'), 45 | path('election//export_csv', views.export_csv, name='export_csv'), 46 | 47 | # account management stuff 48 | path('login', views.LoginView.as_view(), name='login'), 49 | path('logout', auth_views.LogoutView.as_view( 50 | next_page='management:login', 51 | ), name='logout') 52 | ] 53 | -------------------------------------------------------------------------------- /vote/templates/vote/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'vote/base.html' %} 2 | {% load static %} 3 | 4 | {% block content %} 5 |
6 | 7 |
8 |
9 |
10 |

{{ title }}

11 | {% if meeting_link %} 12 | 13 | {% endif %} 14 |
15 |
16 |
17 | {% if not existing_elections %} 18 |
19 | There are no elections for this session 20 |
21 | {% elif open_elections %} 22 |
23 |
24 |

Open Elections

25 |
26 |
27 | {% for election, can_vote, edit in open_elections %} 28 | {% include 'vote/index_election_item.html' %} 29 | {% endfor %} 30 |
31 |
32 | {% endif %} 33 | {% if upcoming_elections %} 34 |
35 |
36 |

Upcoming Elections

37 |
38 |
39 | {% for election, can_vote, edit in upcoming_elections %} 40 | {% include 'vote/index_election_item.html' %} 41 | {% endfor %} 42 |
43 |
44 | {% endif %} 45 | {% if published_elections %} 46 |
47 |
48 |

Published Results

49 |
50 |
51 | {% for election, can_vote, edit in published_elections %} 52 | {% include 'vote/index_election_item.html' %} 53 | {% endfor %} 54 |
55 |
56 | {% endif %} 57 | {% if closed_elections %} 58 |
59 |
60 |

Closed Elections

61 |
62 |
63 | {% for election, can_vote, edit in closed_elections %} 64 | {% include 'vote/index_election_item.html' %} 65 | {% endfor %} 66 |
67 |
68 | {% endif %} 69 |
70 |
71 |
72 | {% endblock %} 73 | 74 | {% block footer_scripts %} 75 | {# Automatic reload of the page: #} 76 | {# - either if the start date / end date of a election is due#} 77 | {# - or if the admin started / stopped one election and the page is notified with a websocket#} 78 | 80 | 81 | {% endblock %} -------------------------------------------------------------------------------- /vote/templates/vote/spectator.html: -------------------------------------------------------------------------------- 1 | {% extends 'vote/base.html' %} 2 | {% load static %} 3 | {% load vote_extras %} 4 | 5 | {% block content %} 6 |
7 | 8 |
9 |
10 |
11 |

{{ title }} - Spectator View

12 | {% if meeting_link %} 13 | 14 | {% endif %} 15 |
16 |
17 |
18 | {% if not existing_elections %} 19 |
20 | There are no elections for this session 21 |
22 | {% elif open_elections %} 23 |
24 |
25 |

Open Elections

26 |
27 |
28 | {% for election in open_elections %} 29 | {% include 'vote/spectator_election_item.html' %} 30 | {% endfor %} 31 |
32 |
33 | {% endif %} 34 | {% if upcoming_elections %} 35 |
36 |
37 |

Upcoming Elections

38 |
39 |
40 | {% for election in upcoming_elections %} 41 | {% include 'vote/spectator_election_item.html' %} 42 | {% endfor %} 43 |
44 |
45 | {% endif %} 46 | {% if published_elections %} 47 |
48 |
49 |

Published Results

50 |
51 |
52 | {% for election in published_elections %} 53 | {% include 'vote/spectator_election_item.html' %} 54 | {% endfor %} 55 |
56 |
57 | {% endif %} 58 | {% if closed_elections %} 59 |
60 |
61 |

Closed Elections

62 |
63 |
64 | {% for election in closed_elections %} 65 | {% include 'vote/spectator_election_item.html' %} 66 | {% endfor %} 67 |
68 |
69 | {% endif %} 70 |
71 |
72 |
73 | {% endblock %} 74 | 75 | {% block footer_scripts %} 76 | {# Automatic reload of the page: #} 77 | {# - either if the start date / end date of a election is due#} 78 | {# - or if the admin started / stopped one election and the page is notified with a websocket#} 79 | 81 | 82 | {% endblock %} -------------------------------------------------------------------------------- /vote/static/vote/css/style.css: -------------------------------------------------------------------------------- 1 | #content { 2 | height: 100%; 3 | } 4 | 5 | @media (min-width: 768px) { 6 | .container, .container-md, .container-sm { 7 | max-width: 720px !important; 8 | } 9 | } 10 | 11 | .navbar-brand img { 12 | height: 1.6em; 13 | margin-top: -0.3em; 14 | } 15 | 16 | @media (max-width: 420px) { 17 | .navbar-brand img { 18 | height: 1.2em; 19 | margin-top: -0.2em; 20 | } 21 | } 22 | 23 | .card-body :last-child { 24 | margin-bottom: 0; 25 | } 26 | 27 | .card-dual .card-body:first-of-type { 28 | padding-bottom: 0.75rem; 29 | } 30 | 31 | .card-dual .card-body:last-of-type { 32 | border-top: 1px solid rgba(0,0,0,.125); 33 | padding-top: 0.75rem; 34 | } 35 | 36 | table.vote-list { 37 | margin: 0; 38 | margin-top: 1.75rem; 39 | } 40 | 41 | table.vote-list th { 42 | position: sticky; 43 | top: 0; 44 | z-index: 100; 45 | } 46 | 47 | table.vote-list th.choice { 48 | width: 15%; 49 | } 50 | 51 | table.vote-list th:nth-child(2), 52 | table.vote-list td:nth-child(2) { 53 | border-left: 1px solid #dee2e6; 54 | } 55 | 56 | table.vote-list th:nth-child(3), 57 | table.vote-list td:nth-child(3) { 58 | border-right: 1px solid #dee2e6; 59 | } 60 | 61 | table.vote-list td:last-child { 62 | background: #e9ecef; 63 | } 64 | 65 | table.vote-list tfoot td { 66 | border-left: none !important; 67 | border-right: none !important; 68 | background: none !important; 69 | color: #495057; 70 | font-size: 0.8em; 71 | } 72 | 73 | table.vote-list label { 74 | width: 100%; 75 | height: 100%; 76 | min-height: 50px; 77 | } 78 | 79 | table.vote-list td.applicant { 80 | padding-left: 0; 81 | max-width: 430px; 82 | } 83 | 84 | table.vote-list .applicant .card-body { 85 | padding: 1vw; 86 | padding-top: 0; 87 | padding-bottom: 0; 88 | } 89 | 90 | .applicant-picture, 91 | .vote-list .applicant .col-3 { 92 | width: auto; 93 | height: auto; 94 | min-width: 50px; 95 | max-width: 100px; 96 | max-height: 100px; 97 | } 98 | 99 | .vote-list .applicant-picture { 100 | float: right; 101 | margin-left: 7px; 102 | margin-bottom: 7px; 103 | border-radius: 4px; 104 | } 105 | 106 | .vote-list .applicant h6 { 107 | font-size: 1.1rem; 108 | margin-bottom: .4rem; 109 | } 110 | 111 | .vote-list .applicant p { 112 | font-size: 0.8em; 113 | } 114 | 115 | .image-input .img-wrapper { 116 | height: 100px; 117 | float: left; 118 | } 119 | 120 | .image-input .applicant-picture { 121 | margin-right: 1rem; 122 | } 123 | 124 | 125 | .myclose { 126 | padding: 0; 127 | background-color: transparent; 128 | border: 0; 129 | -webkit-appearance: none; 130 | -moz-appearance: none; 131 | appearance: none; 132 | } 133 | 134 | .pic { 135 | width: 100%; 136 | } 137 | 138 | .just { 139 | text-align: justify; 140 | } 141 | -------------------------------------------------------------------------------- /docs/settings.py: -------------------------------------------------------------------------------- 1 | # Wahlfang configuration file. 2 | # /etc/wahlfang/settings.py 3 | 4 | from wahlfang.settings.base import * 5 | from wahlfang.settings.wahlfang import * 6 | 7 | 8 | #: Default list of admins who receive the emails from error logging. 9 | ADMINS = ( 10 | ('Mailman Suite Admin', 'root@localhost'), 11 | ) 12 | 13 | # Postgresql datbase setup. 14 | DATABASES = { 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 17 | 'NAME': '', 18 | 'USER': '', 19 | 'PASSWORD': '', 20 | 'HOST': 'localhost', 21 | 'PORT': '5432', 22 | } 23 | } 24 | 25 | # 'collectstatic' command will copy all the static files here. 26 | # Alias this location from your webserver to `/static` 27 | STATIC_ROOT = '/var/www/wahlfang/static' 28 | # Alias this location from your webserver to `/media` 29 | MEDIA_ROOT = '/var/www/wahlfang/media' 30 | 31 | CHANNEL_LAYERS = { 32 | "default": { 33 | "BACKEND": "channels_redis.core.RedisChannelLayer", 34 | "CONFIG": { 35 | "hosts": [("127.0.0.1", 6379)], 36 | }, 37 | }, 38 | } 39 | 40 | # Make sure that this directory is created or Django will fail on start. 41 | LOGGING['handlers']['file']['filename'] = '/var/log/wahlfang/wahlfang.log' 42 | 43 | #: See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts 44 | ALLOWED_HOSTS = [ 45 | "localhost", # Good for debugging, keep it. 46 | # "lists.your-domain.org", 47 | # Add here all production domains you have. 48 | ] 49 | 50 | SECRET_KEY = 'MyVerrySecretKey' # FIXME: PLEASE CHANGE ME BEFORE DEPLOYING TO PRODUCTION 51 | 52 | # Mail, see https://docs.djangoproject.com/en/3.2/topics/email/#email-backends for more options 53 | EMAIL_HOST = '' 54 | EMAIL_SENDER = '' 55 | EMAIL_PORT = 25 56 | 57 | # LDAP, leave commented out to not use ldap authentication 58 | # for more details see https://django-auth-ldap.readthedocs.io/en/latest/example.html 59 | 60 | # AUTHENTICATION_BACKENDS = { 61 | # 'vote.authentication.AccessCodeBackend', 62 | # 'management.authentication.ManagementBackend', 63 | # 'management.authentication.ManagementBackendLDAP', # this authentication backend must be enabled for ldap auth 64 | # 'django.contrib.auth.backends.ModelBackend' 65 | # } 66 | # AUTH_LDAP_SERVER_URI = "ldap://ldap.stusta.de" 67 | # AUTH_LDAP_USER_DN_TEMPLATE = "cn=%(user)s,ou=account,ou=pnyx,dc=stusta,dc=mhn,dc=de" 68 | # AUTH_LDAP_START_TLS = True 69 | # AUTH_LDAP_USER_ATTR_MAP = {'email': 'mail'} 70 | # AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = True 71 | 72 | 73 | # Wahlfang specific settings 74 | 75 | # Whether to send election invitations / reminders from the Election Managers E-Mail or from `EMAIL_SENDER` 76 | SEND_FROM_MANAGER_EMAIL = True 77 | # List of valid domain names for your election managers emails in case `SEND_FROM_MANAGER_EMAIL` is True 78 | # This has to be configured such that the mail server can actually send mails from the valid manager emails. 79 | VALID_MANAGER_EMAIL_DOMAINS = [] 80 | URL = '' 81 | -------------------------------------------------------------------------------- /management/templates/management/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |

My Sessions

10 | Create New Session 12 |
13 |
14 |
15 | {% if len_sessions == 0 %} 16 |
17 | You have no sessions currently. 18 |
19 | {% endif %} 20 | {% for session in sessions %} 21 |
22 | 23 | 28 | {{ session.title }} 29 | 30 | {% if session.start_date %} 31 | {{ session.start_date|date:"l Y-m-d H:i:s" }} 32 | {% endif %} 33 | 34 |
35 | {% endfor %} 36 |
37 |
38 |
39 |
40 |
41 | {% for session in sessions %} 42 | 62 | {% endfor %} 63 | {% endblock %} 64 | 65 | {% block footer_scripts %} 66 | 68 | 70 | {% endblock %} 71 | 72 | -------------------------------------------------------------------------------- /management/models.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | from typing import List, Tuple 4 | 5 | from asgiref.sync import async_to_sync 6 | from channels.layers import get_channel_layer 7 | from django.conf import settings 8 | from django.contrib.auth.base_user import AbstractBaseUser 9 | from django.db import models 10 | 11 | from management.utils import is_valid_sender_email 12 | from vote.models import Session, Election, Voter 13 | 14 | 15 | class ElectionManager(AbstractBaseUser): 16 | username = models.CharField(unique=True, max_length=255) 17 | email = models.EmailField(null=True, blank=True) 18 | sessions = models.ManyToManyField(Session, related_name='managers', blank=True) 19 | 20 | USERNAME_FIELD = 'username' 21 | is_staff = False 22 | is_superuser = False 23 | 24 | def __str__(self): 25 | return f'{self.username}' 26 | 27 | @property 28 | def sender_email(self): 29 | if self.email and is_valid_sender_email(self.email): 30 | return self.email 31 | 32 | return settings.EMAIL_SENDER 33 | 34 | def get_session(self, pk): 35 | return self.sessions.filter(pk=pk).first() 36 | 37 | def get_election(self, pk): 38 | return Election.objects.filter(session__in=self.sessions).filter(pk=pk).first() 39 | 40 | def send_invite_bulk_threaded(self, voters_codes: List[Tuple[Voter, str]]): 41 | def runner(): 42 | failed_voters = list(filter(lambda i: i[0] is not None, 43 | (voter.send_invitation(code, self.sender_email) for voter, code in 44 | voters_codes))) 45 | 46 | def wait_heuristic(): 47 | # heuristic sleep to avoid a channel message before the manager's websocket reconnected 48 | # after x send emails this sleep should be unnecessary 49 | if len(failed_voters) < 10: 50 | time.sleep(1) 51 | 52 | group = "SessionAlert-" + str(voters_codes[0][0].session.pk) 53 | if len(failed_voters) == 0: 54 | wait_heuristic() 55 | # send message that tells the manager that all emails have been sent successfully 56 | async_to_sync(get_channel_layer().group_send)( 57 | group, 58 | {'type': 'send_succ', 'msg': "Emails send successfully!"} 59 | ) 60 | return 61 | failed_emails_str = "".join( 62 | [f"{voter.email}{e}" for voter, e in failed_voters]) 63 | 64 | msg = 'The following email addresses failed to send and thus are probably unassigned addresses. ' \ 65 | 'Please check them again on correctness.' \ 66 | '{}
EmailError
' 67 | 68 | # delete voters where the email could not be sent 69 | for voter, _ in failed_voters: 70 | voter.invalid_email = True 71 | voter.save() # may trigger a lot of automatic reloads 72 | wait_heuristic() 73 | async_to_sync(get_channel_layer().group_send)( 74 | group, 75 | {'type': 'send_alert', 'msg': msg.format(failed_emails_str), 'title': 'Error during email sending', 76 | 'reload': '#voterCard'} 77 | ) 78 | 79 | thread = threading.Thread( 80 | target=runner, 81 | args=()) 82 | thread.start() 83 | -------------------------------------------------------------------------------- /vote/templates/vote/application.html: -------------------------------------------------------------------------------- 1 | {% extends 'vote/base.html' %} 2 | {% load crispy_forms_filters %} 3 | {% load static %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |
12 | {% csrf_token %} 13 |
14 | {% if application_id %} 15 |

Edit Application

16 | {% else %} 17 |

New Application

18 | {% endif %} 19 |
20 |
21 | 22 | {{ form|as_crispy_errors }} 23 | 24 |
25 |
26 |
Display Name
27 |
28 |
29 | {{ form.display_name|as_crispy_field:"bootstrap4" }} 30 |
31 |
32 |
33 |
34 | 35 | {% if with_email %} 36 |
37 |
38 |
Contact E-Mail AddressOptional 39 |
40 | The e-mail address will not be visible to voters. 41 |
42 |
43 | {{ form.email|as_crispy_field:"bootstrap4" }} 44 |
45 |
46 |
47 |
48 | {% endif %} 49 | 50 | {% if with_description %} 51 |
52 |
53 |
Application InfoOptional
54 | This information is visible to voters! 55 | 56 | Add a short description of the applicant. 57 |
58 | 60 |
61 | 63 |
64 |
65 | 66 | Add a photo of the applicant. 67 | {{ form.avatar }} 68 |
69 |
70 | {% endif %} 71 | 72 | Cancel 74 |
75 |
76 |
77 |
78 |
79 | {% endblock %} 80 | 81 | {% block footer_scripts %} 82 | 83 | {% endblock %} 84 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: python:3.8-buster 2 | 3 | stages: 4 | - test 5 | - package 6 | - upload 7 | - release 8 | 9 | variables: 10 | PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" 11 | PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic" 12 | 13 | all_tests: 14 | stage: test 15 | before_script: 16 | - apt-get update && apt-get install -y build-essential libldap2-dev libsasl2-dev 17 | - pip3 install virtualenv 18 | - virtualenv -q .venv 19 | - source .venv/bin/activate 20 | - pip install -U -r requirements.txt 21 | - pip install -U -r requirements_dev.txt 22 | script: 23 | - make test 24 | 25 | bandit: 26 | stage: test 27 | before_script: 28 | - pip3 install virtualenv 29 | - virtualenv -q .venv 30 | - source .venv/bin/activate 31 | - pip install bandit~=1.6.2 32 | script: 33 | - make bandit 34 | 35 | mypy: 36 | stage: test 37 | before_script: 38 | - apt-get update && apt-get install -y build-essential libldap2-dev libsasl2-dev 39 | - pip3 install virtualenv 40 | - virtualenv -q .venv 41 | - source .venv/bin/activate 42 | - pip install -U -r requirements.txt 43 | - pip install -U -r requirements_dev.txt 44 | script: 45 | - make mypy 46 | allow_failure: true 47 | 48 | pylint: 49 | stage: test 50 | before_script: 51 | - apt-get update && apt-get install -y build-essential libldap2-dev libsasl2-dev 52 | - pip3 install virtualenv 53 | - virtualenv -q .venv 54 | - source .venv/bin/activate 55 | - pip install -U -r requirements.txt 56 | - pip install -U -r requirements_dev.txt 57 | script: 58 | - make pylint 59 | 60 | package: 61 | stage: package 62 | before_script: 63 | - pip3 install virtualenv 64 | - virtualenv -q .venv 65 | - source .venv/bin/activate 66 | - pip install -U build 67 | script: 68 | - make package 69 | artifacts: 70 | paths: 71 | - dist/ 72 | expire_in: 2 months 73 | 74 | upload_job: 75 | stage: upload 76 | image: curlimages/curl:latest 77 | needs: 78 | - job: package 79 | artifacts: true 80 | rules: 81 | - if: $CI_COMMIT_TAG # only run when we publish a new tag 82 | script: 83 | - 'ls dist' 84 | - 'curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file dist/*.whl "${PACKAGE_REGISTRY_URL}/${CI_COMMIT_TAG}/wahlfang.whl"' 85 | - 'curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file dist/*.tar.gz "${PACKAGE_REGISTRY_URL}/${CI_COMMIT_TAG}/wahlfang.tar.gz"' 86 | 87 | release_job: 88 | stage: release 89 | image: registry.gitlab.com/gitlab-org/release-cli:latest 90 | needs: 91 | - job: upload_job 92 | rules: 93 | - if: $CI_COMMIT_TAG # only run when we publish a new tag 94 | script: 95 | - echo 'running release_job' 96 | release: 97 | name: 'Release $CI_COMMIT_TAG' 98 | description: 'Created using the release-cli' 99 | tag_name: '$CI_COMMIT_TAG' 100 | ref: '$CI_COMMIT_TAG' 101 | assets: 102 | links: 103 | - name: "wahlfang-${CI_COMMIT_TAG}.whl" 104 | url: "${PACKAGE_REGISTRY_URL}/${CI_COMMIT_TAG}/wahlfang.whl" 105 | - name: "wahlfang-${CI_COMMIT_TAG}.tar.gz" 106 | url: "${PACKAGE_REGISTRY_URL}/${CI_COMMIT_TAG}/wahlfang.tar.gz" 107 | 108 | publish_job: 109 | stage: release 110 | needs: 111 | - job: package 112 | artifacts: true 113 | rules: 114 | - if: $CI_COMMIT_TAG # only run when we publish a new tag 115 | before_script: 116 | - pip3 install virtualenv 117 | - virtualenv -q .venv 118 | - source .venv/bin/activate 119 | - pip install -U twine 120 | script: 121 | - python -m twine upload dist/* -u $TWINE_USERNAME -p $TWINE_PASSWORD --non-interactive -------------------------------------------------------------------------------- /management/templates/management/application.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load crispy_forms_filters %} 3 | {% load static %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 | {% if application_id %} 11 |
13 | {% else %} 14 | 16 | {% endif %} 17 | {% csrf_token %} 18 |
19 | {% if application_id %} 20 |

Edit {% if election.voters_self_apply %}Application{% else %}Option{% endif %}

21 | {% else %} 22 |

New {% if election.voters_self_apply %}Application{% else %}Option{% endif %}

23 | {% endif %} 24 |
25 |
26 | 27 | {{ form|as_crispy_errors }} 28 | 29 |
30 |
31 |
Display Name
32 |
33 |
34 | {{ form.display_name|as_crispy_field:"bootstrap4" }} 35 |
36 |
37 |
38 |
39 | 40 | {% if with_email %} 41 |
42 |
43 |
Contact E-Mail AddressOptional 44 |
45 | The e-mail address will not be visible to voters. 46 |
47 |
48 | {{ form.email|as_crispy_field:"bootstrap4" }} 49 |
50 |
51 |
52 |
53 | {% endif %} 54 | 55 | {% if with_description %} 56 |
57 |
58 |
Application InfoOptional
59 | This information is visible to voters! 60 | 61 | Add a short description of the applicant. 62 |
63 | 65 |
66 | 68 |
69 |
70 | 71 | Add a photo of the applicant. 72 | {{ form.avatar }} 73 |
74 |
75 | {% endif %} 76 | 77 |
78 |
79 |
80 |
81 |
82 | {% endblock %} 83 | 84 | {% block footer_scripts %} 85 | 86 | {% endblock %} 87 | -------------------------------------------------------------------------------- /vote/templates/vote/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block title %}StuStaNet Online Voting System{% endblock %} 15 | 16 | 17 |
18 | 46 | 47 |
48 |
49 |
50 | {% if messages %} 51 |
52 | {% for message in messages %} 53 | 61 | {% endfor %} 62 |
63 | {% endif %} 64 | {% if form.errors %} 65 |
66 | {% for error in form.non_field_errors %} 67 | 74 | {% endfor %} 75 |
76 | {% endif %} 77 |
78 |
79 | {% block content_pre %}{% endblock %} 80 | {% block content %} 81 | No content here. 82 | {% endblock %} 83 |
84 | 85 |
86 | {% block footer_scripts %} 87 | {% endblock %} 88 | 89 | 90 | -------------------------------------------------------------------------------- /vote/static/bootstrap-4.5.3-dist/css/bootstrap-reboot.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.5.3 (https://getbootstrap.com/) 3 | * Copyright 2011-2020 The Bootstrap Authors 4 | * Copyright 2011-2020 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([class]){color:inherit;text-decoration:none}a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit;text-align:-webkit-match-parent}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.min.css.map */ -------------------------------------------------------------------------------- /docs/deploying.md: -------------------------------------------------------------------------------- 1 | # Deploying Wahlfang for Production Use 2 | 3 | ## Install 4 | To install `wahlfang` simply install it from PyPi 5 | 6 | ```shell 7 | $ pip install wahlfang 8 | ``` 9 | 10 | ## Database 11 | We recommend setting up a postgresql database for production use, although django allows mysql and sqlite 12 | (please do not use this one for production use, please) as well. All database backends supported by django 13 | can be used. 14 | 15 | ## Settings 16 | Wahlfang can be customized using a configuration file at `/etc/wahlfang/settings.py`. 17 | The path to this configuration file can be changed by setting the `WAHLFANG_CONFIG` environment variable. 18 | 19 | A starting point for a minimum production ready settings file can be found [here](settings.py). 20 | 21 | After configuring your database make sure to not forget the required database migrations. Simply run 22 | 23 | ```shell 24 | $ wahlfang migrate 25 | ``` 26 | 27 | After configuring a suitable `STATIC_ROOT` for your deployment which will contain all static assets served by your webserver run 28 | 29 | ```shell 30 | $ wahlfang collectstatic 31 | ``` 32 | 33 | ### Management commands 34 | You can create a local election management user with: 35 | ```bash 36 | $ wahlfang create_admin 37 | ``` 38 | 39 | ### Non-Python Requirements 40 | 41 | * Nginx 42 | * Daphne 43 | * Redis 44 | * PDFLatex (only needed when you want to print invite lists for elections) 45 | 46 | ## Nginx + Daphne 47 | 48 | ### `daphne.service` 49 | 50 | ```ini 51 | [Unit] 52 | Description = daphne daemon 53 | After = network.target 54 | 55 | [Service] 56 | User = www-data 57 | Group = www-data 58 | RuntimeDirectory = daphne 59 | ExecStart = daphne wahlfang.asgi:application 60 | ExecReload = /bin/kill -s HUP $MAINPID 61 | KillMode=mixed 62 | TimeoutStopSec=5 63 | PrivateTmp=true 64 | 65 | [Install] 66 | WantedBy=multi-user.target 67 | ``` 68 | 69 | Example nginx config. 70 | 71 | ### `nginx` 72 | 73 | ``` 74 | upstream daphne_server { 75 | server localhost:8000; 76 | } 77 | server { 78 | listen 80 default_server; 79 | listen [::]:80 default_server; 80 | 81 | server_name _; 82 | 83 | return 301 https://$host$request_uri; 84 | } 85 | 86 | server { 87 | listen 443 ssl http2 default_server; 88 | listen [::]:443 ssl http2 default_server; 89 | ssl_certificate /path/to/ssl/fullchainfile; 90 | ssl_certificate_key /path/to/ssl/key; 91 | charset utf-8; 92 | 93 | location /static { 94 | # as configured in settings.py under STATIC_ROOT 95 | alias /var/www/wahlfang/static; 96 | } 97 | 98 | location /media { 99 | # as configured in settings.py under MEDIA_ROOT 100 | alias /var/www/wahlfang/media; 101 | } 102 | 103 | location / { 104 | proxy_pass http://daphne_server; 105 | 106 | proxy_http_version 1.1; 107 | proxy_set_header Upgrade $http_upgrade; 108 | proxy_set_header Connection "upgrade"; 109 | 110 | proxy_set_header Host $host; 111 | proxy_set_header X-Real-IP $remote_addr; 112 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 113 | } 114 | } 115 | ``` 116 | 117 | ## Periodic tasks 118 | Create a systemd service to run periodic tasks such as sending reminder e-mails for elections where this feature has 119 | been enabled. 120 | 121 | ### `wahlfang-reminders.timer` 122 | ```ini 123 | [Unit] 124 | Description=Wahlfang Election Reminders Timer 125 | 126 | [Timer] 127 | OnCalendar=*:0/10 128 | 129 | [Install] 130 | WantedBy=timers.target 131 | ``` 132 | 133 | ### `wahlfang-reminders.service` 134 | ```ini 135 | [Unit] 136 | Description=Wahlfang Election reminders 137 | 138 | [Service] 139 | # the specific user that our service will run as 140 | User = www-data 141 | Group = www-data 142 | ExecStart = wahlfang process_reminders 143 | TimeoutStopSec = 5 144 | PrivateTmp = true 145 | ``` 146 | -------------------------------------------------------------------------------- /vote/tests.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta, datetime 2 | 3 | from django.test import TestCase 4 | from django.utils import timezone 5 | from freezegun import freeze_time 6 | 7 | from vote.models import Election, Enc32, Voter, Session 8 | from vote.selectors import closed_elections, open_elections, published_elections, upcoming_elections 9 | 10 | 11 | class Enc32TestCase(TestCase): 12 | def test_encoding(self): 13 | for voter_id in (0, 12345, 999999): 14 | e = Enc32.encode(voter_id) 15 | d = Enc32.decode(e) 16 | self.assertEqual(voter_id, d) 17 | 18 | 19 | class VoterTestCase(TestCase): 20 | def test_access_code(self): 21 | for voter_id in (0, 12345, 999999): 22 | raw_password = Enc32.alphabet 23 | code = Voter.get_access_code(voter_id, raw_password) 24 | ret_voter_id, ret_password = Voter.split_access_code(code) 25 | self.assertEqual(voter_id, ret_voter_id) 26 | self.assertEqual(raw_password, ret_password) 27 | 28 | 29 | class ElectionSelectorsTest(TestCase): 30 | def test_election_selectors(self) -> None: 31 | now = datetime(year=2021, month=4, day=1, 32 | tzinfo=timezone.get_fixed_timezone(5)) 33 | before = now - timedelta(seconds=5) 34 | bbefore = now - timedelta(seconds=10) 35 | after = now + timedelta(seconds=5) 36 | freeze_time(now).start() 37 | 38 | session = Session.objects.create(title="TEST") 39 | # upcoming elections 40 | all_upcoming = set() 41 | all_upcoming.add(Election.objects.create( 42 | session=session, result_published=False)) 43 | all_upcoming.add(Election.objects.create( 44 | session=session, start_date=after, result_published=False)) 45 | # open elections 46 | all_opened = set() 47 | all_opened.add(Election.objects.create(session=session, 48 | start_date=now, result_published=False)) 49 | all_opened.add(Election.objects.create( 50 | session=session, start_date=before, end_date=after, result_published=False)) 51 | # published elections 52 | all_published = set() 53 | all_published.add(Election.objects.create(session=session, start_date=bbefore, end_date=before, 54 | result_published=True)) 55 | all_published.add(Election.objects.create(session=session, start_date=before, end_date=now, 56 | result_published=True)) 57 | # closed (not published) elections 58 | all_closed = set() 59 | all_closed.add(Election.objects.create( 60 | session=session, start_date=bbefore, end_date=before, result_published=False)) 61 | all_closed.add(Election.objects.create( 62 | session=session, start_date=before, end_date=now, result_published=False)) 63 | 64 | # test upcoming 65 | upcoming = upcoming_elections(session) 66 | self.assertEqual(all_upcoming, set(upcoming)) 67 | for e in upcoming: 68 | self.assertTrue(not e.started and not e.closed and not e.is_open) 69 | 70 | # test open 71 | opened = open_elections(session) 72 | self.assertEqual(all_opened, set(opened)) 73 | for e in opened: 74 | self.assertTrue(e.started and not e.closed and e.is_open) 75 | 76 | # test published 77 | published = published_elections(session) 78 | self.assertEqual(all_published, set(published)) 79 | for e in published: 80 | self.assertTrue( 81 | e.started and e.closed and not e.is_open and e.result_published) 82 | 83 | # test closed 84 | closed = closed_elections(session) 85 | self.assertEqual(all_closed, set(closed)) 86 | for e in closed: 87 | self.assertTrue( 88 | e.started and e.closed and not e.is_open and not e.result_published) 89 | 90 | 91 | def gen_data(): 92 | session = Session.objects.create( 93 | title='Test session' 94 | ) 95 | voter, access_code = Voter.from_data( 96 | email='spam@spam.spam', 97 | session=session 98 | ) 99 | return voter, access_code 100 | -------------------------------------------------------------------------------- /vote/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-29 12:52 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | import vote.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Application', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('text', models.TextField(blank=True, max_length=250)), 22 | ('avatar', models.ImageField(blank=True, null=True, upload_to=vote.models.avatar_file_name)), 23 | ('last_name', models.CharField(max_length=256)), 24 | ('first_name', models.CharField(max_length=256)), 25 | ('room', models.CharField(blank=True, max_length=64)), 26 | ('email', models.EmailField(blank=True, max_length=254)), 27 | ], 28 | ), 29 | migrations.CreateModel( 30 | name='Election', 31 | fields=[ 32 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 33 | ('title', models.CharField(max_length=512)), 34 | ('start_date', models.DateTimeField(blank=True, null=True)), 35 | ('end_date', models.DateTimeField(blank=True, null=True)), 36 | ('max_votes_yes', models.IntegerField()), 37 | ], 38 | ), 39 | migrations.CreateModel( 40 | name='Session', 41 | fields=[ 42 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 43 | ('title', models.CharField(max_length=256)), 44 | ('meeting_link', models.CharField(blank=True, max_length=512, null=True)), 45 | ('start_date', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), 46 | ], 47 | ), 48 | migrations.CreateModel( 49 | name='Voter', 50 | fields=[ 51 | ('voter_id', models.IntegerField(primary_key=True, serialize=False)), 52 | ('password', models.CharField(max_length=256)), 53 | ('first_name', models.CharField(max_length=128)), 54 | ('last_name', models.CharField(max_length=128)), 55 | ('email', models.EmailField(max_length=254)), 56 | ('remind_me', models.BooleanField(default=False)), 57 | ('session', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='vote.Session')), 58 | ], 59 | ), 60 | migrations.CreateModel( 61 | name='Vote', 62 | fields=[ 63 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 64 | ('vote', models.CharField(choices=[('abstention', 'Abstention'), ('accept', 'Yes'), ('reject', 'No')], max_length=10)), 65 | ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='vote.Application')), 66 | ('election', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='vote.Election')), 67 | ], 68 | ), 69 | migrations.CreateModel( 70 | name='OpenVote', 71 | fields=[ 72 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 73 | ('election', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='open_votes', to='vote.Election')), 74 | ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vote.Voter')), 75 | ], 76 | ), 77 | migrations.AddField( 78 | model_name='election', 79 | name='session', 80 | field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='elections', to='vote.Session'), 81 | ), 82 | migrations.AddField( 83 | model_name='application', 84 | name='election', 85 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='application', to='vote.Election'), 86 | ), 87 | ] 88 | -------------------------------------------------------------------------------- /management/templates/management/add_session.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | {% load crispy_forms_filters %} 4 | 5 | {% block head %} 6 | 7 | {% endblock %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |
14 |

Create Session

15 |
16 | {% csrf_token %} 17 | 18 | {{ form|as_crispy_errors }} 19 | {% for field in form %} 20 | {% if field.html_name != "invite_text" and field.html_name != "email" %} 21 | {{ field|as_crispy_field }} 22 | {% endif %} 23 | {% endfor %} 24 | 25 |
26 |
29 | Advanced Options 30 |
31 |
32 |
Invite email template text
33 | The template has be written the python format string format. The following variables are available:
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {% for key, val in variables.items %} 43 | 44 | 45 | 46 | 47 | {% endfor %} 48 | 49 |
NameMeaning
{{ key }}{{ val }}
50 | 51 | Here is an example:

52 |

Dear,

You have been invited to our awesome meeting {title}. We are meeting 53 | on {meeting_link}. It 54 | takes place on the {start_date_en} at {start_time_en}. You can login with the following link: 55 | <a href="{login_url}">{login_url}</a>. 56 | You can also use the following access code on {base_url}: {access_code}

57 | Best regards,
58 | Your awesome Organizers 59 |

60 | 61 |

62 | {{ form.invite_text|as_crispy_field }} 63 |

64 |
65 |
Send test mail
66 |
67 |
68 | {{ form.email|as_crispy_field }} 69 |
70 |
71 | 75 |
76 |
77 |
78 |
79 | 80 |
81 | 82 | 83 | Cancel 85 | 86 |
87 |
88 |
89 |
90 |
91 |
92 | {% endblock %} 93 | 94 | {% block footer_scripts %} 95 | 97 | 99 | 100 | 101 | {% endblock %} 102 | -------------------------------------------------------------------------------- /vote/templates/vote/index_election_item.html: -------------------------------------------------------------------------------- 1 | {% load vote_extras %} 2 | 3 |
4 |
5 |

{{ election.title }} 6 | {% if not electon.started and not election.is_open and not election.closed and election.voters_self_apply %} 7 | {% if edit %} 8 | Delete 9 | application 10 | {% endif %} 11 | {% if edit %} Edit application 12 | {% else %} Apply {% endif %} 13 | {% endif %} 14 |

15 | {% if election.end_date %} 16 | Voting Period: {{ election.start_date|date:"D Y-m-d H:i:s" }} 17 | - {{ election.end_date|date:"D Y-m-d H:i:s" }} (UTC{{ election.end_date|date:"O" }}) 18 | 19 |
{{ election.start_date|date:"U" }}|
20 |
{{ election.end_date|date:"U" }}|
21 | {% endif %} 22 |
23 |
24 | {% if can_vote %} 25 | Vote Now! 26 | {% elif election.closed and election.result_published %} 27 | 55 | {% elif not electon.started and not election.is_open and not election.closed %} 56 | 57 | {% if election.end_date %} 58 | Voting starts {{ election.start_date|date:"l Y-m-d H:i:s" }} 59 | {% else %} 60 | Wait for the admin to start the election 61 | {% endif %} 62 | 63 | 64 | {% else %} 65 | 70 | {% endif %} 71 |
72 | {% if election.can_apply %} 73 |
74 |
{% if election.voters_self_apply %}Applicants{% else %}Options{% endif %}
75 | {% if max_votes_yes %} 76 | Up to {{ max_votes_yes }} applicants will be elected. 77 | {% endif %} 78 | {% if not election.applications.all %} 79 | No {% if election.voters_self_apply %}applicants{% else %}options to vote for{% endif %} so far... 80 | {% endif %} 81 |
82 |
83 | {% for application in election.applications.all|shuffle %} 84 |
85 |
86 | {% if application.avatar %} 87 | applicant-picture 88 | {# {% else %}#} 89 | {# applicant-picture#} 90 | {% endif %} 91 |
{% applicant_name application %}
92 |

{{ application.text|escape }}

93 |
94 |
95 | {% endfor %} 96 |
97 |
98 | {% endif %} 99 |
100 |
-------------------------------------------------------------------------------- /management/templates/management/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% block head %} 16 | {% endblock %} 17 | {% block title %}StuStaNet Wahlfang{% endblock %} 18 | 19 | 20 |
21 | 55 | 56 | 71 | 72 |
73 |
74 |
75 | {% if messages %} 76 |
77 | {% for message in messages %} 78 | 86 | {% endfor %} 87 |
88 | {% endif %} 89 |
90 | 96 |
97 |
98 |
99 | {% block content_pre %}{% endblock %} 100 | {% block content %} 101 | No content here. 102 | {% endblock %} 103 |
104 | 105 |
106 | {% block footer_scripts %} 107 | {% endblock %} 108 | 109 | 110 | -------------------------------------------------------------------------------- /vote/templates/vote/help.md: -------------------------------------------------------------------------------- 1 | ## How does voting work? 2 | 3 | Once you are part of an election session, you should have gotten an email to the mail address that the session creator (usually the election/meeting leader) has entered for you. 4 | If you didn't get the mail please take a look in your Spam folder and if it's not there talk to the session creator. 5 | 6 | This mail contains a link that you can use to access the voting session. 7 | Furthermore, a code is provided that you can enter on visiting [vote.stustanet.de](vote.stustanet.de). 8 | 9 | 10 | ### Vote Overview Page 11 | When there was already an election created, you will be greeted with a screen similar to this one: 12 | 13 | ![](../../../static/img/help_page/vote_waiting.png) 14 | 15 | If enabled by the session creator, you will be able to apply by clicking the grey **apply** button in the upper right corner of the image. 16 | 17 | Once the admin started the election, a blue *Vote Now!* button will appear (you might have to refresh the site) which you can click to open the voting page. 18 | 19 | ### Voting page 20 | ![](../../../static/img/help_page/vote_page.png) 21 | 22 | You can select all applicants that you want to give a *Yes* vote to. 23 | 24 | The admin might have limited the number of *Yes* votes you are allowed to cast. 25 | 26 | Don't forget to click the **Submit** button once you are done to finalize casting your vote. 27 | 28 | ### Apply page 29 | If you want to apply for the election you and it's enabled, you can go to the overview page and click on the grey **Apply** button. 30 | 31 | ![](../../../static/img/help_page/vote_apply.png) 32 | 33 | There will be a new page where you can choose a Display Name that will be shown to other people when they cast their votes. 34 | 35 | Furthermore you can give a contact mail and a short description about yourself and also upload a picture. 36 | 37 | Press **Submit** once you are done to apply for the election. 38 | 39 | You can always edit your application given that the election hasn't started yet by clicking **Edit Application** or **Delete Application** in the overview page. 40 | 41 | 42 | 43 | ## How can I create a new election? 44 | 45 | 46 | 47 | ### Getting a management account 48 | 49 | Before you are able to access the admin page, you will need a management account first. 50 | 51 | Please send a mail to [vorstand@stustanet.de](mailto:vorstand@stustanet.de) to get a management account. 52 | 53 | Now, visit https://vote.stustanet.de/management/ and use your credentials to log in. 54 | 55 | ### Creating a new session 56 | 57 | On https://vote.stustanet.de/management/ there should be a big green **Create Session**. Click it. 58 | 59 | 60 | 61 | 62 | ### Create an election 63 | 64 | 65 | ### Adding applicants 66 | 67 | 68 | ## I have a problem/idea/want to contribute 69 | ## What is this all about? 70 | 71 | [vote.stustanet.de](vote.stustanet.de) is an application that allows creating and managing secure elections and participate in them. 72 | 73 | It was created and is maintained by the administrators of the [StuStaNet e.V.](https://stustanet.de) 74 | 75 | ### Why? 76 | 77 | During the 2020 Covid-19 pandemic there was a big need for an online voting tool for having fair elections in the Studentenstadt Freimann (there are many events where elections are necessary). 78 | 79 | Because other online voting tools seem insescure (see below), the StuStaNet decided to implement its own system. 80 | 81 | ### How? 82 | 83 | vote.stustanet.de uses emails to send personal links to each voter. These mails can then be used to vote. 84 | 85 | Usually, during a session (say a Hausvollversammlung) there will be votes on multiple things. Therefore mutliple votes are brought together into a session. This has the big advantage that the voters stay the same and only one email per voter per session is necessary. 86 | 87 | ### Why don't you just use strawpoll, doodle, ... 88 | 89 | Creating secure online elections is a non-trivial task. 90 | 91 | *How can you be sure that only the people allowed to vote participate?* 92 | 93 | *How can you be sure that no one votes twice (e.g. via a second account or by reloading the site)?* 94 | 95 | Most online voting tools consist of a single link that each voter can use to access a webpage where they can cast their vote. Nothing stops voters to share the link with other people. 96 | 97 | vote.stustanet.de solves this problem by sending each voter a personal access link where it's only possible to vote once for each election. 98 | 99 | *How can you be sure that the election host doesn't access your data/keeps the data after the election?* 100 | 101 | Many online election tools are based in foreign countries and often have an incentive to gain money by tracing your activities online and placing ads. 102 | 103 | The administrators of StuStaNet e.V. are volunteers that you might know in person. All election data is deleted once a session is removed. The source code of vote.stustanet.de is open source and available [here](https://github.com/stustanet/wahlfang). 104 | -------------------------------------------------------------------------------- /management/templates/management/add_election.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | {% load crispy_forms_filters %} 4 | 5 | {% block head %} 6 | 7 | {% endblock %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |
14 |

Create election in {{ session.title }}

15 |
16 | {% csrf_token %} 17 | 18 | {{ form|as_crispy_errors }} 19 | {% for field in form %} 20 | {% if field.html_name != "remind_text" and field.html_name != "send_emails_on_start" and field.html_name != "voters_self_apply" and field.html_name != "email" and field.html_name != 'enable_abstention' and field.html_name != 'result_published' %} 21 | {{ field|as_crispy_field }} 22 | {% endif %} 23 | {% endfor %} 24 | 25 | {# collapse card example taken from https://www.codeply.com/p/cI2Pu6O5N5#} 26 |
27 |
30 | 31 | Advanced Options 32 | 33 |
34 |
35 | {{ form.result_published|as_crispy_field }} 36 | {{ form.enable_abstention|as_crispy_field }} 37 | {{ form.voters_self_apply|as_crispy_field }} 38 | {{ form.send_emails_on_start|as_crispy_field }} 39 |
Remind email template text
40 | The template has be written the python format string format. The following variables are available:
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {% for key, val in variables.items %} 50 | 51 | 52 | 53 | 54 | {% endfor %} 55 | 56 |
NameMeaning
{{ key }}{{ val }}
57 | 58 | Here is an example:

59 |

Dear,

This is a reminder that the {title} election has just began. 60 | So you can now start voting for your favourite candidates on 61 | <a href="{url}">{url}</a>. 62 | The access code can be found in the invitation email. Please be reminded that you can only vote until 63 | {end_time_en} on the {end_date_en}. 64 |

65 | Best regards,
66 | Your awesome Organizers 67 |

68 | 69 |

70 | {{ form.remind_text|as_crispy_field }} 71 |

72 |
73 |
Send test mail
74 |
75 |
76 | {{ form.email|as_crispy_field }} 77 |
78 |
79 | 83 |
84 |
85 |
86 |
87 |
88 | 89 | 92 | Cancel 94 |
95 |
96 |
97 |
98 |
99 |
100 | {% endblock %} 101 | {% block footer_scripts %} 102 | 104 | 106 | 107 | 108 | {% endblock %} 109 | -------------------------------------------------------------------------------- /management/templates/management/session_settings.html: -------------------------------------------------------------------------------- 1 | {% extends 'management/base.html' %} 2 | {% load static %} 3 | {% load crispy_forms_filters %} 4 | 5 | {% block head %} 6 | 7 | {% endblock %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |
14 |

Session Settings of {{ session.title }}

15 |
16 | {% csrf_token %} 17 | 18 | {{ form|as_crispy_errors }} 19 | 20 | {{ form.title|as_crispy_field }} 21 | {{ form.start_date|as_crispy_field }} 22 | {{ form.meeting_link|as_crispy_field }} 23 | 24 |
25 |
28 | Advanced Options 29 |
30 |
31 |
Additional election managers
32 | To add an additional election manager to this session enter his/her username in the following field. 33 | This means he/she will also have full access to this session and be able to modify it, create new 34 | elections as well as add new voters to it.
35 | Current election managers are: 36 |
37 | {% for manager in session.managers.all %} 38 |
39 | {{ manager.username }}{% if manager.username == user.username %} (you){% endif %}
40 | {% endfor %} 41 |
42 | {{ form.add_election_manager|as_crispy_field }} 43 |
44 | 45 |
Invite email template text
46 | The template has be written the python format string format. The following variables are available:
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {% for key, val in variables.items %} 56 | 57 | 58 | 59 | 60 | {% endfor %} 61 | 62 |
NameMeaning
{{ key }}{{ val }}
63 | 64 | Here is an example:

65 |

Dear,

You have been invited to our awesome meeting {title}. We are meeting 66 | on {meeting_link}. It 67 | takes place on the {start_date_en} at {start_time_en}. You can login with the following link: 68 | <a href="{login_url}">{login_url}</a>. 69 | You can also use the following access code on {base_url}: {access_code}

70 | Best regards,
71 | Your awesome Organizers 72 |

73 | 74 |

75 | {{ form.invite_text|as_crispy_field }} 76 |

77 |
78 |
Send test mail
79 |
80 |
81 | {{ form.email|as_crispy_field }} 82 |
83 |
84 | 88 |
89 |
90 |
91 |
92 | 93 |
94 | 95 | 96 | 97 | Cancel 99 | 100 |
101 |
102 |
103 |
104 |
105 |
106 | {% endblock %} 107 | 108 | {% block footer_scripts %} 109 | 111 | 113 | 114 | 115 | {% endblock %} 116 | -------------------------------------------------------------------------------- /management/static/management/css/DateTimePicker.css: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | 3 | jQuery DateTimePicker - Responsive flat design jQuery DateTime Picker plugin for Web & Mobile 4 | Version 0.1.39 5 | Copyright (c)2014-2019 Lajpat Shah 6 | Contributors : https://github.com/nehakadam/DateTimePicker/contributors 7 | Repository : https://github.com/nehakadam/DateTimePicker 8 | Documentation : https://nehakadam.github.io/DateTimePicker 9 | 10 | ----------------------------------------------------------------------------- */ 11 | 12 | .dtpicker-overlay { 13 | z-index: 2000; 14 | display: none; 15 | min-width: 300px; 16 | 17 | background: rgba(0, 0, 0, 0.2); 18 | font-size: 12px; 19 | 20 | -webkit-touch-callout: none; 21 | -webkit-user-select: none; 22 | -khtml-user-select: none; 23 | -moz-user-select: none; 24 | -ms-user-select: none; 25 | user-select: none; 26 | } 27 | 28 | .dtpicker-mobile { 29 | position: fixed; 30 | top: 0; 31 | left: 0; 32 | 33 | width: 100%; 34 | height: 100%; 35 | } 36 | 37 | .dtpicker-overlay * { 38 | -webkit-box-sizing: border-box; 39 | -moz-box-sizing: border-box; 40 | box-sizing: border-box; 41 | -ms-box-sizing: border-box; 42 | 43 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 44 | } 45 | 46 | .dtpicker-bg { 47 | width: 100%; 48 | height: 100%; 49 | 50 | font-family: Arial; 51 | } 52 | 53 | .dtpicker-cont { 54 | border: 1px solid #ECF0F1; 55 | } 56 | 57 | .dtpicker-mobile .dtpicker-cont { 58 | position: relative; 59 | top: 50%; 60 | 61 | -webkit-transform: translateY(-50%); 62 | -moz-transform: translateY(-50%); 63 | -o-transform: translateY(-50%); 64 | -ms-transform: translateY(-50%); 65 | transform: translateY(-50%); 66 | 67 | border: none; 68 | } 69 | 70 | .dtpicker-content { 71 | margin: 0 auto; 72 | padding: 1em 0; 73 | 74 | max-width: 500px; 75 | 76 | background: #fff; 77 | } 78 | 79 | .dtpicker-mobile .dtpicker-content { 80 | width: 97%; 81 | } 82 | 83 | .dtpicker-subcontent { 84 | position: relative; 85 | } 86 | 87 | .dtpicker-header { 88 | margin: 0.2em 1em; 89 | } 90 | 91 | .dtpicker-header .dtpicker-title { 92 | color: #2980B9; 93 | text-align: center; 94 | font-size: 1.1em; 95 | } 96 | 97 | .dtpicker-header .dtpicker-close { 98 | position: absolute; 99 | top: -0.7em; 100 | right: 0.3em; 101 | 102 | padding: 0.5em 0.5em 1em 1em; 103 | 104 | color: #FF3B30; 105 | font-size: 1.5em; 106 | 107 | cursor: pointer; 108 | } 109 | 110 | .dtpicker-header .dtpicker-close:hover { 111 | color: #FF3B30; 112 | } 113 | 114 | .dtpicker-header .dtpicker-value { 115 | padding: 0.8em 0.2em 0.2em 0.2em; 116 | color: #FF3B30; 117 | text-align: center; 118 | 119 | font-size: 1.4em; 120 | } 121 | 122 | .dtpicker-components { 123 | overflow: hidden; 124 | margin: 1em 1em; 125 | 126 | font-size: 1.3em; 127 | } 128 | 129 | .dtpicker-components * { 130 | margin: 0; 131 | padding: 0; 132 | } 133 | 134 | .dtpicker-components .dtpicker-compOutline { 135 | display: inline-block; 136 | float: left; 137 | } 138 | 139 | .dtpicker-comp2 { 140 | width: 50%; 141 | } 142 | 143 | .dtpicker-comp3 { 144 | width: 33.3%; 145 | } 146 | 147 | .dtpicker-comp4 { 148 | width: 25%; 149 | } 150 | 151 | .dtpicker-comp5 { 152 | width: 20%; 153 | } 154 | 155 | .dtpicker-comp6 { 156 | width: 16.66%; 157 | } 158 | 159 | .dtpicker-comp7 { 160 | width: 14.285%; 161 | } 162 | 163 | .dtpicker-components .dtpicker-comp { 164 | margin: 2%; 165 | text-align: center; 166 | } 167 | 168 | .dtpicker-components .dtpicker-comp > * { 169 | display: block; 170 | height: 30px; 171 | 172 | color: #2980B9; 173 | 174 | text-align: center; 175 | line-height: 30px; 176 | } 177 | 178 | .dtpicker-components .dtpicker-comp > *:hover { 179 | color: #2980B9; 180 | } 181 | 182 | .dtpicker-components .dtpicker-compButtonEnable { 183 | opacity: 1; 184 | } 185 | 186 | .dtpicker-components .dtpicker-compButtonDisable { 187 | opacity: 0.5; 188 | } 189 | 190 | .dtpicker-components .dtpicker-compButton { 191 | background: #FFFFFF; 192 | font-size: 140%; 193 | 194 | cursor: pointer; 195 | } 196 | 197 | .dtpicker-components .dtpicker-compValue { 198 | margin: 0.4em 0; 199 | width: 100%; 200 | border: none; 201 | background: #FFFFFF; 202 | 203 | font-size: 100%; 204 | 205 | -webkit-appearance: none; 206 | -moz-appearance: none; 207 | } 208 | 209 | .dtpicker-overlay .dtpicker-compValue:focus { 210 | outline: none; 211 | background: #F2FCFF; 212 | } 213 | 214 | .dtpicker-buttonCont { 215 | overflow: hidden; 216 | margin: 0.2em 1em; 217 | } 218 | 219 | .dtpicker-buttonCont .dtpicker-button { 220 | display: block; 221 | padding: 0.6em 0; 222 | width: 47%; 223 | background: #FF3B30; 224 | color: #FFFFFF; 225 | text-align: center; 226 | font-size: 1.3em; 227 | 228 | cursor: pointer; 229 | } 230 | 231 | .dtpicker-buttonCont .dtpicker-button:hover { 232 | color: #FFFFFF; 233 | } 234 | 235 | .dtpicker-singleButton .dtpicker-button { 236 | margin: 0.2em auto; 237 | } 238 | 239 | .dtpicker-twoButtons .dtpicker-buttonSet { 240 | float: left; 241 | } 242 | 243 | .dtpicker-twoButtons .dtpicker-buttonClear { 244 | float: right; 245 | } -------------------------------------------------------------------------------- /wahlfang/settings/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.urls import reverse_lazy 4 | 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 6 | 7 | ADMINS = ( 8 | ('Wahlfang Admins', 'root@localhost') 9 | ) 10 | 11 | DEBUG = False 12 | 13 | # export application statistics such as http request duration / latency 14 | # will also export # of manager accounts, # of sessions, # of elections 15 | EXPORT_PROMETHEUS_METRICS = True 16 | 17 | ALLOWED_HOSTS = ['*'] 18 | 19 | # Application definition 20 | 21 | INSTALLED_APPS = [ 22 | 'django.contrib.admin', 23 | 'django.contrib.auth', 24 | 'django.contrib.contenttypes', 25 | 'django.contrib.sessions', 26 | 'django.contrib.messages', 27 | 'django.contrib.staticfiles', 28 | 'crispy_forms', 29 | 'vote', 30 | 'management', 31 | 'channels', 32 | ] 33 | 34 | if EXPORT_PROMETHEUS_METRICS: 35 | INSTALLED_APPS += ['django_prometheus'] 36 | 37 | MIDDLEWARE = [ 38 | 'django.middleware.security.SecurityMiddleware', 39 | 'django.contrib.sessions.middleware.SessionMiddleware', 40 | 'django.middleware.common.CommonMiddleware', 41 | 'django.middleware.csrf.CsrfViewMiddleware', 42 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 43 | 'django.contrib.messages.middleware.MessageMiddleware', 44 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 45 | 'csp.middleware.CSPMiddleware', 46 | ] 47 | 48 | if EXPORT_PROMETHEUS_METRICS: 49 | MIDDLEWARE = ['django_prometheus.middleware.PrometheusBeforeMiddleware'] + \ 50 | MIDDLEWARE + \ 51 | ['django_prometheus.middleware.PrometheusAfterMiddleware'] 52 | 53 | ROOT_URLCONF = 'wahlfang.urls' 54 | 55 | TEMPLATES = [ 56 | { 57 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 58 | 'DIRS': [], 59 | 'APP_DIRS': True, 60 | 'OPTIONS': { 61 | 'context_processors': [ 62 | 'django.template.context_processors.debug', 63 | 'django.template.context_processors.request', 64 | 'django.contrib.auth.context_processors.auth', 65 | 'django.contrib.messages.context_processors.messages', 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | AUTHENTICATION_BACKENDS = { 72 | 'vote.authentication.AccessCodeBackend', 73 | 'management.authentication.ManagementBackend', 74 | 'django.contrib.auth.backends.ModelBackend' 75 | } 76 | 77 | ASGI_APPLICATION = 'wahlfang.asgi.application' 78 | 79 | CHANNEL_LAYERS = { 80 | "default": { 81 | "BACKEND": "channels.layers.InMemoryChannelLayer" 82 | } 83 | } 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 100 | }, 101 | ] 102 | 103 | PASSWORD_HASHERS = [ 104 | 'django.contrib.auth.hashers.Argon2PasswordHasher', 105 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 106 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 107 | 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 108 | ] 109 | 110 | LOGIN_URL = reverse_lazy('vote:code_login') 111 | LOGIN_REDIRECT_URL = reverse_lazy('vote:index') 112 | 113 | RATELIMIT_KEY = 'ip' 114 | 115 | # Internationalization 116 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 117 | 118 | LANGUAGE_CODE = 'en-us' 119 | 120 | TIME_ZONE = 'Europe/Berlin' 121 | 122 | USE_I18N = True 123 | 124 | USE_L10N = True 125 | 126 | USE_TZ = True 127 | 128 | # Static files (CSS, JavaScript, Images) 129 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 130 | 131 | STATIC_URL = '/static/' 132 | 133 | CRISPY_TEMPLATE_PACK = 'bootstrap4' 134 | 135 | # Content Security Policy 136 | # https://django-csp.readthedocs.io/en/latest/configuration.html#policy-settings 137 | CSP_DEFAULT_SRC = ("'self'",) 138 | CSP_IMG_SRC = ("'self'", "data:",) 139 | 140 | # File upload, etc... 141 | MEDIA_URL = '/media/' 142 | 143 | #: Default Logging configuration. 144 | LOGGING = { 145 | 'version': 1, 146 | 'disable_existing_loggers': False, 147 | 'filters': { 148 | 'require_debug_false': { 149 | '()': 'django.utils.log.RequireDebugFalse' 150 | } 151 | }, 152 | 'handlers': { 153 | 'mail_admins': { 154 | 'level': 'ERROR', 155 | 'filters': ['require_debug_false'], 156 | 'class': 'django.utils.log.AdminEmailHandler' 157 | }, 158 | 'file': { 159 | 'level': 'INFO', 160 | 'class': 'logging.handlers.WatchedFileHandler', 161 | 'filename': os.path.join(BASE_DIR, 'wahlfang.log'), 162 | 'formatter': 'verbose', 163 | }, 164 | 'console': { 165 | 'class': 'logging.StreamHandler', 166 | 'formatter': 'simple', 167 | }, 168 | }, 169 | 'loggers': { 170 | 'django.request': { 171 | 'handlers': ['mail_admins', 'file'], 172 | 'level': 'ERROR', 173 | 'propagate': True, 174 | }, 175 | 'django': { 176 | 'handlers': ['file'], 177 | 'level': 'ERROR', 178 | 'propagate': True, 179 | }, 180 | }, 181 | 'formatters': { 182 | 'verbose': { 183 | 'format': '%(levelname)s %(asctime)s %(process)d %(name)s %(message)s' 184 | }, 185 | 'simple': { 186 | 'format': '%(levelname)s %(message)s' 187 | }, 188 | }, 189 | } 190 | -------------------------------------------------------------------------------- /vote/forms.py: -------------------------------------------------------------------------------- 1 | from asgiref.sync import async_to_sync 2 | from channels.layers import get_channel_layer 3 | from django import forms 4 | from django.contrib.auth import authenticate 5 | from django.db import transaction 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from management.forms import ApplicationUploadForm 9 | from vote.models import Application, Voter, OpenVote, VOTE_CHOICES, Vote, VOTE_ABSTENTION, VOTE_ACCEPT, \ 10 | VOTE_CHOICES_NO_ABSTENTION 11 | 12 | 13 | class AccessCodeAuthenticationForm(forms.Form): 14 | error_messages = { 15 | 'invalid_login': _( 16 | "Invalid access code." 17 | ) 18 | } 19 | 20 | access_code = forms.CharField(label='access code') 21 | 22 | def __init__(self, *args, request=None, **kwargs): 23 | """ 24 | The 'request' parameter is set for custom auth use by subclasses. 25 | The form data comes in via the standard 'data' kwarg. 26 | """ 27 | self.request = request 28 | self.user_cache = None 29 | super().__init__(*args, **kwargs) 30 | 31 | # self.fields['access_code'].max_length = 128 32 | 33 | def clean(self): 34 | access_code = self.cleaned_data.get('access_code') 35 | if access_code: 36 | self.user_cache = authenticate(self.request, access_code=access_code) 37 | if self.user_cache is None: 38 | raise forms.ValidationError( 39 | self.error_messages['invalid_login'], 40 | code='invalid_login', 41 | ) 42 | 43 | return self.cleaned_data 44 | 45 | def get_user(self): 46 | return self.user_cache 47 | 48 | 49 | class AvatarFileInput(forms.ClearableFileInput): 50 | template_name = 'vote/image_input.html' 51 | 52 | 53 | class EmptyForm(forms.Form): 54 | pass 55 | 56 | 57 | class VoteBoundField(forms.BoundField): 58 | def __init__(self, form, field, name, application): 59 | super().__init__(form, field, name) 60 | self.application = application 61 | 62 | 63 | class VoteField(forms.ChoiceField): 64 | def __init__(self, *, application, enable_abstention=True, **kwargs): 65 | super().__init__( 66 | label=application.get_display_name(), 67 | choices=VOTE_CHOICES if enable_abstention else VOTE_CHOICES_NO_ABSTENTION, 68 | widget=forms.RadioSelect(), 69 | initial=VOTE_ABSTENTION if enable_abstention else None, 70 | **kwargs 71 | ) 72 | self.application = application 73 | 74 | def get_bound_field(self, form, field_name): 75 | return VoteBoundField(form, self, field_name, application=self.application) 76 | 77 | 78 | class VoteForm(forms.Form): 79 | def __init__(self, request, election, *args, **kwargs): 80 | super().__init__(*args, **kwargs) 81 | self.voter = Voter.objects.get(voter_id=request.user.voter_id) 82 | self.election = election 83 | self.request = request 84 | if self.election.max_votes_yes is not None: 85 | self.max_votes_yes = self.election.max_votes_yes 86 | else: 87 | self.max_votes_yes = self.election.applications.all().count() 88 | 89 | # dynamically construct form fields 90 | for application in self.election.applications.all(): 91 | self.fields[f'{application.pk}'] = VoteField(application=application, 92 | enable_abstention=self.election.enable_abstention) 93 | 94 | self.num_applications = self.election.applications.all().count() 95 | 96 | def clean(self): 97 | super().clean() 98 | if not OpenVote.objects.filter(election_id=self.election.pk, voter_id=self.voter.pk).exists(): 99 | raise forms.ValidationError('You are not allowed to vote') 100 | 101 | votes_yes = 0 102 | 103 | for _, vote in self.cleaned_data.items(): 104 | if vote == VOTE_ACCEPT: 105 | votes_yes += 1 106 | 107 | if votes_yes > self.max_votes_yes: 108 | raise forms.ValidationError( 109 | f'Too many "yes" votes, only max. {self.max_votes_yes} allowed.') 110 | 111 | def save(self, commit=True): 112 | votes = [ 113 | Vote( 114 | election=self.election, 115 | candidate=Application.objects.get(pk=int(name)), 116 | vote=value 117 | ) for name, value in self.cleaned_data.items() 118 | ] 119 | 120 | # existence of can_vote object already checked in clean() 121 | can_vote = OpenVote.objects.get(election_id=self.election.pk, voter_id=self.voter.pk) 122 | 123 | if commit: 124 | with transaction.atomic(): 125 | Vote.objects.bulk_create(votes) 126 | can_vote.delete() 127 | # notify manager that new votes were cast 128 | group = "Election-" + str(self.election.pk) 129 | async_to_sync(get_channel_layer().group_send)( 130 | group, 131 | {'type': 'send_reload', 'id': '#votes'} 132 | ) 133 | 134 | return votes 135 | 136 | 137 | class ApplicationUploadFormUser(ApplicationUploadForm): 138 | def __init__(self, election, request, *args, **kwargs): 139 | super().__init__(election, request, *args, **kwargs) 140 | if self.request.user.name: 141 | # these rules are meant for the StuStaNet Hausadmin election 142 | self.fields['display_name'].initial = self.request.user.name 143 | self.fields['display_name'].disabled = True 144 | self.fields['email'].required = True 145 | self.fields['email'].initial = self.request.user.email 146 | 147 | def save(self, commit=True): 148 | instance = super().save(commit=False) 149 | instance.voter = self.request.user 150 | instance.election = self.election 151 | 152 | if commit: 153 | instance.save() 154 | 155 | return instance 156 | -------------------------------------------------------------------------------- /vote/templates/vote/vote.html: -------------------------------------------------------------------------------- 1 | {% extends 'vote/base.html' %} 2 | {% load crispy_forms_tags %} 3 | {% load static %} 4 | {% load vote_extras %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
11 | {% if can_vote %} 12 |
13 | {% csrf_token %} 14 |
15 |

{{ title }}

16 | Voting Period: {{ election.application_due_date|date:"D Y-m-d H:i:s" }} 17 | - {{ election.end_date|date:"D Y-m-d H:i:s" }} (UTC{{ election.end_date|date:"O" }}) 18 |
19 |
20 | {% if form.non_field_errors %} 21 |
22 |
    23 | {% for error in form.non_field_errors %} 24 |
  • {{ error|escape }}
  • 25 | {% endfor %} 26 |
27 |
28 | {% endif %} 29 | {% if form.visible_fields %} 30 |
31 |
Instruction
32 |
33 | {% if max_votes_yes > 1 %}You may give up to {{ max_votes_yes }} YES votes.
{% endif %} 34 | NO votes should only be used if you are explicitly against 35 | {% if election.voters_self_apply %}a candidate{% else %}an option{% endif %}.
36 | The display order of the {% if election.voters_self_apply %}applicants{% else %} 37 | options{% endif %} is randomized.
38 | Your vote is anonymous. 39 |
40 |
41 | {% if max_votes_yes > 1 and max_votes_yes >= form.num_applications %} 42 |
43 | 46 |
47 | {% endif %} 48 | 49 | 50 | 51 | 52 | {% if election.enable_abstention %} 53 | 54 | {% endif %} 55 | 56 | 60 | 61 | 62 | 63 | {% for field in form.visible_fields|shuffle %} 64 | 65 | 80 | {% for radio in field %} 81 | 83 | {% endfor %} 84 | 85 | {% endfor %} 86 | 87 | 88 | 89 | 90 | 91 | 93 | 94 | 95 | 96 |
{% if election.voters_self_apply %}Applicant{% else %}Option{% endif %}AbstentionYES 58 | NO* 59 |
66 |
67 | {% if field.application.avatar %} 68 |
69 | applicant-picture 70 |
71 | {% endif %} 72 |
73 |
74 |
{{ field.label|escape }}
75 |

{{ field.application.text|escape }}

76 |
77 |
78 |
79 |
Up to {{ max_votes_yes }}
97 |
98 | 99 | {% else %} 100 |
101 |
102 | :( 103 |
Unfortunately there were no applicants.
104 |
105 |
106 | {% endif %} 107 |
108 | {% else %} 109 |
110 | Already voted 111 |
112 | {% endif %} 113 |
114 |
115 |
116 |
117 | {% endblock %} 118 | 119 | {% block footer_scripts %} 120 | 121 | {% endblock %} 122 | -------------------------------------------------------------------------------- /vote/static/bootstrap-4.5.3-dist/css/bootstrap-reboot.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.5.3 (https://getbootstrap.com/) 3 | * Copyright 2011-2020 The Bootstrap Authors 4 | * Copyright 2011-2020 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: border-box; 12 | } 13 | 14 | html { 15 | font-family: sans-serif; 16 | line-height: 1.15; 17 | -webkit-text-size-adjust: 100%; 18 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 19 | } 20 | 21 | article, aside, figcaption, figure, footer, header, hgroup, main, nav, section { 22 | display: block; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 28 | font-size: 1rem; 29 | font-weight: 400; 30 | line-height: 1.5; 31 | color: #212529; 32 | text-align: left; 33 | background-color: #fff; 34 | } 35 | 36 | [tabindex="-1"]:focus:not(:focus-visible) { 37 | outline: 0 !important; 38 | } 39 | 40 | hr { 41 | box-sizing: content-box; 42 | height: 0; 43 | overflow: visible; 44 | } 45 | 46 | h1, h2, h3, h4, h5, h6 { 47 | margin-top: 0; 48 | margin-bottom: 0.5rem; 49 | } 50 | 51 | p { 52 | margin-top: 0; 53 | margin-bottom: 1rem; 54 | } 55 | 56 | abbr[title], 57 | abbr[data-original-title] { 58 | text-decoration: underline; 59 | -webkit-text-decoration: underline dotted; 60 | text-decoration: underline dotted; 61 | cursor: help; 62 | border-bottom: 0; 63 | -webkit-text-decoration-skip-ink: none; 64 | text-decoration-skip-ink: none; 65 | } 66 | 67 | address { 68 | margin-bottom: 1rem; 69 | font-style: normal; 70 | line-height: inherit; 71 | } 72 | 73 | ol, 74 | ul, 75 | dl { 76 | margin-top: 0; 77 | margin-bottom: 1rem; 78 | } 79 | 80 | ol ol, 81 | ul ul, 82 | ol ul, 83 | ul ol { 84 | margin-bottom: 0; 85 | } 86 | 87 | dt { 88 | font-weight: 700; 89 | } 90 | 91 | dd { 92 | margin-bottom: .5rem; 93 | margin-left: 0; 94 | } 95 | 96 | blockquote { 97 | margin: 0 0 1rem; 98 | } 99 | 100 | b, 101 | strong { 102 | font-weight: bolder; 103 | } 104 | 105 | small { 106 | font-size: 80%; 107 | } 108 | 109 | sub, 110 | sup { 111 | position: relative; 112 | font-size: 75%; 113 | line-height: 0; 114 | vertical-align: baseline; 115 | } 116 | 117 | sub { 118 | bottom: -.25em; 119 | } 120 | 121 | sup { 122 | top: -.5em; 123 | } 124 | 125 | a { 126 | color: #007bff; 127 | text-decoration: none; 128 | background-color: transparent; 129 | } 130 | 131 | a:hover { 132 | color: #0056b3; 133 | text-decoration: underline; 134 | } 135 | 136 | a:not([href]):not([class]) { 137 | color: inherit; 138 | text-decoration: none; 139 | } 140 | 141 | a:not([href]):not([class]):hover { 142 | color: inherit; 143 | text-decoration: none; 144 | } 145 | 146 | pre, 147 | code, 148 | kbd, 149 | samp { 150 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 151 | font-size: 1em; 152 | } 153 | 154 | pre { 155 | margin-top: 0; 156 | margin-bottom: 1rem; 157 | overflow: auto; 158 | -ms-overflow-style: scrollbar; 159 | } 160 | 161 | figure { 162 | margin: 0 0 1rem; 163 | } 164 | 165 | img { 166 | vertical-align: middle; 167 | border-style: none; 168 | } 169 | 170 | svg { 171 | overflow: hidden; 172 | vertical-align: middle; 173 | } 174 | 175 | table { 176 | border-collapse: collapse; 177 | } 178 | 179 | caption { 180 | padding-top: 0.75rem; 181 | padding-bottom: 0.75rem; 182 | color: #6c757d; 183 | text-align: left; 184 | caption-side: bottom; 185 | } 186 | 187 | th { 188 | text-align: inherit; 189 | text-align: -webkit-match-parent; 190 | } 191 | 192 | label { 193 | display: inline-block; 194 | margin-bottom: 0.5rem; 195 | } 196 | 197 | button { 198 | border-radius: 0; 199 | } 200 | 201 | button:focus { 202 | outline: 1px dotted; 203 | outline: 5px auto -webkit-focus-ring-color; 204 | } 205 | 206 | input, 207 | button, 208 | select, 209 | optgroup, 210 | textarea { 211 | margin: 0; 212 | font-family: inherit; 213 | font-size: inherit; 214 | line-height: inherit; 215 | } 216 | 217 | button, 218 | input { 219 | overflow: visible; 220 | } 221 | 222 | button, 223 | select { 224 | text-transform: none; 225 | } 226 | 227 | [role="button"] { 228 | cursor: pointer; 229 | } 230 | 231 | select { 232 | word-wrap: normal; 233 | } 234 | 235 | button, 236 | [type="button"], 237 | [type="reset"], 238 | [type="submit"] { 239 | -webkit-appearance: button; 240 | } 241 | 242 | button:not(:disabled), 243 | [type="button"]:not(:disabled), 244 | [type="reset"]:not(:disabled), 245 | [type="submit"]:not(:disabled) { 246 | cursor: pointer; 247 | } 248 | 249 | button::-moz-focus-inner, 250 | [type="button"]::-moz-focus-inner, 251 | [type="reset"]::-moz-focus-inner, 252 | [type="submit"]::-moz-focus-inner { 253 | padding: 0; 254 | border-style: none; 255 | } 256 | 257 | input[type="radio"], 258 | input[type="checkbox"] { 259 | box-sizing: border-box; 260 | padding: 0; 261 | } 262 | 263 | textarea { 264 | overflow: auto; 265 | resize: vertical; 266 | } 267 | 268 | fieldset { 269 | min-width: 0; 270 | padding: 0; 271 | margin: 0; 272 | border: 0; 273 | } 274 | 275 | legend { 276 | display: block; 277 | width: 100%; 278 | max-width: 100%; 279 | padding: 0; 280 | margin-bottom: .5rem; 281 | font-size: 1.5rem; 282 | line-height: inherit; 283 | color: inherit; 284 | white-space: normal; 285 | } 286 | 287 | progress { 288 | vertical-align: baseline; 289 | } 290 | 291 | [type="number"]::-webkit-inner-spin-button, 292 | [type="number"]::-webkit-outer-spin-button { 293 | height: auto; 294 | } 295 | 296 | [type="search"] { 297 | outline-offset: -2px; 298 | -webkit-appearance: none; 299 | } 300 | 301 | [type="search"]::-webkit-search-decoration { 302 | -webkit-appearance: none; 303 | } 304 | 305 | ::-webkit-file-upload-button { 306 | font: inherit; 307 | -webkit-appearance: button; 308 | } 309 | 310 | output { 311 | display: inline-block; 312 | } 313 | 314 | summary { 315 | display: list-item; 316 | cursor: pointer; 317 | } 318 | 319 | template { 320 | display: none; 321 | } 322 | 323 | [hidden] { 324 | display: none !important; 325 | } 326 | /*# sourceMappingURL=bootstrap-reboot.css.map */ --------------------------------------------------------------------------------